Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.tidal.sdk.tidalapi.networking

import java.lang.reflect.Type
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import retrofit2.Converter
import retrofit2.Retrofit

/**
* Lets resource-create calls represent a successful dry run without a typed resource body.
*
* Create endpoints normally return a typed `*CreateSingleResourceDataDocument`. A dry run instead
* returns no representation: currently as an empty 204 response, and potentially later as another
* successful 2xx response with an empty body or a generic JSON:API document. Retrofit already
* handles 204 without invoking a converter; this factory covers the other two shapes while leaving
* ordinary typed create responses strict.
*/
internal class OptionalCreateResponseConverterFactory(
private val json: Json,
private val delegate: Converter.Factory,
) : Converter.Factory() {

override fun responseBodyConverter(
type: Type,
annotations: Array<Annotation>,
retrofit: Retrofit,
): Converter<ResponseBody, *>? {
if (!getRawType(type).simpleName.endsWith(CREATE_RESPONSE_TYPE_SUFFIX)) return null

val typedConverter =
delegate.responseBodyConverter(type, annotations, retrofit) ?: return null

return Converter { body ->
val contentType = body.contentType()
val payload = body.bytes()
val text = payload.decodeToString()

if (text.isBlank() || text.isGenericJsonApiSuccessDocument()) {
null
} else {
typedConverter.convert(payload.toResponseBody(contentType))
}
}
}

private fun String.isGenericJsonApiSuccessDocument(): Boolean {
val document =
runCatching { json.parseToJsonElement(this) as? JsonObject }.getOrNull() ?: return false

if ("errors" in document) return false

return document["data"] == JsonNull || ("data" !in document && "meta" in document)
}

private companion object {
const val CREATE_RESPONSE_TYPE_SUFFIX = "CreateSingleResourceDataDocument"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,14 @@ constructor(
}
}

private val jsonSerializer = createJsonSerializer()
private val jsonConverterFactory =
jsonSerializer.asConverterFactory("application/json".toMediaType())
private val converterFactories: List<Converter.Factory> =
listOf(
ScalarsConverterFactory.create(),
createJsonSerializer().asConverterFactory("application/json".toMediaType()),
OptionalCreateResponseConverterFactory(jsonSerializer, jsonConverterFactory),
jsonConverterFactory,
)

private fun provideOkHttpClient(credentialsProvider: CredentialsProvider): OkHttpClient =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package com.tidal.sdk.tidalapi.networking

import com.tidal.sdk.tidalapi.generated.models.ArtistsCreateSingleResourceDataDocument
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import retrofit2.Response
import retrofit2.http.POST

class CreateDryRunResponseCompatibilityTest {

private lateinit var server: MockWebServer

@BeforeEach
fun setUp() {
server = MockWebServer()
server.start()
}

@AfterEach
fun tearDown() {
server.shutdown()
}

private interface TestApi {
@POST("artists")
suspend fun createArtist(): Response<ArtistsCreateSingleResourceDataDocument>
}

private fun api(): TestApi =
RetrofitProvider(retryPolicy = null)
.provideRetrofit(server.url("/").toString(), FakeCredentialsProvider())
.create(TestApi::class.java)

@Test
fun `accepts the current empty 204 dry-run response`() = runTest {
server.enqueue(MockResponse().setResponseCode(204))

val response = api().createArtist()

assertEquals(204, response.code())
assertNull(response.body())
}

@Test
fun `accepts a future empty successful response`() = runTest {
server.enqueue(MockResponse().setResponseCode(200))

val response = api().createArtist()

assertEquals(200, response.code())
assertNull(response.body())
}

@Test
fun `accepts a future generic JSON API success document`() = runTest {
server.enqueue(
MockResponse()
.setResponseCode(202)
.setHeader("Content-Type", "application/vnd.api+json")
.setBody("""{"links":{"self":"/artists"},"meta":{}}""")
)

val response = api().createArtist()

assertEquals(202, response.code())
assertNull(response.body())
}

@Test
fun `continues to decode an ordinary typed create response`() = runTest {
server.enqueue(
MockResponse()
.setResponseCode(201)
.setHeader("Content-Type", "application/vnd.api+json")
.setBody(
"""
{
"data": {"id": "123", "type": "artists"},
"links": {"self": "/artists/123"}
}
"""
.trimIndent()
)
)

val response = api().createArtist()

assertEquals(201, response.code())
assertEquals("123", response.body()?.data?.id)
}

@Test
fun `does not hide a malformed typed create response`() = runTest {
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/vnd.api+json")
.setBody("""{"data":{"type":"artists"},"links":{"self":"/artists"}}""")
)

val result = runCatching { api().createArtist() }

assertFalse(result.isSuccess)
assertNotNull(result.exceptionOrNull())
}
}
Loading