diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 99ba457..13e4991 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -279,7 +279,7 @@ Server-to-server against `https://yce-api-01.perfectcorp.com`, from shared Kotli - **Auth is RSA, not a bearer secret.** The `id_token` is `client_id=×tamp=` encrypted with the user's public key under RSA/PKCS#1 v1.5, Base64'd. Access tokens are cached for their 2h TTL with a 5-minute refresh margin. - The `expect/actual` `RsaEncryptor` exists because the platforms disagree on key format: Android's `X509EncodedKeySpec` takes the X.509 SPKI key the portal issues, while iOS's `SecKeyCreateWithData` requires PKCS#1 — so the iOS actual walks the ASN.1 TLV structure to unwrap the inner `RSAPublicKey`. -- **Endpoint family is routed by garment category**: `v2.0`/`cloth-v3` with `garment_category` = `upper_body`/`lower_body`/`full_body`, or `v1.0`/`shoes` (no garment category). +- **The feature is routed by garment category**, both under `v2.0` (only auth is `v1.0`): `cloth-v3` with `garment_category` = `upper_body`/`lower_body`/`full_body`, or `shoes`, which takes no garment category and instead sends `gender` (always `male` by design — Worn is an app for men) and `style` (always `random`). Neither is user-facing. - Polling runs every 2s for up to 60 attempts; HTTP status codes map to user-actionable messages (401/403 credentials, 429 quota, 5xx service). See the README for the full walkthrough. diff --git a/README.md b/README.md index 7dc0048..efb90e0 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Worn talks to Perfect Corp's YCE server-to-server API at `https://yce-api-01.per - **Android** ([`RsaEncryptor.android.kt`](shared/src/androidMain/kotlin/com/github/worn/util/crypto/RsaEncryptor.android.kt)) — JCA, `RSA/ECB/PKCS1Padding` with `X509EncodedKeySpec`, which consumes the X.509 SPKI key the portal issues as-is. - **iOS** ([`RsaEncryptor.ios.kt`](shared/src/iosMain/kotlin/com/github/worn/util/crypto/RsaEncryptor.ios.kt)) — Security framework. `SecKeyCreateWithData` wants a PKCS#1 `RSAPublicKey`, *not* SPKI, so this file walks the ASN.1 TLV structure by hand (`stripSpkiHeader`) to unwrap the inner key before importing it. -**Two endpoint families, routed by garment category.** Tops, bottoms, and full outfits go to `v2.0` / `cloth-v3` with `garment_category` set to `upper_body`, `lower_body`, or `full_body`; shoes go to `v1.0` / `shoes`, which takes no garment category. One `GarmentCategory` enum drives both the chip the user taps and the endpoint selection. +**Two features, routed by garment category.** Both live under `v2.0` — only the auth handshake is `v1.0`. Tops, bottoms, and full outfits go to `cloth-v3` with `garment_category` set to `upper_body`, `lower_body`, or `full_body`; shoes go to `shoes`, which takes no garment category and instead sends two fixed values: `gender` is always `male` — Worn is an app for men, so that's a product decision rather than a parameter — and `style` is always `random`. Neither is exposed in the UI. One `GarmentCategory` enum drives both the chip the user taps and the feature selection. HTTP failures are mapped to messages a non-technical user can act on — 401/403 to "check your credentials", 429 to "wait and try again", 5xx to "try again later" — and credentials are never written to logs, only their lengths. diff --git a/shared/src/androidHostTest/kotlin/com/github/worn/remote/YouCamApiClientTest.kt b/shared/src/androidHostTest/kotlin/com/github/worn/remote/YouCamApiClientTest.kt index 38823ac..9fcfe73 100644 --- a/shared/src/androidHostTest/kotlin/com/github/worn/remote/YouCamApiClientTest.kt +++ b/shared/src/androidHostTest/kotlin/com/github/worn/remote/YouCamApiClientTest.kt @@ -11,6 +11,7 @@ import io.ktor.client.engine.mock.respond import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode +import io.ktor.http.content.OutgoingContent import io.ktor.http.headersOf import kotlinx.coroutines.test.runTest import java.security.KeyPairGenerator @@ -19,6 +20,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue @OptIn(ExperimentalEncodingApi::class) @@ -33,47 +35,109 @@ class YouCamApiClientTest { private fun jsonHeaders() = headersOf(HttpHeaders.ContentType, "application/json") - @Test - fun `tryOn runs auth, uploads, task and poll then returns the image`() = runTest { + /** + * Serves the full try-on sequence for exactly one feature, matching the *whole* path rather than + * a `/file/` substring — a wrong API version or feature name has to 404 here, otherwise a routing + * regression like the one behind issue #45 sails through the tests. Records every call and the + * task-creation body for assertion. + */ + private class TryOnEngine(feature: String) { val calls = mutableListOf() + var taskBody: String = "" + private set + + private val filePath = "/s2s/v2.0/file/$feature" + private val taskPath = "/s2s/v2.0/task/$feature" + val engine = MockEngine { request -> val path = request.url.encodedPath calls.add("${request.method.value} ${request.url.host}$path") when { - path.endsWith("/client/auth") -> - respond("""{"result":{"access_token":"tok"}}""", HttpStatusCode.OK, jsonHeaders()) - request.method == HttpMethod.Put -> + path == "/s2s/v1.0/client/auth" -> + respond("""{"result":{"access_token":"tok"}}""", HttpStatusCode.OK, jsonHeadersOf()) + request.method == HttpMethod.Put && request.url.host == "upload.example" -> respond("", HttpStatusCode.OK) - path.contains("/file/") -> + path == filePath && request.method == HttpMethod.Post -> respond( """{"data":{"files":[{"file_id":"fid","requests":[""" + """{"url":"https://upload.example/put","method":"PUT",""" + """"headers":{"Content-Type":"image/jpeg"}}]}]}}""", HttpStatusCode.OK, - jsonHeaders(), + jsonHeadersOf(), ) - path.contains("/task/") && request.method == HttpMethod.Post -> - respond("""{"data":{"task_id":"task-1"}}""", HttpStatusCode.OK, jsonHeaders()) - path.contains("/task/") && request.method == HttpMethod.Get -> + path == taskPath && request.method == HttpMethod.Post -> { + taskBody = (request.body as OutgoingContent.ByteArrayContent).bytes().decodeToString() + respond("""{"data":{"task_id":"task-1"}}""", HttpStatusCode.OK, jsonHeadersOf()) + } + path == "$taskPath/task-1" && request.method == HttpMethod.Get -> respond( """{"data":{"task_status":"success","results":{"url":"https://cdn.example/r.jpg"}}}""", HttpStatusCode.OK, - jsonHeaders(), + jsonHeadersOf(), ) request.url.host == "cdn.example" -> - respond(resultJpeg, HttpStatusCode.OK) + respond(byteArrayOf(9, 8, 7, 6), HttpStatusCode.OK) else -> respond("not found", HttpStatusCode.NotFound) } } - val client = client(engine) - val result = client.tryOn(byteArrayOf(1), byteArrayOf(2), GarmentCategory.TOP) + private fun jsonHeadersOf() = headersOf(HttpHeaders.ContentType, "application/json") + } + + @Test + fun `tryOn runs auth, uploads, task and poll then returns the image`() = runTest { + val mock = TryOnEngine(feature = "cloth-v3") + + val result = client(mock.engine).tryOn(byteArrayOf(1), byteArrayOf(2), GarmentCategory.TOP) + + assertContentEquals(resultJpeg, result) + assertTrue(mock.calls.any { it.contains("/client/auth") }) + assertEquals(2, mock.calls.count { it.contains("/s2s/v2.0/file/cloth-v3") && it.startsWith("POST") }) + assertTrue(mock.calls.any { it == "POST yce-api-01.perfectcorp.com/s2s/v2.0/task/cloth-v3" }) + assertTrue(mock.calls.any { it == "GET yce-api-01.perfectcorp.com/s2s/v2.0/task/cloth-v3/task-1" }) + } + + @Test + fun `tryOn sends garment_category and no shoes fields for clothes`() = runTest { + val mock = TryOnEngine(feature = "cloth-v3") + + client(mock.engine).tryOn(byteArrayOf(1), byteArrayOf(2), GarmentCategory.TOP) + + assertTrue(mock.taskBody.contains(""""garment_category":"upper_body""""), mock.taskBody) + assertFalse(mock.taskBody.contains("gender"), mock.taskBody) + assertFalse(mock.taskBody.contains("style"), mock.taskBody) + } + + @Test + fun `tryOn routes shoes to the v2 shoes feature with gender and style`() = runTest { + val mock = TryOnEngine(feature = "shoes") + + val result = client(mock.engine).tryOn(byteArrayOf(1), byteArrayOf(2), GarmentCategory.SHOES) assertContentEquals(resultJpeg, result) - assertTrue(calls.any { it.contains("/client/auth") }) - assertEquals(2, calls.count { it.contains("/file/") && it.startsWith("POST") }) - assertTrue(calls.any { it.startsWith("POST") && it.contains("/task/") }) - assertTrue(calls.any { it.startsWith("GET") && it.contains("/task/") }) + assertEquals(2, mock.calls.count { it.contains("/s2s/v2.0/file/shoes") && it.startsWith("POST") }) + assertTrue(mock.calls.any { it == "POST yce-api-01.perfectcorp.com/s2s/v2.0/task/shoes" }) + assertTrue(mock.taskBody.contains(""""gender":"male""""), mock.taskBody) + assertTrue(mock.taskBody.contains(""""style":"random""""), mock.taskBody) + assertFalse(mock.taskBody.contains("garment_category"), mock.taskBody) + } + + @Test + fun `tryOn gives a friendly message when a response has an unexpected shape`() = runTest { + // The exact v1.0-style `result` wrapper that produced the crash in issue #45. + val engine = MockEngine { request -> + when { + request.url.encodedPath.endsWith("/client/auth") -> + respond("""{"result":{"access_token":"tok"}}""", HttpStatusCode.OK, jsonHeaders()) + else -> respond("""{"result":{}}""", HttpStatusCode.OK, jsonHeaders()) + } + } + + val error = runCatching { client(engine).tryOn(byteArrayOf(1), byteArrayOf(2), GarmentCategory.SHOES) } + .exceptionOrNull() + + assertFalse(error?.message.orEmpty().contains("com.github.worn"), error?.message.orEmpty()) + assertTrue(error?.message?.contains("unexpected response", ignoreCase = true) == true) } @Test diff --git a/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/YouCamApiClient.kt b/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/YouCamApiClient.kt index e27d1c3..f1af477 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/YouCamApiClient.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/YouCamApiClient.kt @@ -17,6 +17,7 @@ import io.ktor.http.ContentType import io.ktor.http.contentType import io.ktor.http.isSuccess import kotlinx.coroutines.delay +import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.json.Json import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Clock @@ -33,7 +34,12 @@ class YouCamApiClient( private val secretStore: SecretStore, private val rsaEncryptor: RsaEncryptor, ) { - private val json = Json { ignoreUnknownKeys = true } + private val json = Json { + ignoreUnknownKeys = true + // Keeps the category-specific task fields (`garment_category`, `gender`, `style`) out of the + // request body when they don't apply, instead of sending them as explicit nulls. + explicitNulls = false + } private var cachedToken: String? = null private var tokenExpiryMillis: Long = 0 @@ -44,7 +50,7 @@ class YouCamApiClient( category: GarmentCategory, ): ByteArray { val feature = category.feature() - log("tryOn: start category=$category feature=${feature.type}") + log("tryOn: start category=$category feature=$feature") val token = authenticate() val personFileId = uploadImage(token, feature, personBytes) val garmentFileId = uploadImage(token, feature, garmentBytes) @@ -95,14 +101,14 @@ class YouCamApiClient( setBody(body) } ensureSuccess(response, "auth") - val token = json.decodeFromString(response.bodyAsText()).result.accessToken + val token = decode(YouCamAuthResponse.serializer(), response.bodyAsText(), "auth").result.accessToken log("auth: ok, requesting upload slots") return token } - private suspend fun uploadImage(token: String, feature: Feature, bytes: ByteArray): String { - log("upload: creating slot (${bytes.size}b) at /s2s/${feature.version}/file/${feature.type}") - val createResponse = request("$BASE_URL/s2s/${feature.version}/file/${feature.type}") { + private suspend fun uploadImage(token: String, feature: String, bytes: ByteArray): String { + log("upload: creating slot (${bytes.size}b) at /s2s/$API_VERSION/file/$feature") + val createResponse = request("$BASE_URL/s2s/$API_VERSION/file/$feature") { authorized(token) contentType(ContentType.Application.Json) setBody( @@ -115,7 +121,7 @@ class YouCamApiClient( ) } ensureSuccess(createResponse, "upload/create") - val entry = json.decodeFromString(createResponse.bodyAsText()) + val entry = decode(YouCamFileResponse.serializer(), createResponse.bodyAsText(), "upload/create") .data.files.firstOrNull() ?: error("Upload could not be prepared. Please try again.") val upload = entry.requests.firstOrNull() ?: error("Upload could not be prepared. Please try again.") @@ -130,39 +136,35 @@ class YouCamApiClient( private suspend fun createTask( token: String, - feature: Feature, + feature: String, personFileId: String, garmentFileId: String, category: GarmentCategory, ): String { - val response = request("$BASE_URL/s2s/${feature.version}/task/${feature.type}") { + val response = request("$BASE_URL/s2s/$API_VERSION/task/$feature") { authorized(token) contentType(ContentType.Application.Json) setBody( json.encodeToString( YouCamTaskRequest.serializer(), - YouCamTaskRequest( - srcFileId = personFileId, - refFileId = garmentFileId, - garmentCategory = category.garmentCategory(), - ), + category.taskRequest(srcFileId = personFileId, refFileId = garmentFileId), ), ) } ensureSuccess(response, "task/create") - val taskId = json.decodeFromString(response.bodyAsText()).data.taskId + val taskId = decode(YouCamTaskResponse.serializer(), response.bodyAsText(), "task/create").data.taskId log("task: created") return taskId } - private suspend fun pollTask(token: String, feature: Feature, taskId: String): String { + private suspend fun pollTask(token: String, feature: String, taskId: String): String { repeat(MAX_POLL_ATTEMPTS) { attempt -> val response = request( - "$BASE_URL/s2s/${feature.version}/task/${feature.type}/$taskId", + "$BASE_URL/s2s/$API_VERSION/task/$feature/$taskId", method = HttpMethod.GET, ) { authorized(token) } ensureSuccess(response, "task/poll") - val result = json.decodeFromString(response.bodyAsText()).data + val result = decode(YouCamPollResponse.serializer(), response.bodyAsText(), "task/poll").data log("poll: attempt ${attempt + 1} status=${result.status}") when (result.status.lowercase()) { STATUS_SUCCESS -> @@ -217,26 +219,40 @@ class YouCamApiClient( error(message) } + /** + * Decodes a response body, turning a contract mismatch into a message a user can act on. Without + * this the raw [kotlinx.serialization.SerializationException] — which names the internal DTO + * class — would reach the UI. The body is logged so the real shape stays debuggable. + */ + private fun decode(serializer: DeserializationStrategy, body: String, step: String): T = + runCatching { json.decodeFromString(serializer, body) }.getOrElse { + log("$step: decode failed: ${it.message} body=${body.take(BODY_PREVIEW_LEN)}") + error("YouCam returned an unexpected response. Please try again.") + } + private fun log(message: String) { println("[$LOG_TAG] $message") } private enum class HttpMethod { GET, POST, PUT } - /** Which YouCam endpoint family serves a garment category. */ - private data class Feature(val version: String, val type: String) - - private fun GarmentCategory.feature(): Feature = when (this) { - GarmentCategory.TOP, GarmentCategory.BOTTOM, GarmentCategory.FULL_BODY -> - Feature(version = "v2.0", type = "cloth-v3") - GarmentCategory.SHOES -> Feature(version = "v1.0", type = "shoes") + /** Which YouCam feature serves a garment category. Both live under the same [API_VERSION]. */ + private fun GarmentCategory.feature(): String = when (this) { + GarmentCategory.TOP, GarmentCategory.BOTTOM, GarmentCategory.FULL_BODY -> FEATURE_CLOTH + GarmentCategory.SHOES -> FEATURE_SHOES } - private fun GarmentCategory.garmentCategory(): String? = when (this) { - GarmentCategory.TOP -> "upper_body" - GarmentCategory.BOTTOM -> "lower_body" - GarmentCategory.FULL_BODY -> "full_body" - GarmentCategory.SHOES -> null + /** The two features take different task parameters; each set is spelled out here in one place. */ + private fun GarmentCategory.taskRequest(srcFileId: String, refFileId: String) = when (this) { + GarmentCategory.SHOES -> YouCamTaskRequest( + srcFileId = srcFileId, + refFileId = refFileId, + gender = SHOES_GENDER, + style = SHOES_STYLE, + ) + GarmentCategory.TOP -> YouCamTaskRequest(srcFileId, refFileId, garmentCategory = "upper_body") + GarmentCategory.BOTTOM -> YouCamTaskRequest(srcFileId, refFileId, garmentCategory = "lower_body") + GarmentCategory.FULL_BODY -> YouCamTaskRequest(srcFileId, refFileId, garmentCategory = "full_body") } private companion object { @@ -244,6 +260,20 @@ class YouCamApiClient( const val BODY_PREVIEW_LEN = 300 const val BASE_URL = "https://yce-api-01.perfectcorp.com" const val CONTENT_TYPE_JPEG = "image/jpeg" + + // Try-on features. Both the clothes and the shoes families live under v2.0; only the auth + // handshake is still v1.0. Routing shoes to v1.0 returns a legacy `result`-wrapped body that + // none of the response DTOs can decode. + const val API_VERSION = "v2.0" + const val FEATURE_CLOTH = "cloth-v3" + const val FEATURE_SHOES = "shoes" + + // Shoes-only task parameters. Worn is an app for men, so gender is pinned to "male" by + // product decision rather than exposed as a choice; style is left to the API's presets at + // random. Neither is threaded through the UI. + const val SHOES_GENDER = "male" + const val SHOES_STYLE = "random" + const val TOKEN_TTL_MILLIS = 2 * 60 * 60 * 1000L const val TOKEN_REFRESH_MARGIN_MILLIS = 5 * 60 * 1000L const val POLL_INTERVAL_MILLIS = 2000L diff --git a/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/YouCamApiModels.kt b/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/YouCamApiModels.kt index 616c0bb..70dc416 100644 --- a/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/YouCamApiModels.kt +++ b/shared/src/commonMain/kotlin/com/github/worn/data/source/remote/YouCamApiModels.kt @@ -3,8 +3,9 @@ package com.github.worn.data.source.remote import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -// Field names confirmed against live cloth-v3 responses. Note the wrapper differs by endpoint: -// auth wraps its payload in "result", while file/task/poll wrap in "data". +// Field names confirmed against live cloth-v3 responses. Note the wrapper differs by API *version*, +// not by endpoint: v1.0 (auth) wraps its payload in "result", while the whole v2.0 family — +// file/task/poll, for both cloth-v3 and shoes — wraps in "data". // Auth ------------------------------------------------------------------------------------------ @@ -53,11 +54,16 @@ internal data class YouCamFileResponse(@SerialName("data") val data: Payload) { // Task creation + polling ----------------------------------------------------------------------- +// `garment_category` is cloth-v3 only; `gender` and `style` are shoes only. The client's Json is +// configured with `explicitNulls = false`, so the fields that don't apply are omitted entirely +// rather than sent as nulls (which the endpoints reject). @Serializable internal data class YouCamTaskRequest( @SerialName("src_file_id") val srcFileId: String, @SerialName("ref_file_id") val refFileId: String, @SerialName("garment_category") val garmentCategory: String? = null, + val gender: String? = null, + val style: String? = null, ) @Serializable