Skip to content
Merged
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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id>&timestamp=<ms>` 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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<String>()
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -95,14 +101,14 @@ class YouCamApiClient(
setBody(body)
}
ensureSuccess(response, "auth")
val token = json.decodeFromString<YouCamAuthResponse>(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(
Expand All @@ -115,7 +121,7 @@ class YouCamApiClient(
)
}
ensureSuccess(createResponse, "upload/create")
val entry = json.decodeFromString<YouCamFileResponse>(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.")

Expand All @@ -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<YouCamTaskResponse>(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<YouCamPollResponse>(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 ->
Expand Down Expand Up @@ -217,33 +219,61 @@ 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 <T> decode(serializer: DeserializationStrategy<T>, 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 {
const val LOG_TAG = "YouCam"
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
Expand Down
Loading
Loading