Commit b49c5afd authored by Michael Comella's avatar Michael Comella Committed by Sebastian Kaspari
Browse files

Issue #651: Add PocketJSONParser.

The PocketEndpoint will use this as a dependency to parse values from the
PocketEndpointRaw and return them to the application.

This was adapted from FFTV's PocketViewModel.FeedItem.Video (for the
video data type):
  https://github.com/mozilla-mobile/firefox-tv/blob/785501a9eb6c68386b84d0f1104fc5b2f19ed9cb/app/src/main/java/org/mozilla/tv/firefox/pocket/PocketViewModel.kt#L36-L43

And the PocketVideoParser (for the JSON parsing):
  https://github.com/mozilla-mobile/firefox-tv/blob/785501a9eb6c68386b84d0f1104fc5b2f19ed9cb/app/src/main/java/org/mozilla/tv/firefox/pocket/PocketVideoParser.kt

Notable changes:
- The API is represented accurately and the data objects do not include
presentation logic from FFTV
- The parser replicates the entire API and does not parse only the parts
necessary for FFTV
- Minor changes like renaming
- Additional tests were added to cover empty authors, multiple authors,
multiple authors with invalid authors
parent 324f7b40
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package mozilla.components.service.pocket
import android.support.annotation.VisibleForTesting
import android.support.annotation.VisibleForTesting.PRIVATE
import mozilla.components.service.pocket.data.PocketGlobalVideoRecommendation
import mozilla.components.service.pocket.ext.mapObjNotNull
import org.json.JSONException
import org.json.JSONObject
/**
* Holds functions that parse the JSON returned by the Pocket API and converts them to more usable Kotlin types.
*/
internal class PocketJSONParser {
/**
* @return The videos or null on error; the list will never be empty.
*/
fun jsonToGlobalVideoRecommendations(jsonStr: String?): List<PocketGlobalVideoRecommendation>? = try {
val rawJSON = JSONObject(jsonStr)
val videosJSON = rawJSON.getJSONArray(KEY_VIDEO_RECOMMENDATIONS_INNER)
val videos = videosJSON.mapObjNotNull { jsonToGlobalVideoRecommendation(it) }
// We return null, rather than the empty list, because devs might forget to check an empty list.
if (videos.isNotEmpty()) videos else null
} catch (e: JSONException) {
logger.warn("invalid JSON from Pocket server", e)
null
}
private fun jsonToGlobalVideoRecommendation(jsonObj: JSONObject): PocketGlobalVideoRecommendation? = try {
/** @return a list of authors, removing entries which are invalid JSON */
fun getAuthors(authorsObj: JSONObject): List<PocketGlobalVideoRecommendation.Author> {
return authorsObj.keys().asSequence().toList().mapNotNull { key ->
try {
val authorJson = authorsObj.getJSONObject(key)
PocketGlobalVideoRecommendation.Author(
id = authorJson.getString("author_id"),
name = authorJson.getString("name"),
url = authorJson.getString("url")
)
} catch (e: JSONException) {
logger.warn("Invalid author object in pocket JSON", e)
null
}
}
}
PocketGlobalVideoRecommendation(
id = jsonObj.getInt("id"),
url = jsonObj.getString("url"),
tvURL = jsonObj.getString("tv_url"),
title = jsonObj.getString("title"),
excerpt = jsonObj.getString("excerpt"),
domain = jsonObj.getString("domain"),
imageSrc = jsonObj.getString("image_src"),
publishedTimestamp = jsonObj.getString("published_timestamp"),
sortId = jsonObj.getInt("sort_id"),
popularitySortId = jsonObj.getInt("popularity_sort_id"),
authors = getAuthors(jsonObj.getJSONObject("authors"))
)
} catch (e: JSONException) {
null
}
companion object {
@VisibleForTesting(otherwise = PRIVATE) const val KEY_VIDEO_RECOMMENDATIONS_INNER = "recommendations"
}
}
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package mozilla.components.service.pocket.data
/**
* A recommended video as returned from the Pocket Global Video Recommendation endpoint v2.
*/
data class PocketGlobalVideoRecommendation(
val id: Int,
val url: String,
val tvURL: String,
val title: String,
val excerpt: String,
val domain: String,
val imageSrc: String,
val publishedTimestamp: String,
val sortId: Int,
val popularitySortId: Int,
val authors: List<Author>
) {
data class Author(
val id: String,
val name: String,
val url: String
)
}
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package mozilla.components.service.pocket
import mozilla.components.service.pocket.PocketJSONParser.Companion.KEY_VIDEO_RECOMMENDATIONS_INNER
import mozilla.components.service.pocket.data.PocketGlobalVideoRecommendation
import mozilla.components.service.pocket.helpers.PocketTestResource
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
private const val TEST_DATA_SIZE = 20
private const val KEY_AUTHORS = "authors"
private val TEST_AUTHOR = PocketGlobalVideoRecommendation.Author(
id = "101",
name = "The New York Times",
url = "https://www.nytimes.com/"
)
@RunWith(RobolectricTestRunner::class)
class PocketJSONParserTest {
private lateinit var parser: PocketJSONParser
@Before
fun setUp() {
parser = PocketJSONParser()
}
@Test
fun `WHEN parsing valid global video recommendations THEN pocket videos are returned`() {
val expectedSubset = listOf(
PocketGlobalVideoRecommendation(
id = 27587,
url = "https://www.youtube.com/watch?v=953Qt4FnAcU",
tvURL = "https://www.youtube.com/tv#/watch/video/idle?v=953Qt4FnAcU",
title = "How a Master Pastry Chef Uses Architecture to Make Sky High Pastries",
excerpt = "At New York City's International Culinary Center, host Rebecca DeAngelis makes a modern day croquembouche with architect-turned pastry chef Jansen Chan",
domain = "youtube.com",
imageSrc = "https://img-getpocket.cdn.mozilla.net/direct?url=http%3A%2F%2Fimg.youtube.com%2Fvi%2F953Qt4FnAcU%2Fmaxresdefault.jpg&resize=w450",
publishedTimestamp = "0",
sortId = 0,
popularitySortId = 20,
authors = listOf(PocketGlobalVideoRecommendation.Author(
id = "96612022",
name = "Eater",
url = "http://www.youtube.com/channel/UCRzPUBhXUZHclB7B5bURFXw"
))
),
PocketGlobalVideoRecommendation(
id = 27581,
url = "https://www.youtube.com/watch?v=GHZ7-kq3GDQ",
tvURL = "https://www.youtube.com/tv#/watch/video/idle?v=GHZ7-kq3GDQ",
title = "How Does Having Too Much Power Affect Your Brain?",
excerpt = "Power is tied to a hierarchical escalator that we rise up through decisions and actions. But once we have power, how does it affect our brain and behavior? How Our Brains Respond to People Who Aren't Like Us - https://youtu.be/KIwe_O0am4URead More:The age of adolescencehttps://www.thelancet.com/jour",
domain = "youtube.com",
imageSrc = "https://img-getpocket.cdn.mozilla.net/direct?url=http%3A%2F%2Fimg.youtube.com%2Fvi%2FGHZ7-kq3GDQ%2Fmaxresdefault.jpg&resize=w450",
publishedTimestamp = "0",
sortId = 1,
popularitySortId = 17,
authors = listOf(PocketGlobalVideoRecommendation.Author(
id = "96612138",
name = "Seeker",
url = "http://www.youtube.com/channel/UCzWQYUVCpZqtN93H8RR44Qw"
))
)
)
val pocketJSON = PocketTestResource.POCKET_VIDEO_RECOMMENDATION.get()
val actualVideos = parser.jsonToGlobalVideoRecommendations(pocketJSON)
// We only test a subset of the data for developer sanity. :)
assertNotNull(actualVideos)
assertEquals(TEST_DATA_SIZE, actualVideos!!.size)
expectedSubset.forEachIndexed { i, expected ->
assertEquals(expected, actualVideos[i])
}
}
@Test
fun `WHEN parsing global video recommendations with no authors THEN the returned authors is an empty list`() {
val pocketJSON = PocketTestResource.POCKET_VIDEO_RECOMMENDATION.get()
val jsonFirstMissingAuthors = replaceFirstAuthorsList(pocketJSON, JSONObject())
val actualVideos = parser.jsonToGlobalVideoRecommendations(jsonFirstMissingAuthors)
assertNotNull(actualVideos)
assertEquals(emptyList<PocketGlobalVideoRecommendation.Author>(), actualVideos!![0].authors)
}
@Test
fun `WHEN parsing global video recommendations with multiple authors THEN there are multiple returned authors`() {
val expectedAuthors = listOf(
TEST_AUTHOR,
PocketGlobalVideoRecommendation.Author(
id = "200",
name = "Vox",
url = "https://www.vox.com/"
))
val newAuthors = JSONObject().apply {
expectedAuthors.forEach { expectedAuthor ->
val newAuthorJSON = expectedAuthor.toJSONObject()
put(expectedAuthor.id, newAuthorJSON)
}
}
val pocketJSON = PocketTestResource.POCKET_VIDEO_RECOMMENDATION.get()
val jsonFirstMultipleAuthors = replaceFirstAuthorsList(pocketJSON, newAuthors)
val actual = parser.jsonToGlobalVideoRecommendations(jsonFirstMultipleAuthors)
assertNotNull(actual)
assertEquals(expectedAuthors, actual!![0].authors)
}
@Test
fun `WHEN parsing global video recommendations with some invalid authors THEN the authors are returned without invalid entries`() {
val expectedAuthors = listOf(TEST_AUTHOR)
val newAuthors = JSONObject().apply {
val expectedAuthor = expectedAuthors[0]
put(expectedAuthor.id, expectedAuthor.toJSONObject())
put("999", JSONObject().apply { put("id", "999") })
}
val pocketJSON = PocketTestResource.POCKET_VIDEO_RECOMMENDATION.get()
val jsonFirstInvalidAuthors = replaceFirstAuthorsList(pocketJSON, newAuthors)
val actual = parser.jsonToGlobalVideoRecommendations(jsonFirstInvalidAuthors)
assertNotNull(actual)
assertEquals(TEST_DATA_SIZE, actual!!.size)
assertEquals(expectedAuthors, actual[0].authors)
}
@Test
fun `WHEN parsing global video recommendations with missing fields on some items THEN those entries are dropped`() {
val pocketJSON = PocketTestResource.POCKET_VIDEO_RECOMMENDATION.get()
val expectedFirstTitle = JSONObject(pocketJSON)
.getJSONArray(KEY_VIDEO_RECOMMENDATIONS_INNER)
.getJSONObject(0)
.getString("title")
assertNotNull(expectedFirstTitle)
val pocketJSONWithNoTitleExceptFirst = removeTitleStartingAtIndex(1, pocketJSON)
val actualVideos = parser.jsonToGlobalVideoRecommendations(pocketJSONWithNoTitleExceptFirst)
assertNotNull(actualVideos)
assertEquals(1, actualVideos!!.size)
assertEquals(expectedFirstTitle, actualVideos[0].title)
}
@Test
fun `WHEN parsing global video recommendations with missing fields on all items THEN null is returned`() {
val pocketJSON = PocketTestResource.POCKET_VIDEO_RECOMMENDATION.get()
val pocketJSONWithNoTitles = removeTitleStartingAtIndex(0, pocketJSON)
val actualVideos = parser.jsonToGlobalVideoRecommendations(pocketJSONWithNoTitles)
assertNull(actualVideos)
}
@Test
fun `WHEN parsing global video recommendations for an empty string THEN null is returned`() {
assertNull(parser.jsonToGlobalVideoRecommendations(""))
}
@Test
fun `WHEN parsing global video recommendations for an invalid JSON String THEN null is returned`() {
assertNull(parser.jsonToGlobalVideoRecommendations("{!!}}"))
}
}
private fun replaceFirstAuthorsList(fullJSONStr: String, replacedAuthors: JSONObject): String {
val json = JSONObject(fullJSONStr)
val firstRecommendation = json.getJSONArray(KEY_VIDEO_RECOMMENDATIONS_INNER).getJSONObject(0)
firstRecommendation.put(KEY_AUTHORS, replacedAuthors)
return json.toString()
}
private fun removeTitleStartingAtIndex(startIndex: Int, json: String): String {
val obj = JSONObject(json)
val videosJson = obj.getJSONArray(KEY_VIDEO_RECOMMENDATIONS_INNER)
for (i in startIndex until videosJson.length()) {
videosJson.getJSONObject(i).remove("title")
}
return obj.toString()
}
private fun PocketGlobalVideoRecommendation.Author.toJSONObject(): JSONObject = JSONObject().also {
it.put("author_id", id)
it.put("name", name)
it.put("url", url)
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment