Skip to content
Open
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
10 changes: 7 additions & 3 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ val scala2 = List(scala2_12, scala2_13)
val scala3 = List("3.3.8")

val scalaTestVersion = "3.2.19"
val scalaTestPlusScalaCheckVersion = "3.2.19.0"

excludeLintKeys in Global ++= Set(ideSkipProject)

Expand All @@ -23,7 +24,8 @@ val commonSettings = commonSmlBuildSettings ++ ossPublishSettings ++ Seq(
val commonJvmSettings = commonSettings ++ Seq(
ideSkipProject := (scalaVersion.value != scala2_13),
libraryDependencies ++= Seq(
"org.scalatest" %% "scalatest" % scalaTestVersion % Test
"org.scalatest" %% "scalatest" % scalaTestVersion % Test,
"org.scalatestplus" %% "scalacheck-1-18" % scalaTestPlusScalaCheckVersion % Test
),
mimaPreviousArtifacts := previousStableVersion.value.map(organization.value %% moduleName.value % _).toSet,
mimaReportBinaryIssues := { if ((publish / skip).value) {} else mimaReportBinaryIssues.value }
Expand All @@ -47,14 +49,16 @@ val commonJsSettings = commonSettings ++ Seq(
},
libraryDependencies ++= Seq(
"org.scala-js" %%% "scalajs-dom" % "2.8.1",
"org.scalatest" %%% "scalatest" % scalaTestVersion % Test
"org.scalatest" %%% "scalatest" % scalaTestVersion % Test,
"org.scalatestplus" %%% "scalacheck-1-18" % scalaTestPlusScalaCheckVersion % Test
)
)

val commonNativeSettings = commonSettings ++ Seq(
ideSkipProject := true,
libraryDependencies ++= Seq(
"org.scalatest" %%% "scalatest" % scalaTestVersion % Test
"org.scalatest" %%% "scalatest" % scalaTestVersion % Test,
"org.scalatestplus" %%% "scalacheck-1-18" % scalaTestPlusScalaCheckVersion % Test
)
)

Expand Down
63 changes: 53 additions & 10 deletions core/src/main/scala/sttp/model/sse/ServerSentEvent.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,47 @@ case class ServerSentEvent(
data: Option[String] = None,
eventType: Option[String] = None,
id: Option[String] = None,
retry: Option[Int] = None
retry: Option[Int] = None,
comments: List[String] = Nil
) {
// required for binary compatibility
def this(data: Option[String], eventType: Option[String], id: Option[String], retry: Option[Int]) =
this(data, eventType, id, retry, Nil)

def copy(
data: Option[String] = this.data,
eventType: Option[String] = this.eventType,
id: Option[String] = this.id,
retry: Option[Int] = this.retry,
comments: List[String] = this.comments
): ServerSentEvent = ServerSentEvent(data, eventType, id, retry, comments)

// required for binary compatibility
def copy(
data: Option[String],
eventType: Option[String],
id: Option[String],
retry: Option[Int]
): ServerSentEvent = ServerSentEvent(data, eventType, id, retry, this.comments)

/** True if the event carries no data, event type, id or retry. Clients ignore comments, so such events - keep-alive
* pings, but also blocks made up of unknown fields - carry nothing for the application and can usually be skipped.
*/
def hasNoFields: Boolean = data.isEmpty && eventType.isEmpty && id.isEmpty && retry.isEmpty

override def toString: String = {
val _comments =
comments.flatMap(ServerSentEvent.splitOnLineTerminators).map(comment => Some(s": $comment")).toArray
val _data = data
.map(ServerSentEvent.splitOnLineTerminators)
.map(_.map(line => Some(s"data: $line")))
.getOrElse(Array.empty[Option[String]])
val _event = eventType.map(event => s"event: ${ServerSentEvent.removeLineTerminators(event)}")
val _id = id.map(id => s"id: ${ServerSentEvent.removeLineTerminators(id)}")
val _retry = retry.map(retryCount => s"retry: $retryCount")
(_data :+ _event :+ _id :+ _retry).flatten.mkString("\n")
val _fields = _data :+ _event :+ _id :+ _retry
val _all = if (_comments.isEmpty) _fields else _comments ++ _fields
_all.flatten.mkString("\n")
}
}

Expand All @@ -30,10 +60,26 @@ object ServerSentEvent {
private def removeLineTerminators(s: String): String =
if (s.indexOf('\r') < 0 && s.indexOf('\n') < 0) s else s.replaceAll(LineTerminators, "")

// required for binary compatibility
def apply(
data: Option[String],
eventType: Option[String],
id: Option[String],
retry: Option[Int]
): ServerSentEvent = new ServerSentEvent(data, eventType, id, retry, Nil)

/** An event consisting of comment lines only, one per line of the given text. Such events are ignored by clients, and
* can be used to keep the connection alive, so that it isn't dropped by proxies.
*/
def comment(text: String): ServerSentEvent =
ServerSentEvent(comments = splitOnLineTerminators(text).toList)

// https://html.spec.whatwg.org/multipage/server-sent-events.html
def parse(event: List[String]): ServerSentEvent = {
event.foldLeft(ServerSentEvent()) { (event, line) =>
if (line.startsWith("data:")) combineData(event, removeLeadingSpace(line.substring(5)))
// comments are prepended and reversed once at the end for performance
val parsed = event.foldLeft(ServerSentEvent()) { (event, line) =>
if (line.startsWith(":")) event.copy(comments = removeLeadingSpace(line.substring(1)) :: event.comments)
else if (line.startsWith("data:")) combineData(event, removeLeadingSpace(line.substring(5)))
else if (line.startsWith("id:")) event.copy(id = Some(removeLeadingSpace(line.substring(3))))
else if (line.startsWith("retry:"))
event.copy(retry = ParseUtils.toIntOption(removeLeadingSpace(line.substring(6))))
Expand All @@ -43,14 +89,11 @@ object ServerSentEvent {
else if (line == "event") event.copy(eventType = Some(""))
else event
}
if (parsed.comments.isEmpty) parsed else parsed.copy(comments = parsed.comments.reverse)
}

private def combineData(event: ServerSentEvent, newData: String): ServerSentEvent = {
event match {
case e @ ServerSentEvent(Some(oldData), _, _, _) => e.copy(data = Some(s"$oldData\n$newData"))
case e @ ServerSentEvent(None, _, _, _) => e.copy(data = Some(newData))
}
}
private def combineData(event: ServerSentEvent, newData: String): ServerSentEvent =
event.copy(data = Some(event.data.fold(newData)(oldData => s"$oldData\n$newData")))

private def removeLeadingSpace(s: String): String = if (s.startsWith(" ")) s.substring(1) else s
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package sttp.model.sse

import org.scalacheck.Gen
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks

class ServerSentEventPropertyTest extends AnyFlatSpec with ScalaCheckDrivenPropertyChecks with Matchers {
implicit override val generatorDrivenConfig: PropertyCheckConfiguration =
PropertyCheckConfiguration(minSuccessful = 100)

private val LineTerminators = "\r\n|\r|\n"

private val fieldValue: Gen[String] = Gen
.listOf(
Gen.frequency(
6 -> Gen.oneOf("a", "b", ":", " "),
2 -> Gen.oneOf("data: ", "event: ", "id: ", "retry: 9", "data", "id", "event", ": "),
1 -> Gen.oneOf("\n", "\r", "\r\n")
)
)
.map(_.mkString)

private val terminatorFreeValue: Gen[String] =
Gen.listOf(Gen.oneOf("a", "b", ":", " ", "data: ", "event: ", "id: ", "data")).map(_.mkString)

private def eventsOf(value: Gen[String]): Gen[ServerSentEvent] = for {
data <- Gen.option(value)
eventType <- Gen.option(value)
id <- Gen.option(value)
retry <- Gen.option(Gen.chooseNum(Int.MinValue, Int.MaxValue))
comments <- Gen.listOf(value)
} yield ServerSentEvent(data, eventType, id, retry, comments)

private val events = eventsOf(fieldValue)
private val terminatorFreeEvents = eventsOf(terminatorFreeValue)

private val allowedPrefixes = List("data:", "event:", "id:", "retry:", ":")

private def lines(serialised: String): List[String] = serialised.split(LineTerminators, -1).toList

private def lineCount(s: String): Int = s.split(LineTerminators, -1).length

it should "serialise every line as a comment or a known field" in {
forAll(events) { sse =>
val serialised = sse.toString
if (serialised.nonEmpty) {
lines(serialised).foreach { line =>
withClue(s"line [$line] of [$serialised]: ") {
allowedPrefixes.exists(line.startsWith) shouldBe true
}
}
}
}
}

it should "serialise one line per comment line, per data line and per other field that is set" in {
forAll(events) { sse =>
val expected = sse.comments.map(lineCount).sum + sse.data.fold(0)(lineCount) +
List(sse.eventType, sse.id, sse.retry).count(_.isDefined)
lines(sse.toString).size shouldBe math.max(expected, 1)
}
}

it should "serialise, parse and serialise again to the same result" in {
forAll(events) { sse =>
val serialised = sse.toString
ServerSentEvent.parse(lines(serialised)).toString shouldBe serialised
}
}

it should "parse back exactly what was serialised, when no value contains a line terminator" in {
forAll(terminatorFreeEvents) { sse =>
ServerSentEvent.parse(lines(sse.toString)) shouldBe sse
}
}

it should "parse any lines without throwing" in {
val anyLine = Gen.oneOf(
fieldValue,
Gen.oneOf(
"",
":",
"data:",
"data",
"id:",
"id",
"event:",
"event",
"retry:",
"retry",
"retry: x",
"retry: 99999999999999999999",
"foo: bar"
)
)
forAll(Gen.listOf(anyLine)) { ls => noException should be thrownBy ServerSentEvent.parse(ls) }
}
}
Loading
Loading