JAMES-3376 BackReferences should allow reading arrays element
diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/ProcessingContext.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/ProcessingContext.scala
index 8402d48..8506f2f 100644
--- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/ProcessingContext.scala
+++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/routes/ProcessingContext.scala
@@ -19,6 +19,9 @@
package org.apache.james.jmap.routes
+import eu.timepit.refined.numeric.NonNegative
+import eu.timepit.refined.refineV
+import eu.timepit.refined.types.numeric.NonNegInt
import org.apache.james.jmap.json.BackReferenceDeserializer
import org.apache.james.jmap.mail.MailboxSetRequest.UnparsedMailboxId
import org.apache.james.jmap.mail.VacationResponse.{UnparsedVacationResponseId, VACATION_RESPONSE_ID}
@@ -28,9 +31,11 @@
import org.apache.james.mailbox.model.MailboxId
import play.api.libs.json.{JsArray, JsError, JsObject, JsResult, JsSuccess, JsValue, Reads}
+import scala.util.Try
+
sealed trait JsonPathPart
-case class WildcardPart() extends JsonPathPart
+case object WildcardPart extends JsonPathPart
case class PlainPart(name: String) extends JsonPathPart {
def read(jsValue: JsValue): JsResult[JsValue] = jsValue match {
@@ -39,13 +44,63 @@
}
}
+object ArrayElementPart {
+ def parse(string: String): Option[ArrayElementPart] = {
+ if (string.startsWith("[") && string.endsWith("]")) {
+ val positionPart: String = string.substring(1, string.length - 1)
+ Try(positionPart.toInt)
+ .fold(_ => None, fromInt)
+ } else {
+ None
+ }
+ }
+
+ private def fromInt(position: Int): Option[ArrayElementPart] =
+ refineV[NonNegative](position)
+ .fold(_ => None,
+ ref => Some(ArrayElementPart(ref)))
+}
+
+case class ArrayElementPart(position: NonNegInt) extends JsonPathPart {
+ def read(jsValue: JsValue): JsResult[JsValue] = jsValue match {
+ case JsArray(values) => values.lift(position.value)
+ .map(JsSuccess(_))
+ .getOrElse(JsError(s"Supplied array have no $position element"))
+ case _ => JsError("Expecting a JsArray but got a different structure")
+ }
+}
+
object JsonPath {
def parse(string: String): JsonPath = JsonPath(string.split('/').toList
.flatMap {
- case "" => None
- case "*" => Some(WildcardPart())
- case part => Some(PlainPart(part))
+ case "" => Nil
+ case "*" => List(WildcardPart)
+ case string if ArrayElementPart.parse(string).isDefined => ArrayElementPart.parse(string)
+ case part: String =>
+ val arrayElementPartPosition = part.indexOf('[')
+ if (arrayElementPartPosition < 0) {
+ asPlainPart(part)
+ } else if (arrayElementPartPosition == 0) {
+ asArrayElementPart(string)
+ } else {
+ asArrayElementInAnObject(string, part, arrayElementPartPosition)
+ }
})
+
+ private def asPlainPart(part: String): List[JsonPathPart] = {
+ List(PlainPart(part))
+ }
+
+ private def asArrayElementInAnObject(string: String, part: String, arrayElementPartPosition: Int): List[JsonPathPart] = {
+ ArrayElementPart.parse(string.substring(arrayElementPartPosition))
+ .map(List(PlainPart(part.substring(0, arrayElementPartPosition)), _))
+ .getOrElse(List(PlainPart(part)))
+ }
+
+ private def asArrayElementPart(string: String): List[JsonPathPart] = {
+ List(ArrayElementPart.parse(string)
+ .getOrElse(PlainPart(string)))
+ }
}
case class JsonPath(parts: List[JsonPathPart]) {
@@ -55,7 +110,8 @@
val tailAsJsonPath = JsonPath(tail)
head match {
case part: PlainPart => part.read(jsValue).flatMap(subPart => tailAsJsonPath.evaluate(subPart))
- case _: WildcardPart => tailAsJsonPath.readWildcard(jsValue)
+ case part: ArrayElementPart => part.read(jsValue).flatMap(subPart => tailAsJsonPath.evaluate(subPart))
+ case WildcardPart => tailAsJsonPath.readWildcard(jsValue)
}
}
diff --git a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/BackReferenceTest.scala b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/BackReferenceTest.scala
index 7906f7e..6d31491 100644
--- a/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/BackReferenceTest.scala
+++ b/server/protocols/jmap-rfc-8621/src/test/scala/org/apache/james/jmap/json/BackReferenceTest.scala
@@ -21,10 +21,10 @@
import eu.timepit.refined.auto._
import org.apache.james.jmap.model.Invocation.{MethodCallId, MethodName}
-import org.apache.james.jmap.routes.{BackReference, JsonPath}
+import org.apache.james.jmap.routes.{ArrayElementPart, BackReference, JsonPath, PlainPart}
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
-import play.api.libs.json.{JsError, JsSuccess, Json}
+import play.api.libs.json.{JsError, JsString, JsSuccess, Json}
class BackReferenceTest extends AnyWordSpec with Matchers {
@@ -45,6 +45,30 @@
}
}
+ "Array element parsing" should {
+ "succeed when poositive" in {
+ ArrayElementPart.parse("[1]") should equal(Some(ArrayElementPart(1)))
+ }
+ "succeed when zero" in {
+ ArrayElementPart.parse("[0]") should equal(Some(ArrayElementPart(0)))
+ }
+ "fail when negative" in {
+ ArrayElementPart.parse("[-1]") should equal(None)
+ }
+ "fail when not an int" in {
+ ArrayElementPart.parse("[invalid]") should equal(None)
+ }
+ "fail when not closed" in {
+ ArrayElementPart.parse("[0") should equal(None)
+ }
+ "fail when not open" in {
+ ArrayElementPart.parse("0]") should equal(None)
+ }
+ "fail when no bracket" in {
+ ArrayElementPart.parse("0") should equal(None)
+ }
+ }
+
"JsonPath evaluation" should {
"noop when empty" in {
val jsonPath = JsonPath.parse("")
@@ -60,6 +84,41 @@
jsonPath.evaluate(json) should equal(JsSuccess(expected))
}
+ "succeed when array element is present and root" in {
+ val jsonPath = JsonPath.parse("[1]")
+ val json = Json.parse("""["1", "2", "3"]""")
+ val expected = JsString("2")
+
+ jsonPath.evaluate(json) should equal(JsSuccess(expected))
+ }
+ "succeed when first array element is present" in {
+ val jsonPath = JsonPath.parse("path[0]")
+ val json = Json.parse("""{"path" : ["1", "2", "3"]}""")
+ val expected = JsString("1")
+
+ jsonPath.evaluate(json) should equal(JsSuccess(expected))
+ }
+ "fail when overflow" in {
+ val jsonPath = JsonPath.parse("path[3]")
+ val json = Json.parse("""{"path" : ["1", "2", "3"]}""")
+
+ jsonPath.evaluate(json) shouldBe a[JsError]
+ }
+ "parse should default to plain part when negative" in {
+ JsonPath.parse("path[-1]") should equal(JsonPath(List(PlainPart("path[-1]"))))
+ }
+ "parse should default to plain part when not an int" in {
+ JsonPath.parse("path[invalid]") should equal(JsonPath(List(PlainPart("path[invalid]"))))
+ }
+ "parse should default to plain part when empty" in {
+ JsonPath.parse("path[]") should equal(JsonPath(List(PlainPart("path[]"))))
+ }
+ "parse should default to plain part when not closed" in {
+ JsonPath.parse("path[36") should equal(JsonPath(List(PlainPart("path[36"))))
+ }
+ "parse should default to plain part when not closed and root" in {
+ JsonPath.parse("[36") should equal(JsonPath(List(PlainPart("[36"))))
+ }
"succeed when array part is present" in {
val jsonPath = JsonPath.parse("path/*")
val json = Json.parse("""{"path" : ["1", "2"]}""")