Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enable indicating the whole path when retrieving nested values in JSON #142

Merged
merged 6 commits into from
Apr 7, 2020
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ object Repository {

client
.get[Repository](uri)
.failMap {
.collectFail {
case "description" / NotFound => GithubError(descriptionNotFound)
case "license" / NotFound => GithubError(licenseNotFound)
case "license" / ("spdx_id" / NotFound) => GithubError(licenseNotInferred)
Expand All @@ -160,8 +160,8 @@ object Repository {
startYear <- json.get[ZonedDateTime]("created_at")
contributors <- json.get[String]("contributors_url")
collaborators <- json.get[String]("collaborators_url")
organizationUrl <- json.get[Option[OrganizationUrl]]("organization")
ownerUrl <- json.get[OwnerUrl]("owner")
organizationUrl <- json.get[Option[String]]("organization", "url")
ownerUrl <- json.get[String]("owner", "url")
} yield Repository(
name,
description,
Expand All @@ -170,18 +170,8 @@ object Repository {
startYear.getYear,
contributors,
collaborators.replace("{/collaborator}", ""),
organizationUrl.map(_.value),
ownerUrl.value
organizationUrl,
ownerUrl
)

final private case class OrganizationUrl(value: String) extends AnyVal

implicit private val OrganizationUrlDecoder: Decoder[OrganizationUrl] =
_.get[String]("url").map(OrganizationUrl)

final private case class OwnerUrl(value: String) extends AnyVal

implicit private val OwnerUrlDecoder: Decoder[OwnerUrl] =
_.get[String]("url").map(OwnerUrl)

}
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ object client {
Source.fromInputStream(inputStream, "UTF-8").mkString
}
)
}.failMap {
}.collectFail {
case _: FileNotFoundException => URLNotFound(uri)
}.flatMap(Json.parse).as[A]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ trait Decoder[A] {
/** Decode the given [[Json.Value]] */
def decode(json: Json.Value): Try[A]

/**
* What should this decoder return if a `Null` is found on the path to getting the value.
*
* This doesn't affect when the value itself is `Null`.
*/
def onNullPath: Try[A] = NotFound.raise

}

object Decoder {
Expand Down Expand Up @@ -72,9 +79,15 @@ object Decoder {
case value => NotADateTime(value).raise
}

implicit def OptionDecoder[A: Decoder]: Decoder[Option[A]] = {
case Json.Null => Try(None)
case value => Decoder[A].decode(value).map(Some(_))
implicit def OptionDecoder[A: Decoder]: Decoder[Option[A]] = new Decoder[Option[A]] {

override def decode(json: Json.Value): Try[Option[A]] = json match {
case Json.Null => Try(None)
case value => Decoder[A].decode(value).map(Some(_))
}

override def onNullPath: Try[Option[A]] = Try(None)

}

implicit def ListDecoder[A: Decoder]: Decoder[List[A]] = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@

package com.alejandrohdezma.sbt.github.syntax

import scala.annotation.tailrec
import scala.util.Try

import com.alejandrohdezma.sbt.github.error.NotFound
import com.alejandrohdezma.sbt.github.json.error.{InvalidPath, NotAJSONObject}
import com.alejandrohdezma.sbt.github.json.{Decoder, Json}
import com.alejandrohdezma.sbt.github.syntax.scalatry._
Expand All @@ -37,10 +37,19 @@ object json {
*
* Returns `Failure` with the error in case this is not a `Json.Object` or the decoding fails.
*/
def get[A: Decoder](path: String): Try[A] = json match {
case json: Json.Object => json.get(path).as[A].failMap { case t => InvalidPath(path, t) }
case Json.Null => NotFound.raise
case value => NotAJSONObject(value).raise
def get[A: Decoder](head: String, tail: String*): Try[A] =
recursiveGet(json, List(head +: tail: _*), Nil)

@tailrec
private def recursiveGet[A: Decoder](
value: Json.Value,
remain: List[String],
done: List[String]
): Try[A] = (value, remain) match {
case (j: Json.Value, Nil) => j.as[A].mapFail(done.foldRight(_)(InvalidPath))
case (j: Json.Object, h :: t) => recursiveGet(j.get(h), t, done :+ h)
case (Json.Null, _) => Decoder[A].onNullPath.mapFail(done.foldRight(_)(InvalidPath))
case (v, _) => done.foldRight(NotAJSONObject(v): Throwable)(InvalidPath).raise
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,37 @@ object scalatry {
* The contained throwable is used as cause for the provided one.
*
* {{{
* Failure(NotFound).failMap {
* Failure(NotFound).collectFail {
* case NotFound => UrlNotFound
* } // Result: Failure(UrlNotFound(cause = NotFound))
*
* Failure(NotAString).failMap {
* Failure(NotAString).collectFail {
* case NotFound => UrlNotFound
* } // Result: Failure(NotAString)
*
*
* Success(12).failMap {
* Success(12).collectFail {
* case NotFound => UrlNotFound
* } // Result: Success(12)
* }}}
*/
def failMap(pf: PartialFunction[Throwable, Throwable]): Try[A] = aTry match {
def collectFail(pf: PartialFunction[Throwable, Throwable]): Try[A] = aTry match {
case Success(a) => Success(a)
case Failure(b) => Failure(pf.andThen(_.initCause(b)).lift(b).getOrElse(b))
case Failure(b) => Failure(pf.andThen(initCause(b)).lift(b).getOrElse(b))
}

/**
* The given function is applied if this is a `Failure`.
* The contained throwable is used as cause for the provided one.
*
* {{{
* Failure(NotFound).mapFail(_ => UrlNotFound) // Result: Failure(UrlNotFound(cause = NotFound))
* Success(12).mapFail(_ => UrlNotFound) // Result: Success(12)
* }}}
*/
def mapFail(pf: Throwable => Throwable): Try[A] = aTry match {
case Success(a) => Success(a)
case Failure(b) => Failure(pf.andThen(initCause(b))(b))
}

/**
Expand All @@ -57,9 +71,13 @@ object scalatry {
*/
def failAs(t: => Throwable): Try[A] = aTry match {
case Success(a) => Success(a)
case Failure(b) => Failure(t.initCause(b))
case Failure(b) => Failure(initCause(b)(t))
}

}

@SuppressWarnings(Array("all"))
private def initCause[A](cause: Throwable)(t: Throwable): Throwable =
if (cause != t && t.getCause == null) t.initCause(cause) else t

}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,41 @@ class JsonSyntaxSpec extends Specification {
json.get[Int]("miau") must beSuccessfulTry(42)
}

"return requested type on a path chain" >> {
val json: Json.Value = Json.Object(
Map("m1" -> Json.Object(Map("m2" -> Json.Object(Map("m3" -> Json.Number(42d))))))
)

json.get[Int]("m1", "m2", "m3") must beSuccessfulTry(42)
}

"keep failed path on decoding failure" >> {
val json: Json.Value = Json.Object(
Map("m1" -> Json.Object(Map("m2" -> Json.Object(Map("m3" -> Json.Text("miau"))))))
)

val expected =
InvalidPath("m1", InvalidPath("m2", InvalidPath("m3", NotANumber(Json.Text("miau")))))

json.get[Int]("m1", "m2", "m3") must beFailedTry(equalTo(expected))
}

"keep failed path on not a json object" >> {
val json: Json.Value = Json.Object(Map("m1" -> Json.Object(Map("m2" -> Json.Text("miau")))))

val expected = InvalidPath("m1", InvalidPath("m2", NotAJSONObject(Json.Text("miau"))))

json.get[Int]("m1", "m2", "m3") must beFailedTry(equalTo(expected))
}

"keep failed path on not found" >> {
val json: Json.Value = Json.Object(Map("m1" -> Json.Object(Map())))

val expected = InvalidPath("m1", InvalidPath("m2", NotFound))

json.get[Int]("m1", "m2", "m3") must beFailedTry(equalTo(expected))
}

"propagate Decoder[A] failure" >> {
val json: Json.Value = Json.Object(Map("miau" -> Json.Number(42d)))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ import org.specs2.mutable.Specification

class TrySyntaxSpec extends Specification {

"failMap" should {
"collectFail" should {

"change value if Failure" >> {
val failure = Failure(NotFound)

val result = failure.failMap {
val result = failure.collectFail {
case _ => URLNotFound("url")
}

Expand All @@ -40,7 +40,7 @@ class TrySyntaxSpec extends Specification {
"do nothing if Success" >> {
val success = Try(42)

val result = success.failMap {
val result = success.collectFail {
case _ => NotFound
}

Expand All @@ -49,6 +49,26 @@ class TrySyntaxSpec extends Specification {

}

"mapFail" should {

"change value if Failure" >> {
val failure = Failure(NotFound)

val result = failure.mapFail(_ => URLNotFound("url"))

result must beAFailedTry(equalTo(URLNotFound("url")))
}

"do nothing if Success" >> {
val success = Try(42)

val result = success.mapFail(_ => NotFound)

result must beSuccessfulTry(42)
}

}

"failAs" should {

"change value if Failure" >> {
Expand Down