From 160559deb133ca78ff94eb75d994a563ed103c2b Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:10:12 +0200 Subject: [PATCH] Migrate the OG scrape response to the generated GetOGResponse model --- .../client/api2/endpoint/OpenGraphApi.kt | 4 +- .../client/api2/mapping/DomainMapping.kt | 36 +++++ .../android/client/parser2/MoshiChatParser.kt | 2 + .../parser2/adapters/GetOGResponseAdapter.kt | 45 ++++++ .../android/network/models/GetOGResponse.kt | 101 +++++++++++++ .../getstream/chat/android/client/Mother.kt | 15 ++ .../android/client/api2/MoshiChatApiTest.kt | 4 +- .../client/api2/MoshiChatApiTestArguments.kt | 6 +- .../parser2/GetOGResponseParsingTest.kt | 136 ++++++++++++++++++ 9 files changed, 342 insertions(+), 7 deletions(-) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/GetOGResponseAdapter.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/GetOGResponse.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GetOGResponseParsingTest.kt diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/OpenGraphApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/OpenGraphApi.kt index 8ea3c06419d..28bda208530 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/OpenGraphApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/OpenGraphApi.kt @@ -18,8 +18,8 @@ package io.getstream.chat.android.client.api2.endpoint import io.getstream.chat.android.client.api.AuthenticatedApi import io.getstream.chat.android.client.api.QueryParams -import io.getstream.chat.android.client.api2.model.dto.AttachmentDto import io.getstream.chat.android.client.call.RetrofitCall +import io.getstream.chat.android.network.models.GetOGResponse import retrofit2.http.GET import retrofit2.http.Query @@ -30,5 +30,5 @@ import retrofit2.http.Query internal interface OpenGraphApi { @GET("/og") - fun get(@Query(QueryParams.URL) url: String): RetrofitCall + fun get(@Query(QueryParams.URL) url: String): RetrofitCall } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt index 1927b76df6b..76e5f73abe1 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt @@ -138,6 +138,7 @@ import io.getstream.chat.android.network.models.ChannelResponse import io.getstream.chat.android.network.models.ChatPreferencesResponse import io.getstream.chat.android.network.models.DeviceResponse import io.getstream.chat.android.network.models.GetApplicationResponse +import io.getstream.chat.android.network.models.GetOGResponse import io.getstream.chat.android.network.models.PollOptionResponseData import io.getstream.chat.android.network.models.PushPreferencesResponse import io.getstream.chat.android.network.models.UnreadCountsChannel @@ -747,6 +748,41 @@ internal class DomainMapping( extraData = extraData.toMutableMap(), ) + internal fun GetOGResponse.toDomain(): Attachment { + // The spec does not declare file_size/image/mime_type/name, so when the wire sends them they + // arrive in `custom`. Read them back out and remove them, or they would also sit in extraData + // under their wire names, which the hand-written DTO never did. + val extras = custom.toMutableMap() + val fileSize = (extras.remove("file_size") as? Number)?.toInt() ?: 0 + val image = extras.remove("image") as? String + val mimeType = extras.remove("mime_type") as? String + val name = extras.remove("name") as? String + return Attachment( + assetUrl = assetUrl, + authorName = authorName, + authorLink = authorLink, + fallback = fallback, + fileSize = fileSize, + image = image, + imageUrl = imageUrl, + mimeType = mimeType, + name = name, + ogUrl = ogScrapeUrl, + text = text, + thumbUrl = thumbUrl, + title = title, + titleLink = titleLink, + type = type, + originalHeight = originalHeight, + originalWidth = originalWidth, + extraData = extras.mapNotNull { (key, value) -> value?.let { key to it } }.toMap(), + ) + // `giphy` is deliberately not mapped: unlike the fields above, it has a single producer -- the + // giphy slash command, which writes it to a message attachment -- so it cannot reach /og. The + // slice that adopts the shared Attachment model owns it, and must re-emit it into + // extraData["giphy"] or Attachment.giphyInfo() stops finding gif urls. + } + /** * Transforms [BannedUserResponse] to [BannedUser]. */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt index 6bb80a02de2..910ecd81dcd 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt @@ -52,6 +52,7 @@ import io.getstream.chat.android.client.parser2.adapters.DownstreamUserDtoAdapte import io.getstream.chat.android.client.parser2.adapters.EventAdapterFactory import io.getstream.chat.android.client.parser2.adapters.EventRequestAdapter import io.getstream.chat.android.client.parser2.adapters.ExactDateAdapter +import io.getstream.chat.android.client.parser2.adapters.GetOGResponseAdapter import io.getstream.chat.android.client.parser2.adapters.MessageRequestAdapter import io.getstream.chat.android.client.parser2.adapters.NullCollectionsAsEmptyFactory import io.getstream.chat.android.client.parser2.adapters.PollOptionInputAdapter @@ -123,6 +124,7 @@ internal class MoshiChatParser( .add(PollOptionInputAdapter) .add(PollOptionRequestAdapter) .add(EventRequestAdapter) + .add(GetOGResponseAdapter) .add(PollOptionResponseDataAdapter) .add( CreatePollRequest.VotingVisibility::class.java, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/GetOGResponseAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/GetOGResponseAdapter.kt new file mode 100644 index 00000000000..b17d0a63532 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/GetOGResponseAdapter.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.parser2.adapters + +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson +import io.getstream.chat.android.network.models.GetOGResponse + +/** + * Collects the root-level custom fields of an OG scrape into `custom`, which is how the API sends them. + * + * The endpoint also ships `file_size`, `image`, `mime_type` and `name` at the root even though the spec + * does not declare them, so they arrive here too; `GetOGResponse.toDomain()` reads them back out. + */ +internal object GetOGResponseAdapter : + CustomObjectDtoAdapter(GetOGResponse::class, extraDataPropertyName = "custom") { + + @FromJson + fun fromJson( + jsonReader: JsonReader, + mapAdapter: JsonAdapter>, + valueAdapter: JsonAdapter, + ): GetOGResponse? = parseWithExtraData(jsonReader, mapAdapter, valueAdapter) + + @ToJson + @Suppress("UNUSED_PARAMETER") + fun toJson(jsonWriter: JsonWriter, value: GetOGResponse): Unit = error("Can't convert this to Json") +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/GetOGResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/GetOGResponse.kt new file mode 100644 index 00000000000..462f0a600c3 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/GetOGResponse.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.getstream.chat.android.network.models + +import com.squareup.moshi.Json + +/** + * + */ +@com.squareup.moshi.JsonClass(generateAdapter = true) +internal data class GetOGResponse( + @Json(name = "duration") + internal val duration: String, + + @Json(name = "custom") + internal val custom: Map = emptyMap(), + + @Json(name = "asset_url") + internal val assetUrl: String? = null, + + @Json(name = "author_icon") + internal val authorIcon: String? = null, + + @Json(name = "author_link") + internal val authorLink: String? = null, + + @Json(name = "author_name") + internal val authorName: String? = null, + + @Json(name = "color") + internal val color: String? = null, + + @Json(name = "fallback") + internal val fallback: String? = null, + + @Json(name = "footer") + internal val footer: String? = null, + + @Json(name = "footer_icon") + internal val footerIcon: String? = null, + + @Json(name = "image_url") + internal val imageUrl: String? = null, + + @Json(name = "og_scrape_url") + internal val ogScrapeUrl: String? = null, + + @Json(name = "original_height") + internal val originalHeight: Int? = null, + + @Json(name = "original_width") + internal val originalWidth: Int? = null, + + @Json(name = "pretext") + internal val pretext: String? = null, + + @Json(name = "text") + internal val text: String? = null, + + @Json(name = "thumb_url") + internal val thumbUrl: String? = null, + + @Json(name = "title") + internal val title: String? = null, + + @Json(name = "title_link") + internal val titleLink: String? = null, + + @Json(name = "type") + internal val type: String? = null, + + @Json(name = "actions") + internal val actions: List? = emptyList(), + + @Json(name = "fields") + internal val fields: List? = emptyList(), + + @Json(name = "giphy") + internal val giphy: io.getstream.chat.android.network.models.Images? = null, +) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt index 3ad193aa658..6e0c5fe626e 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt @@ -98,6 +98,7 @@ import io.getstream.chat.android.network.models.DeviceResponse import io.getstream.chat.android.network.models.FileUploadConfig import io.getstream.chat.android.network.models.FileUploadResponse import io.getstream.chat.android.network.models.GetApplicationResponse +import io.getstream.chat.android.network.models.GetOGResponse import io.getstream.chat.android.network.models.PollOptionResponseData import io.getstream.chat.android.network.models.ThreadParticipant import io.getstream.chat.android.network.models.UnblockUsersResponse @@ -790,6 +791,20 @@ internal object Mother { last_delivered_message_id = lastDeliveredMessageId, ) + fun randomGetOGResponse( + duration: String = randomString(), + type: String? = randomString(), + ogScrapeUrl: String? = randomString(), + imageUrl: String? = randomString(), + custom: Map = emptyMap(), + ): GetOGResponse = GetOGResponse( + duration = duration, + type = type, + ogScrapeUrl = ogScrapeUrl, + imageUrl = imageUrl, + custom = custom, + ) + fun randomAttachmentDto( assetUrl: String? = randomString(), authorName: String? = randomString(), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt index 34fadec9c0e..112cb32bb54 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt @@ -38,7 +38,6 @@ import io.getstream.chat.android.client.api2.endpoint.UserGroupApi import io.getstream.chat.android.client.api2.mapping.DomainMapping import io.getstream.chat.android.client.api2.mapping.DtoMapping import io.getstream.chat.android.client.api2.mapping.EventMapping -import io.getstream.chat.android.client.api2.model.dto.AttachmentDto import io.getstream.chat.android.client.api2.model.dto.DownstreamLocationDto import io.getstream.chat.android.client.api2.model.dto.UpstreamChatPreferencesDto import io.getstream.chat.android.client.api2.model.dto.UpstreamPushPreferenceInputDto @@ -138,6 +137,7 @@ import io.getstream.chat.android.network.models.DeliveredMessagePayload import io.getstream.chat.android.network.models.EventRequest import io.getstream.chat.android.network.models.GetApplicationResponse import io.getstream.chat.android.network.models.GetBlockedUsersResponse +import io.getstream.chat.android.network.models.GetOGResponse import io.getstream.chat.android.network.models.GetUserGroupResponse import io.getstream.chat.android.network.models.GroupedChannelsGroupRequest import io.getstream.chat.android.network.models.GroupedQueryChannelsRequest @@ -1938,7 +1938,7 @@ internal class MoshiChatApiTest { @ParameterizedTest @MethodSource("io.getstream.chat.android.client.api2.MoshiChatApiTestArguments#ogInput") - fun testOg(call: RetrofitCall, expected: KClass<*>) = runTest { + fun testOg(call: RetrofitCall, expected: KClass<*>) = runTest { // given val api = mock() whenever(api.get(any())).doReturn(call) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt index f2b60fd2092..2a89d162e3e 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt @@ -25,7 +25,6 @@ import io.getstream.chat.android.client.Mother.randomUnreadCountByTeamDto import io.getstream.chat.android.client.Mother.randomUnreadDto import io.getstream.chat.android.client.Mother.randomUnreadThreadDto import io.getstream.chat.android.client.api.FakeResponse -import io.getstream.chat.android.client.api2.model.dto.AttachmentDto import io.getstream.chat.android.client.api2.model.dto.DownstreamLocationDto import io.getstream.chat.android.client.api2.model.dto.DownstreamReminderDto import io.getstream.chat.android.client.api2.model.dto.HealthEventDto @@ -74,6 +73,7 @@ import io.getstream.chat.android.network.models.CreateGuestResponse import io.getstream.chat.android.network.models.CreateUserGroupResponse import io.getstream.chat.android.network.models.GetApplicationResponse import io.getstream.chat.android.network.models.GetBlockedUsersResponse +import io.getstream.chat.android.network.models.GetOGResponse import io.getstream.chat.android.network.models.GetUserGroupResponse import io.getstream.chat.android.network.models.ListDevicesResponse import io.getstream.chat.android.network.models.ListUserGroupsResponse @@ -431,8 +431,8 @@ internal object MoshiChatApiTestArguments { @JvmStatic fun ogInput() = listOf( - Arguments.of(RetroSuccess(Mother.randomAttachmentDto()).toRetrofitCall(), Result.Success::class), - Arguments.of(RetroError(statusCode = 500).toRetrofitCall(), Result.Failure::class), + Arguments.of(RetroSuccess(Mother.randomGetOGResponse()).toRetrofitCall(), Result.Success::class), + Arguments.of(RetroError(statusCode = 500).toRetrofitCall(), Result.Failure::class), ) @JvmStatic diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GetOGResponseParsingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GetOGResponseParsingTest.kt new file mode 100644 index 00000000000..00b92a804df --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GetOGResponseParsingTest.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.parser2 + +import io.getstream.chat.android.client.api2.mapping.DomainMapping +import io.getstream.chat.android.models.NoOpChannelTransformer +import io.getstream.chat.android.models.NoOpMessageTransformer +import io.getstream.chat.android.models.NoOpUserTransformer +import io.getstream.chat.android.network.models.GetOGResponse +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeNull +import org.junit.jupiter.api.Test + +internal class GetOGResponseParsingTest { + private val parser = ParserFactory.createMoshiChatParser() + private val mapping = DomainMapping( + currentUserIdProvider = { "me" }, + channelTransformer = NoOpChannelTransformer, + messageTransformer = NoOpMessageTransformer, + userTransformer = NoOpUserTransformer, + ) + + @Test + fun `Custom root fields are collected into extraData`() { + val response = parser.fromJson( + """ + { + "duration": "1ms", + "type": "image", + "title": "A page", + "og_scrape_url": "https://example.com", + "image_url": "https://example.com/i.png", + "thumb_url": "https://example.com/i.png", + "sentinel": "keep-me" + } + """.trimIndent(), + GetOGResponse::class.java, + ) + + val attachment = with(mapping) { response.toDomain() } + + attachment.type shouldBeEqualTo "image" + attachment.title shouldBeEqualTo "A page" + attachment.ogUrl shouldBeEqualTo "https://example.com" + attachment.imageUrl shouldBeEqualTo "https://example.com/i.png" + attachment.thumbUrl shouldBeEqualTo "https://example.com/i.png" + attachment.extraData shouldBeEqualTo mapOf("sentinel" to "keep-me") + } + + @Test + fun `An empty scrape maps to an attachment with domain defaults`() { + val response = parser.fromJson("""{"duration":"1ms"}""", GetOGResponse::class.java) + + val attachment = with(mapping) { response.toDomain() } + + attachment.type.shouldBeNull() + attachment.ogUrl.shouldBeNull() + attachment.fileSize shouldBeEqualTo 0 + attachment.extraData shouldBeEqualTo emptyMap() + } + + // The scraper does not appear to populate site_name/site for any page tried on the wire, so these two + // are covered here instead. Same-named on both sides, which is exactly how a swap goes unnoticed. + @Test + fun `Author name and link are mapped from their own fields`() { + val response = parser.fromJson( + """ + { + "duration": "1ms", + "author_name": "Example Site", + "author_link": "https://example.com", + "asset_url": "https://example.com/v.mp4", + "type": "video" + } + """.trimIndent(), + GetOGResponse::class.java, + ) + + val attachment = with(mapping) { response.toDomain() } + + attachment.authorName shouldBeEqualTo "Example Site" + attachment.authorLink shouldBeEqualTo "https://example.com" + attachment.assetUrl shouldBeEqualTo "https://example.com/v.mp4" + attachment.type shouldBeEqualTo "video" + } + + // The wire was not observed sending these on /og, but three sampled pages cannot prove it never + // will, and losing them would be silent. They arrive in `custom` because the spec omits them. + @Test + fun `Undeclared root fields are mapped and removed from extraData`() { + val response = parser.fromJson( + """ + { + "duration": "1ms", + "type": "file", + "og_scrape_url": "https://example.com/doc.pdf", + "file_size": 2048, + "image": "https://example.com/legacy.png", + "mime_type": "application/pdf", + "name": "doc.pdf", + "fallback": "a fallback", + "original_width": 640, + "original_height": 480, + "sentinel": "keep-me" + } + """.trimIndent(), + GetOGResponse::class.java, + ) + + val attachment = with(mapping) { response.toDomain() } + + attachment.fileSize shouldBeEqualTo 2048 + attachment.image shouldBeEqualTo "https://example.com/legacy.png" + attachment.mimeType shouldBeEqualTo "application/pdf" + attachment.name shouldBeEqualTo "doc.pdf" + attachment.fallback shouldBeEqualTo "a fallback" + attachment.originalWidth shouldBeEqualTo 640 + attachment.originalHeight shouldBeEqualTo 480 + // Read out of `custom`, so they must not also linger under their wire names. + attachment.extraData shouldBeEqualTo mapOf("sentinel" to "keep-me") + } +}