Skip to content

Commit 3b4c706

Browse files
committed
Support support-fetching-then-parsing-with-key
# Conflicts: # core/src/main/scala/com/gu/etagcaching/Loading.scala # core/src/main/scala/com/gu/etagcaching/fetching/Fetching.scala
1 parent 99bd01e commit 3b4c706

File tree

5 files changed

+135
-19
lines changed

5 files changed

+135
-19
lines changed

core/src/main/scala/com/gu/etagcaching/Loading.scala

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ import scala.concurrent.{ExecutionContext, Future}
88
/**
99
* `Loading` represents the two sequential steps of getting something useful from a remote resource, specifically:
1010
*
11-
* - Fetching
11+
* - [[Fetching]]
1212
* - Parsing
1313
*
14-
* Our [[Fetching]] interface requires supporting conditional-fetching based on `ETag`s, which means that
15-
* we can short-cut the Parsing step if the resource hasn't changed.
14+
* The [[Fetching]] interface supports conditional-fetching based on `ETag`s, allowing us to short-cut the Parsing step
15+
* if the resource hasn't changed.
16+
*
17+
* The easiest way to get a [[Loading]] instance is with a [[Fetching]] instance - just call
18+
* [[Fetching.thenParsing]] to create a [[Loading]] instance.
1619
*
1720
* @tparam K The 'key' or resource identifier type - for instance, a URL or S3 Object Id.
1821
* @tparam V The 'value' for the key - a parsed representation of whatever was in the resource data.
@@ -34,17 +37,6 @@ trait Loading[K, V] {
3437
}
3538

3639
object Loading {
37-
def by[K, Response, V](fetching: Fetching[K, Response])(parse: Response => V)(implicit parsingEC: ExecutionContext): Loading[K, V] = new Loading[K, V] {
38-
def fetchAndParse(key: K): Future[MissingOrETagged[V]] =
39-
fetching.fetch(key).map(_.map(parse))
40-
41-
def fetchThenParseIfNecessary(key: K, oldV: ETaggedData[V]): Future[MissingOrETagged[V]] =
42-
fetching.fetchOnlyIfETagChanged(key, oldV.eTag).map {
43-
case None => oldV // we got HTTP 304 'NOT MODIFIED': there's no new data - old data is still valid
44-
case Some(freshResponse) => freshResponse.map(parse)
45-
}
46-
}
47-
4840
/**
4941
* Represents an update event for a given key.
5042
*/

core/src/main/scala/com/gu/etagcaching/fetching/Fetching.scala

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,31 @@ trait Fetching[K, Response] {
3737

3838
def mapResponse[Response2](f: Response => Response2)(implicit ec: ExecutionContext): Fetching[K, Response2] = ResponseMapper(this)(f)
3939

40-
def thenParsing[V](parse: Response => V)(implicit parsingEC: ExecutionContext): Loading[K, V] = Loading.by(this)(parse)
40+
/**
41+
* Transforms this [[Fetching]] instance into a full [[Loading]] instance by saying how to parse
42+
* the fetched response.
43+
*
44+
* If you happen to need the `key` to parse the response, use [[thenParsingWithKey]].
45+
*/
46+
def thenParsing[V](parse: Response => V)(implicit parsingEC: ExecutionContext): Loading[K, V] = thenParsingWithKey((_, response) => parse(response))
47+
48+
/**
49+
* Transforms this [[Fetching]] instance into a full [[Loading]] instance by saying how to parse
50+
* the fetched response - in this particular case, the parsing method is passed the `key`
51+
* as well as the fetched response.
52+
*
53+
* If you don't need the `key` to parse the response, just use [[thenParsing]].
54+
*/
55+
def thenParsingWithKey[V](parse: (K, Response) => V)(implicit parsingEC: ExecutionContext): Loading[K, V] = new Loading[K, V] {
56+
def fetchAndParse(key: K): Future[MissingOrETagged[V]] =
57+
fetch(key).map(_.map(parse(key, _)))
58+
59+
def fetchThenParseIfNecessary(key: K, oldV: ETaggedData[V]): Future[MissingOrETagged[V]] =
60+
fetchOnlyIfETagChanged(key, oldV.eTag).map {
61+
case None => oldV // we got HTTP 304 'NOT MODIFIED': there's no new data - old data is still valid
62+
case Some(freshResponse) => freshResponse.map(parse(key, _))
63+
}
64+
}
4165
}
4266

4367
object Fetching {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.gu.etagcaching
2+
3+
import java.util.concurrent.atomic.AtomicLong
4+
5+
class CountingParser[K,V](parser: K => V) extends (K => V) {
6+
7+
private val counter = new AtomicLong()
8+
9+
override def apply(key: K): V = {
10+
counter.incrementAndGet()
11+
parser(key)
12+
}
13+
14+
def count(): Long = counter.get()
15+
}

core/src/test/scala/com/gu/etagcaching/LoadingTest.scala

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,75 @@ package com.gu.etagcaching
22

33
import com.gu.etagcaching.FreshnessPolicy.TolerateOldValueWhileRefreshing
44
import com.gu.etagcaching.Loading.Update
5-
import com.gu.etagcaching.fetching.Fetching
6-
import org.scalatest.OptionValues
5+
import com.gu.etagcaching.fetching.{ETaggedData, Fetching}
76
import org.scalatest.concurrent.{Eventually, ScalaFutures}
87
import org.scalatest.flatspec.AnyFlatSpec
98
import org.scalatest.matchers.should.Matchers
9+
import org.scalatest.{Inside, OptionValues}
1010

11+
import java.time.DayOfWeek.{SATURDAY, SUNDAY}
12+
import java.time.temporal.ChronoUnit.DAYS
13+
import java.time.{DayOfWeek, ZoneId, ZonedDateTime}
14+
import java.util.Locale
15+
import java.util.Locale.UK
1116
import scala.collection.mutable
1217
import scala.concurrent.ExecutionContext.Implicits.global
1318
import scala.concurrent.duration._
1419

15-
class LoadingTest extends AnyFlatSpec with Matchers with ScalaFutures with OptionValues with Eventually {
20+
class LoadingTest extends AnyFlatSpec with Matchers with ScalaFutures with OptionValues with Eventually with Inside {
21+
val exampleFetchingDayLength: Fetching[ZonedDateTime, java.time.Duration] = TestFetching.withLookup { dateTime =>
22+
val day = dateTime.truncatedTo(DAYS)
23+
Some(java.time.Duration.between(day, day.plusDays(1)))
24+
}
25+
val momentInShortUKDay: ZonedDateTime = ZonedDateTime.of(2025, 3, 30, 21, 55, 56, 0, ZoneId.of("Europe/London"))
26+
val momentInNormalUSDay: ZonedDateTime = momentInShortUKDay.withZoneSameInstant(ZoneId.of("America/Chicago"))
27+
28+
"Creating a Loading instance from a Fetching instance" should "work with 'thenParsing'" in {
29+
val loading: Loading[ZonedDateTime, Long] = exampleFetchingDayLength.thenParsing(_.toHours)
30+
31+
loading.fetchAndParse(momentInShortUKDay).futureValue.toOption.value shouldBe 23
32+
}
33+
34+
it should "work with 'thenParsingWithKey'" in {
35+
val loading: Loading[ZonedDateTime, String] =
36+
exampleFetchingDayLength.thenParsingWithKey((key, response) => s"${key.getZone.getId.split('/')(1)}: ${response.toHours} hours")
37+
38+
loading.fetchAndParse(momentInShortUKDay).futureValue.toOption.value shouldBe "London: 23 hours"
39+
loading.fetchAndParse(momentInNormalUSDay).futureValue.toOption.value shouldBe "Chicago: 24 hours"
40+
}
41+
42+
private def loadingFavouriteDayOfWeekInDifferentCountries() = new TestLoading[Locale, String, DayOfWeek](DayOfWeek.valueOf)
43+
44+
"Loading" should "not do any parsing if fetching found no change" in {
45+
val sample = loadingFavouriteDayOfWeekInDifferentCountries()
46+
47+
sample.dataStore(UK) = "SATURDAY"
48+
inside(sample.loading.fetchAndParse(UK).futureValue) {
49+
case initialLoad: ETaggedData[DayOfWeek] =>
50+
sample.countingParser.count() shouldBe 1
51+
initialLoad.result shouldBe SATURDAY
52+
53+
sample.loading.fetchThenParseIfNecessary(UK, initialLoad).futureValue.toOption.value shouldBe SATURDAY
54+
sample.countingParser.count() shouldBe 1
55+
}
56+
}
57+
58+
it should "do parsing when values change" in {
59+
val sample = loadingFavouriteDayOfWeekInDifferentCountries()
60+
61+
sample.dataStore(UK) = "SATURDAY"
62+
inside(sample.loading.fetchAndParse(UK).futureValue) {
63+
case initialLoad: ETaggedData[DayOfWeek] =>
64+
sample.countingParser.count() shouldBe 1
65+
initialLoad.result shouldBe SATURDAY
66+
67+
sample.dataStore(UK) = "SUNDAY"
68+
sample.loading.fetchThenParseIfNecessary(UK, initialLoad).futureValue.toOption.value shouldBe SUNDAY
69+
sample.countingParser.count() shouldBe 2
70+
}
71+
}
72+
73+
1674
"onUpdate" should "give callbacks that allow logging updates" in {
1775
val updates: mutable.Buffer[Update[String, Int]] = mutable.Buffer.empty
1876

@@ -43,3 +101,9 @@ class LoadingTest extends AnyFlatSpec with Matchers with ScalaFutures with Optio
43101
updates.toSeq shouldBe expectedUpdates // No updates if we're not requesting the key from the cache
44102
}
45103
}
104+
105+
class TestLoading[K, Response, V](parser: Response => V) {
106+
val dataStore = mutable.Map[K, Response]()
107+
val countingParser = new CountingParser[Response, V](parser)
108+
val loading: Loading[K, V] = TestFetching.withLookup(dataStore.get).thenParsing(countingParser)
109+
}

core/src/test/scala/com/gu/etagcaching/TestFetching.scala

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.gu.etagcaching
22

3-
import com.gu.etagcaching.fetching.{ETaggedData, Fetching, MissingOrETagged}
3+
import com.gu.etagcaching.fetching.{ETaggedData, Fetching, Missing, MissingOrETagged}
44

55
import java.util.concurrent.atomic.AtomicInteger
66
import scala.concurrent.{ExecutionContext, Future}
@@ -17,4 +17,25 @@ object TestFetching {
1717
override def fetchOnlyIfETagChanged(key: String, eTag: String): Future[Option[MissingOrETagged[Int]]] =
1818
fetch(key).map(Some(_))(ExecutionContext.parasitic)
1919
}
20+
21+
/**
22+
* Create a test [[Fetching]] instance with a (possibly impure) `lookup` function.
23+
*
24+
* The [[Fetching]] instance will use the object hashcode to create the object's ETag.
25+
*
26+
* The `lookup` function can return different values for the same input if we want to simulate
27+
* the value for a key changing over time.
28+
*/
29+
def withLookup[K, V](lookup: K => Option[V]): Fetching[K, V] = new Fetching[K, V] {
30+
override def fetch(key: K): Future[MissingOrETagged[V]] = Future.successful {
31+
lookup(key).fold[MissingOrETagged[V]](Missing) { value =>
32+
ETaggedData(value.hashCode().toString, value)
33+
}
34+
}
35+
override def fetchOnlyIfETagChanged(key: K, ETag: String): Future[Option[MissingOrETagged[V]]] =
36+
fetch(key).map {
37+
case ETaggedData(ETag, _) => None
38+
case other => Some(other)
39+
}(ExecutionContext.parasitic)
40+
}
2041
}

0 commit comments

Comments
 (0)