Skip to content

Commit c682041

Browse files
authored
[CSL 449] Android Recommendations (#71)
* Convert conversion to v2 * Update tests * Remove unused imports * Update conversion tests * Check request body for v2 endpoints * Update tests * Add recommendations support * Add integration test * Merge master * Adjust gradle * Add data model checks to integration tests * Remove facets * Add back private
1 parent 3d107c5 commit c682041

File tree

12 files changed

+1043
-8
lines changed

12 files changed

+1043
-8
lines changed

library/src/main/java/io/constructor/core/Constants.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ class Constants {
4242
const val FILTER_VALUE = "filter_value"
4343
const val RESULT_POSITION_ON_PAGE = "result_position_on_page"
4444
const val RESULT_COUNT = "result_count"
45+
const val NUM_RESULT = "num_result"
46+
const val ITEM_ID = "item_id"
47+
const val TERM = "term"
4548
}
4649

4750
object QueryValues {

library/src/main/java/io/constructor/core/ConstructorIo.kt

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import io.constructor.data.model.autocomplete.AutocompleteResponse
1111
import io.constructor.data.model.common.ResultGroup
1212
import io.constructor.data.model.search.SearchResponse
1313
import io.constructor.data.model.browse.BrowseResponse
14+
import io.constructor.data.model.recommendations.RecommendationsResponse
1415
import io.constructor.data.model.browse.BrowseResultClickRequestBody
1516
import io.constructor.data.model.browse.BrowseResultLoadRequestBody
1617
import io.constructor.data.model.purchase.PurchaseItem
@@ -474,4 +475,26 @@ object ConstructorIo {
474475

475476
}
476477

477-
}
478+
/**
479+
* Returns a list of search results including filters, categories, sort options, etc.
480+
* @param podId the pod id
481+
* @param facets additional facets used to refine results
482+
* @param numResults the number of results to return
483+
* @param sectionName the section the selection will come from, i.e. "Products"
484+
* @param itemId: The item id to retrieve recommendations (strategy specific)
485+
* @param term: The term to use to refine results (strategy specific)
486+
*/
487+
fun getRecommendationResults(podId: String, facets: List<Pair<String, List<String>>>? = null, numResults: Int? = null, sectionName: String? = null, itemId: String? = null, term: String? = null): Observable<ConstructorData<RecommendationsResponse>> {
488+
val encodedParams: ArrayList<Pair<String, String>> = arrayListOf()
489+
numResults?.let { encodedParams.add(Constants.QueryConstants.NUM_RESULT.urlEncode() to numResults.toString().urlEncode()) }
490+
sectionName?.let { encodedParams.add(Constants.QueryConstants.SECTION.urlEncode() to sectionName.toString().urlEncode()) }
491+
itemId?.let { encodedParams.add(Constants.QueryConstants.ITEM_ID.urlEncode() to itemId.toString().urlEncode()) }
492+
term?.let { encodedParams.add(Constants.QueryConstants.TERM.urlEncode() to term.toString().urlEncode()) }
493+
facets?.forEach { facet ->
494+
facet.second.forEach {
495+
encodedParams.add(Constants.QueryConstants.FILTER_FACET.format(facet.first).urlEncode() to it.urlEncode())
496+
}
497+
}
498+
return dataManager.getRecommendationResults(podId, encodedParams = encodedParams.toTypedArray())
499+
}
500+
}

library/src/main/java/io/constructor/data/DataManager.kt

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.squareup.moshi.Moshi
44
import io.constructor.data.model.autocomplete.AutocompleteResponse
55
import io.constructor.data.model.search.SearchResponse
66
import io.constructor.data.model.browse.BrowseResponse
7+
import io.constructor.data.model.recommendations.RecommendationsResponse
78
import io.constructor.data.model.browse.BrowseResultClickRequestBody
89
import io.constructor.data.model.browse.BrowseResultLoadRequestBody
910
import io.constructor.data.model.conversion.ConversionRequestBody
@@ -122,4 +123,23 @@ constructor(private val constructorApi: ConstructorApi, private val moshi: Moshi
122123
return constructorApi.trackBrowseResultClick(browseResultClickRequestBody, params.toMap(), encodedParams.toMap())
123124
}
124125

125-
}
126+
fun getRecommendationResults(podId: String, encodedParams: Array<Pair<String, String>> = arrayOf()): Observable<ConstructorData<RecommendationsResponse>> {
127+
return constructorApi.getRecommendationResults(podId, encodedParams?.toMap()).map {
128+
if (!it.isError) {
129+
it.response()?.let {
130+
if (it.isSuccessful) {
131+
val adapter = moshi.adapter(RecommendationsResponse::class.java)
132+
val response = it.body()?.string()
133+
val result = response?.let { adapter.fromJson(it) }
134+
result?.rawData = response
135+
ConstructorData.of(result!!)
136+
} else {
137+
ConstructorData.networkError(it.errorBody()?.string())
138+
}
139+
} ?: ConstructorData.error(it.error())
140+
} else {
141+
ConstructorData.error(it.error())
142+
}
143+
}.toObservable()
144+
}
145+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package io.constructor.data.model.recommendations
2+
3+
import com.squareup.moshi.Json
4+
import java.io.Serializable
5+
6+
data class Pod(
7+
@Json(name = "id") val response: String?,
8+
@Json(name = "display_name") val resultId: String?
9+
) : Serializable
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package io.constructor.data.model.recommendations
2+
3+
import com.squareup.moshi.Json
4+
import java.io.Serializable
5+
6+
data class RecommendationsResponse(
7+
@Json(name = "response") val response: RecommendationsResponseInner?,
8+
@Json(name = "result_id") val resultId: String?,
9+
var rawData: String?
10+
) : Serializable
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package io.constructor.data.model.recommendations
2+
3+
import com.squareup.moshi.Json
4+
import io.constructor.data.model.common.*;
5+
import java.io.Serializable
6+
7+
data class RecommendationsResponseInner(
8+
@Json(name = "pod") val pod: Pod?,
9+
@Json(name = "results") val results: List<Result>?,
10+
@Json(name = "total_num_results") val resultCount: Int
11+
) : Serializable

library/src/main/java/io/constructor/data/remote/ApiPaths.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ object ApiPaths {
1616
const val URL_BROWSE = "browse/{filter_name}/{filter_value}"
1717
const val URL_BROWSE_RESULT_CLICK_EVENT = "v2/behavioral_action/browse_result_click"
1818
const val URL_BROWSE_RESULT_LOAD_EVENT = "v2/behavioral_action/browse_result_load"
19-
}
19+
const val URL_RECOMMENDATIONS = "recommendations/v1/pods/{podId}"
20+
}

library/src/main/java/io/constructor/data/remote/ConstructorApi.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,7 @@ interface ConstructorApi {
7474
fun trackBrowseResultsLoaded(@Body browseRequestBody: BrowseResultLoadRequestBody,
7575
@QueryMap params: Map<String, String>): Completable
7676

77-
}
77+
@GET(ApiPaths.URL_RECOMMENDATIONS)
78+
fun getRecommendationResults(@Path("podId") value: String,
79+
@QueryMap data: Map<String, String>): Single<Result<ResponseBody>>
80+
}

library/src/test/java/io/constructor/core/ConstructorIoIntegrationTest.kt

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ class ConstructorIoIntegrationTest {
4747
@Test
4848
fun getAutocompleteResultsAgainstRealResponse() {
4949
val observer = constructorIo.getAutocompleteResults("pork").test()
50-
observer.assertComplete();
50+
observer.assertComplete().assertValue {
51+
it.get()?.sections!!.isNotEmpty()
52+
it.get()?.resultId!!.isNotEmpty()
53+
}
5154
}
5255

5356
@Test
@@ -72,7 +75,14 @@ class ConstructorIoIntegrationTest {
7275
@Test
7376
fun getSearchResultsAgainstRealResponse() {
7477
val observer = constructorIo.getSearchResults("pork").test()
75-
observer.assertComplete();
78+
observer.assertComplete().assertValue {
79+
it.get()?.resultId !== null
80+
it.get()?.response?.results!!.isNotEmpty()
81+
it.get()?.response?.facets!!.isNotEmpty()
82+
it.get()?.response?.groups!!.isNotEmpty()
83+
it.get()?.response?.filterSortOptions!!.isNotEmpty()
84+
it.get()?.response?.resultCount!! > 0
85+
}
7686
}
7787

7888
@Test
@@ -84,8 +94,15 @@ class ConstructorIoIntegrationTest {
8494

8595
@Test
8696
fun getBrowseResultsAgainstRealResponse() {
87-
val observer = constructorIo.getBrowseResults("group_ids", "544").test()
88-
observer.assertComplete();
97+
val observer = constructorIo.getBrowseResults("group_id", "744").test()
98+
observer.assertComplete().assertValue {
99+
it.get()?.resultId !== null
100+
it.get()?.response?.results!!.isNotEmpty()
101+
it.get()?.response?.facets!!.isNotEmpty()
102+
it.get()?.response?.groups!!.isNotEmpty()
103+
it.get()?.response?.filterSortOptions!!.isNotEmpty()
104+
it.get()?.response?.resultCount!! > 0
105+
}
89106
}
90107

91108
@Test
@@ -136,4 +153,15 @@ class ConstructorIoIntegrationTest {
136153
val observer = constructorIo.trackBrowseResultClickInternal("group_ids", "544", "prrst_shldr_bls", 5).test()
137154
observer.assertComplete();
138155
}
156+
157+
@Test
158+
fun getRecommendationResultsAgainstRealResponse() {
159+
val observer = constructorIo.getRecommendationResults("best_sellers").test()
160+
observer.assertComplete().assertValue {
161+
it.get()?.resultId !== null
162+
it.get()?.response?.pod !== null
163+
it.get()?.response?.results!!.isNotEmpty()
164+
it.get()?.response?.resultCount!! > 0
165+
}
166+
}
139167
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package io.constructor.core
2+
3+
import android.content.Context
4+
import io.constructor.data.local.PreferencesHelper
5+
import io.constructor.data.memory.ConfigMemoryHolder
6+
import io.constructor.test.createTestDataManager
7+
import io.constructor.util.RxSchedulersOverrideRule
8+
import io.constructor.util.TestDataLoader
9+
import io.mockk.every
10+
import io.mockk.mockk
11+
import okhttp3.mockwebserver.MockResponse
12+
import okhttp3.mockwebserver.MockWebServer
13+
import org.junit.Before
14+
import org.junit.Rule
15+
import org.junit.Test
16+
import java.util.concurrent.TimeUnit
17+
18+
class ConstructorIoRecommendationsTest {
19+
20+
@Rule
21+
@JvmField
22+
val overrideSchedulersRule = RxSchedulersOverrideRule()
23+
24+
private lateinit var mockServer: MockWebServer
25+
private var constructorIo = ConstructorIo
26+
private val ctx = mockk<Context>()
27+
private val preferencesHelper = mockk<PreferencesHelper>()
28+
private val configMemoryHolder = mockk<ConfigMemoryHolder>()
29+
30+
@Before
31+
fun setup() {
32+
mockServer = MockWebServer()
33+
mockServer.start()
34+
35+
every { ctx.applicationContext } returns ctx
36+
37+
every { preferencesHelper.apiKey } returns "golden-key"
38+
every { preferencesHelper.id } returns "guido-the-guid"
39+
every { preferencesHelper.serviceUrl } returns mockServer.hostName
40+
every { preferencesHelper.port } returns mockServer.port
41+
every { preferencesHelper.scheme } returns "http"
42+
every { preferencesHelper.defaultItemSection } returns "Products"
43+
every { preferencesHelper.getSessionId(any(), any()) } returns 79
44+
45+
every { configMemoryHolder.autocompleteResultCount } returns null
46+
every { configMemoryHolder.userId } returns "player-one"
47+
every { configMemoryHolder.testCellParams } returns emptyList()
48+
every { configMemoryHolder.segments } returns emptyList()
49+
50+
val config = ConstructorIoConfig("dummyKey")
51+
val dataManager = createTestDataManager(preferencesHelper, configMemoryHolder, ctx)
52+
53+
constructorIo.testInit(ctx, config, dataManager, preferencesHelper, configMemoryHolder)
54+
}
55+
56+
@Test
57+
fun getRecommendationResults() {
58+
val mockResponse = MockResponse().setResponseCode(200).setBody(TestDataLoader.loadAsString("recommendation_response.json"))
59+
mockServer.enqueue(mockResponse)
60+
val observer = constructorIo.getRecommendationResults("titanic").test()
61+
observer.assertComplete().assertValue {
62+
it.get()!!.response?.results!!.size == 24
63+
it.get()!!.response?.results!![0].value == "LaCroix Sparkling Water Pure Cans - 12-12 Fl. Oz."
64+
it.get()!!.response?.results!![0].data.id == "960189161"
65+
it.get()!!.response?.results!![0].data.imageUrl == "https://d17bbgoo3npfov.cloudfront.net/images/farmstand-960189161.png"
66+
it.get()!!.response?.results!![0].data.metadata?.get("price") == 1.11
67+
it.get()!!.response?.resultCount == 225
68+
}
69+
val request = mockServer.takeRequest()
70+
println(request.path)
71+
val path = "/recommendations/v1/pods/titanic?key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.6.0&_dt="
72+
assert(request.path.startsWith(path))
73+
}
74+
75+
@Test
76+
fun getRecommendationResultsWithServerError() {
77+
val mockResponse = MockResponse().setResponseCode(500).setBody("Internal server error")
78+
mockServer.enqueue(mockResponse)
79+
val observer = constructorIo.getRecommendationResults("titanic").test()
80+
observer.assertComplete().assertValue {
81+
it.networkError
82+
}
83+
val request = mockServer.takeRequest()
84+
val path = "/recommendations/v1/pods/titanic?key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.6.0&_dt="
85+
assert(request.path.startsWith(path))
86+
}
87+
88+
@Test
89+
fun getRecommendationResultsWithTimeout() {
90+
val mockResponse = MockResponse().setResponseCode(200).setBody(TestDataLoader.loadAsString("recommendation_response.json"))
91+
mockResponse.throttleBody(128, 5, TimeUnit.SECONDS)
92+
mockServer.enqueue(mockResponse)
93+
val observer = constructorIo.getRecommendationResults("titanic").test()
94+
observer.assertComplete().assertValue {
95+
it.isError
96+
}
97+
val request = mockServer.takeRequest()
98+
val path = "/recommendations/v1/pods/titanic?key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.6.0&_dt="
99+
assert(request.path.startsWith(path))
100+
}
101+
102+
@Test
103+
fun getRecommendationResultsWithEmptyResponse() {
104+
val mockResponse = MockResponse().setResponseCode(200).setBody(TestDataLoader.loadAsString("recommendation_response_empty.json"))
105+
mockServer.enqueue(mockResponse)
106+
val observer = constructorIo.getRecommendationResults("titanic").test()
107+
observer.assertComplete().assertValue {
108+
it.get()!!.response?.results!!.isEmpty()
109+
it.get()!!.response?.resultCount == 0
110+
}
111+
val request = mockServer.takeRequest()
112+
val path = "/recommendations/v1/pods/titanic?key=golden-key&i=guido-the-guid&ui=player-one&s=79&c=cioand-2.6.0&_dt="
113+
assert(request.path.startsWith(path))
114+
}
115+
}

0 commit comments

Comments
 (0)