From f0992ae1e4fee72d2aa442d7b17cb1b9b45f28c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Tue, 26 Nov 2019 16:28:27 +0100 Subject: [PATCH] Import codecs handout --- .vscode/settings.json | 8 + build.sbt | 14 + grading-tests.jar | Bin 0 -> 20155 bytes project/MOOCSettings.scala | 25 ++ project/StudentTasks.scala | 323 ++++++++++++++++++++++++ project/build.properties | 1 + project/buildSettings.sbt | 7 + project/plugins.sbt | 2 + src/main/scala/codecs/codecs.scala | 266 +++++++++++++++++++ src/main/scala/codecs/json.scala | 73 ++++++ src/test/scala/codecs/CodecsSuite.scala | 115 +++++++++ student.sbt | 9 + 12 files changed, 843 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 build.sbt create mode 100644 grading-tests.jar create mode 100644 project/MOOCSettings.scala create mode 100644 project/StudentTasks.scala create mode 100644 project/build.properties create mode 100644 project/buildSettings.sbt create mode 100644 project/plugins.sbt create mode 100644 src/main/scala/codecs/codecs.scala create mode 100644 src/main/scala/codecs/json.scala create mode 100644 src/test/scala/codecs/CodecsSuite.scala create mode 100644 student.sbt diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a35362b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "dotty": { + "trace": { + "remoteTracingUrl": "wss://lamppc36.epfl.ch/dotty-remote-tracer/upload/lsp.log", + "server": { "format": "JSON", "verbosity": "verbose" } + } + } +} diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..7c8827c --- /dev/null +++ b/build.sbt @@ -0,0 +1,14 @@ +course := "progfun2" +assignment := "codecs" +name := course.value + "-" + assignment.value +testSuite := "codecs.CodecsSuite" + +scalaVersion := "0.19.0-RC1" +scalacOptions ++= Seq("-deprecation") +libraryDependencies ++= Seq( + ("org.scalacheck" %% "scalacheck" % "1.14.2" % Test).withDottyCompat(scalaVersion.value), + ("org.typelevel" %% "jawn-parser" % "0.14.2").withDottyCompat(scalaVersion.value), + "com.novocode" % "junit-interface" % "0.11" % Test +) + +testOptions in Test += Tests.Argument(TestFrameworks.JUnit, "-a", "-v", "-s") diff --git a/grading-tests.jar b/grading-tests.jar new file mode 100644 index 0000000000000000000000000000000000000000..46ea8d1240157b6a5cc2e5bc83db53f0a4c71558 GIT binary patch literal 20155 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 b!|=|luTM$vH}2fov9Y;rST=BD?$Lh%u=#NU literal 0 HcmV?d00001 diff --git a/project/MOOCSettings.scala b/project/MOOCSettings.scala new file mode 100644 index 0000000..ea30b36 --- /dev/null +++ b/project/MOOCSettings.scala @@ -0,0 +1,25 @@ +package ch.epfl.lamp + +import sbt._ +import sbt.Keys._ + +/** + * Settings shared by all assignments, reused in various tasks. + */ +object MOOCSettings extends AutoPlugin { + + object autoImport { + val course = SettingKey[String]("course") + val assignment = SettingKey[String]("assignment") + val testSuite = SettingKey[String]("testSuite") + val options = SettingKey[Map[String, Map[String, String]]]("options") + } + + override def trigger = allRequirements + + override val projectSettings: Seq[Def.Setting[_]] = Seq( + parallelExecution in Test := false, + // Report test result after each test instead of waiting for every test to finish + logBuffered in Test := false + ) +} diff --git a/project/StudentTasks.scala b/project/StudentTasks.scala new file mode 100644 index 0000000..587ba85 --- /dev/null +++ b/project/StudentTasks.scala @@ -0,0 +1,323 @@ +package ch.epfl.lamp + +import sbt._ +import Keys._ + +// import scalaj.http._ +import java.io.{File, FileInputStream, IOException} +import java.nio.file.FileSystems +import org.apache.commons.codec.binary.Base64 +// import play.api.libs.json.{Json, JsObject, JsPath} +import scala.util.{Failure, Success, Try} + +import MOOCSettings.autoImport._ + +case class AssignmentInfo( + key: String, + itemId: String, + premiumItemId: Option[String], + partId: String +) + +/** + * Provides tasks for submitting the assignment + */ +object StudentTasks extends AutoPlugin { + + object autoImport { + val assignmentInfo = SettingKey[AssignmentInfo]("assignmentInfo") + + val packageSourcesOnly = TaskKey[File]("packageSourcesOnly", "Package the sources of the project") + val packageBinWithoutResources = TaskKey[File]("packageBinWithoutResources", "Like packageBin, but without the resources") + val packageSubmissionZip = TaskKey[File]("packageSubmissionZip") + val packageSubmission = inputKey[Unit]("package solution as an archive file") + val runGradingTests = taskKey[Unit]("run black-box tests used for final grading") + } + + + import autoImport._ + + override lazy val projectSettings = Seq( + packageSubmissionSetting, + // submitSetting, // FIXME: restore assignmentInfo setting on assignments + runGradingTestsSettings, + + fork := true, + connectInput in run := true, + outputStrategy := Some(StdoutOutput) + ) ++ packageSubmissionZipSettings + + lazy val runGradingTestsSettings = runGradingTests := { + val testSuiteJar = "grading-tests.jar" + if (!new File(testSuiteJar).exists) { + throw new MessageOnlyException(s"Could not find tests JarFile: $testSuiteJar") + } + + val classPath = s"${(Test / dependencyClasspath).value.map(_.data).mkString(File.pathSeparator)}${File.pathSeparator}$testSuiteJar" + val junitProcess = + Fork.java.fork( + ForkOptions(), + "-cp" :: classPath :: + "org.junit.runner.JUnitCore" :: + (Test / testSuite).value :: + Nil + ) + + // Wait for tests to complete. + junitProcess.exitValue() + } + + + /** ********************************************************** + * SUBMITTING A SOLUTION TO COURSERA + */ + + val packageSubmissionZipSettings = Seq( + packageSubmissionZip := { + val submission = crossTarget.value / "submission.zip" + val sources = (packageSourcesOnly in Compile).value + val binaries = (packageBinWithoutResources in Compile).value + IO.zip(Seq(sources -> "sources.zip", binaries -> "binaries.jar"), submission) + submission + }, + artifactClassifier in packageSourcesOnly := Some("sources"), + artifact in (Compile, packageBinWithoutResources) ~= (art => art.withName(art.name + "-without-resources")) + ) ++ + inConfig(Compile)( + Defaults.packageTaskSettings(packageSourcesOnly, Defaults.sourceMappings) ++ + Defaults.packageTaskSettings(packageBinWithoutResources, Def.task { + val relativePaths = + (unmanagedResources in Compile).value.flatMap(Path.relativeTo((unmanagedResourceDirectories in Compile).value)(_)) + (mappings in (Compile, packageBin)).value.filterNot { case (_, path) => relativePaths.contains(path) } + }) + ) + + val maxSubmitFileSize = { + val mb = 1024 * 1024 + 10 * mb + } + + /** Check that the jar exists, isn't empty, isn't crazy big, and can be read + * If so, encode jar as base64 so we can send it to Coursera + */ + def prepareJar(jar: File, s: TaskStreams): String = { + val errPrefix = "Error submitting assignment jar: " + val fileLength = jar.length() + if (!jar.exists()) { + s.log.error(errPrefix + "jar archive does not exist\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength == 0L) { + s.log.error(errPrefix + "jar archive is empty\n" + jar.getAbsolutePath) + failSubmit() + } else if (fileLength > maxSubmitFileSize) { + s.log.error(errPrefix + "jar archive is too big. Allowed size: " + + maxSubmitFileSize + " bytes, found " + fileLength + " bytes.\n" + + jar.getAbsolutePath) + failSubmit() + } else { + val bytes = new Array[Byte](fileLength.toInt) + val sizeRead = try { + val is = new FileInputStream(jar) + val read = is.read(bytes) + is.close() + read + } catch { + case ex: IOException => + s.log.error(errPrefix + "failed to read sources jar archive\n" + ex.toString) + failSubmit() + } + if (sizeRead != bytes.length) { + s.log.error(errPrefix + "failed to read the sources jar archive, size read: " + sizeRead) + failSubmit() + } else encodeBase64(bytes) + } + } + + /** Task to package solution to a given file path */ + lazy val packageSubmissionSetting = packageSubmission := { + val args: Seq[String] = Def.spaceDelimited("[path]").parsed + val s: TaskStreams = streams.value // for logging + val jar = (packageSubmissionZip in Compile).value + + val base64Jar = prepareJar(jar, s) + + val path = args.headOption.getOrElse((baseDirectory.value / "submission.jar").absolutePath) + scala.tools.nsc.io.File(path).writeAll(base64Jar) + } + +/* + /** Task to submit a solution to coursera */ + val submit = inputKey[Unit]("submit solution to Coursera") + lazy val submitSetting = submit := { + val args: Seq[String] = Def.spaceDelimited("").parsed + val s: TaskStreams = streams.value // for logging + val jar = (packageSubmissionZip in Compile).value + + val assignmentDetails = assignmentInfo.value + val assignmentKey = assignmentDetails.key + val courseName = + course.value match { + case "capstone" => "scala-capstone" + case "bigdata" => "scala-spark-big-data" + case other => other + } + + val partId = assignmentDetails.partId + val itemId = assignmentDetails.itemId + val premiumItemId = assignmentDetails.premiumItemId + + val (email, secret) = args match { + case email :: secret :: Nil => + (email, secret) + case _ => + val inputErr = + s"""|Invalid input to `submit`. The required syntax for `submit` is: + |submit + | + |The submit token is NOT YOUR LOGIN PASSWORD. + |It can be obtained from the assignment page: + |https://www.coursera.org/learn/$courseName/programming/$itemId + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + """.stripMargin + s.log.error(inputErr) + failSubmit() + } + + val base64Jar = prepareJar(jar, s) + val json = + s"""|{ + | "assignmentKey":"$assignmentKey", + | "submitterEmail":"$email", + | "secret":"$secret", + | "parts":{ + | "$partId":{ + | "output":"$base64Jar" + | } + | } + |}""".stripMargin + + def postSubmission[T](data: String): Try[HttpResponse[String]] = { + val http = Http("https://www.coursera.org/api/onDemandProgrammingScriptSubmissions.v1") + val hs = List( + ("Cache-Control", "no-cache"), + ("Content-Type", "application/json") + ) + s.log.info("Connecting to Coursera...") + val response = Try(http.postData(data) + .headers(hs) + .option(HttpOptions.connTimeout(10000)) // scalaj default timeout is only 100ms, changing that to 10s + .asString) // kick off HTTP POST + response + } + + val connectMsg = + s"""|Attempting to submit "${assignment.value}" assignment in "$courseName" course + |Using: + |- email: $email + |- submit token: $secret""".stripMargin + s.log.info(connectMsg) + + def reportCourseraResponse(response: HttpResponse[String]): Unit = { + val code = response.code + val respBody = response.body + + /* Sample JSON response from Coursera + { + "message": "Invalid email or token.", + "details": { + "learnerMessage": "Invalid email or token." + } + } + */ + + // Success, Coursera responds with 2xx HTTP status code + if (response.is2xx) { + val successfulSubmitMsg = + s"""|Successfully connected to Coursera. (Status $code) + | + |Assignment submitted successfully! + | + |You can see how you scored by going to: + |https://www.coursera.org/learn/$courseName/programming/$itemId/ + |${ + premiumItemId.fold("") { id => + s"""or (for premium learners): + |https://www.coursera.org/learn/$courseName/programming/$id + """.stripMargin + } + } + |and clicking on "My Submission".""".stripMargin + s.log.info(successfulSubmitMsg) + } + + // Failure, Coursera responds with 4xx HTTP status code (client-side failure) + else if (response.is4xx) { + val result = Try(Json.parse(respBody)).toOption + val learnerMsg = result match { + case Some(resp: JsObject) => + (JsPath \ "details" \ "learnerMessage").read[String].reads(resp).get + case Some(x) => // shouldn't happen + "Could not parse Coursera's response:\n" + x + case None => + "Could not parse Coursera's response:\n" + respBody + } + val failedSubmitMsg = + s"""|Submission failed. + |There was something wrong while attempting to submit. + |Coursera says: + |$learnerMsg (Status $code)""".stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera responds with 5xx HTTP status code (server-side failure) + else if (response.is5xx) { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera seems to be unavailable at the moment (Status $code) + |Check https://status.coursera.org/ and try again in a few minutes. + """.stripMargin + s.log.error(failedSubmitMsg) + } + + // Failure, Coursera repsonds with an unexpected status code + else { + val failedSubmitMsg = + s"""|Submission failed. + |Coursera replied with an unexpected code (Status $code) + """.stripMargin + s.log.error(failedSubmitMsg) + } + } + + // kick it all off, actually make request + postSubmission(json) match { + case Success(resp) => reportCourseraResponse(resp) + case Failure(e) => + val failedConnectMsg = + s"""|Connection to Coursera failed. + |There was something wrong while attempting to connect to Coursera. + |Check your internet connection. + |${e.toString}""".stripMargin + s.log.error(failedConnectMsg) + } + + } +*/ + + def failSubmit(): Nothing = { + sys.error("Submission failed") + } + + /** + * ***************** + * DEALING WITH JARS + */ + def encodeBase64(bytes: Array[Byte]): String = + new String(Base64.encodeBase64(bytes)) +} diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..c0bab04 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.2.8 diff --git a/project/buildSettings.sbt b/project/buildSettings.sbt new file mode 100644 index 0000000..f7847f8 --- /dev/null +++ b/project/buildSettings.sbt @@ -0,0 +1,7 @@ +// Used for base64 encoding +libraryDependencies += "commons-codec" % "commons-codec" % "1.10" + +// Used for Coursera submussion +// libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.3.0" +// libraryDependencies += "com.typesafe.play" %% "play-json" % "2.6.9" + diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..64a2492 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,2 @@ +addSbtPlugin("io.get-coursier" % "sbt-coursier" % "2.0.0-RC3-5") +addSbtPlugin("ch.epfl.lamp" % "sbt-dotty" % "0.3.4") diff --git a/src/main/scala/codecs/codecs.scala b/src/main/scala/codecs/codecs.scala new file mode 100644 index 0000000..a4073b8 --- /dev/null +++ b/src/main/scala/codecs/codecs.scala @@ -0,0 +1,266 @@ +package codecs + +/** + * A data type modeling JSON values. + * + * For example, the `42` integer JSON value can be modeled as `Json.Num(42)` + */ +sealed trait Json { + /** + * Try to decode this JSON value into a value of type `A` by using + * the given decoder. + * + * Note that you have to explicitly fix `A` type parameter when you call the method: + * + * {{{ + * someJsonValue.decodeAs[User] // OK + * someJsonValue.decodeAs // Wrong! + * }}} + */ + def decodeAs[A](given decoder: Decoder[A]): Option[A] = decoder.decode(this) +} + +object Json { + /** The JSON `null` value */ + case object Null extends Json + /** JSON boolean values */ + case class Bool(value: Boolean) extends Json + /** JSON numeric values */ + case class Num(value: BigDecimal) extends Json + /** JSON string values */ + case class Str(value: String) extends Json + /** JSON objects */ + case class Obj(fields: Map[String, Json]) extends Json + /** JSON arrays */ + case class Arr(items: List[Json]) extends Json +} + +/** + * A type class that turns a value of type `A` into its JSON representation. + */ +trait Encoder[-A] { + + def encode(value: A): Json + + /** + * Transforms this `Encoder[A]` into an `Encoder[B]`, given a transformation function + * from `B` to `A`. + * + * For instance, given a `Encoder[String]`, we can get an `Encoder[UUID]`: + * + * {{{ + * def uuidEncoder(given stringEncoder: Encoder[String]): Encoder[UUID] = + * stringEncoder.transform[UUID](uuid => uuid.toString) + * }}} + * + * This operation is also known as ?contramap?. + */ + def transform[B](f: B => A): Encoder[B] = + Encoder.fromFunction[B](value => this.encode(f(value))) +} + +object Encoder extends GivenEncoders { + + /** + * Convenient method for creating an instance of encoder from a function `f` + */ + def fromFunction[A](f: A => Json) = new Encoder[A] { + def encode(value: A): Json = f(value) + } + +} + +trait GivenEncoders { + + /** An encoder for the `Unit` value */ + given Encoder[Unit] = Encoder.fromFunction(_ => Json.Null) + + /** An encoder for `Int` values */ + given Encoder[Int] = Encoder.fromFunction(n => Json.Num(BigDecimal(n))) + + /** An encoder for `String` values */ + given Encoder[String] = + ??? // TODO Implement the `Encoder[String]` instance + + /** An encoder for `Boolean` values */ + // TODO Define a given `Encoder[Boolean]` instance + + /** + * Encodes a list of values of type `A` into a JSON array containing + * the list elements encoded with the given `encoder` + */ + given [A](given encoder: Encoder[A]): Encoder[List[A]] = + Encoder.fromFunction(as => Json.Arr(as.map(encoder.encode))) + +} + +/** + * A specialization of `Encoder` that returns JSON objects only + */ +trait ObjectEncoder[-A] extends Encoder[A] { + // Refines the encoding result to `Json.Obj` + def encode(value: A): Json.Obj + + /** + * Combines `this` encoder with `that` encoder. + * Returns an encoder producing a JSON object containing both + * fields of `this` encoder and fields of `that` encoder. + */ + def zip[B](that: ObjectEncoder[B]): ObjectEncoder[(A, B)] = + ObjectEncoder.fromFunction { (a, b) => + Json.Obj(this.encode(a).fields ++ that.encode(b).fields) + } +} + +object ObjectEncoder { + + /** + * Convenient method for creating an instance of object encoder from a function `f` + */ + def fromFunction[A](f: A => Json.Obj): ObjectEncoder[A] = new ObjectEncoder[A] { + def encode(value: A): Json.Obj = f(value) + } + + /** + * An encoder for values of type `A` that produces a JSON object with one field + * named according to the supplied `name` and containing the encoded value. + */ + def field[A](name: String)(given encoder: Encoder[A]): ObjectEncoder[A] = + ObjectEncoder.fromFunction(a => Json.Obj(Map(name -> encoder.encode(a)))) + +} + +/** + * The dual of an encoder. Decodes a serialized value into its initial type `A`. + */ +trait Decoder[+A] { + /** + * @param data The data to de-serialize + * @return The decoded value wrapped in `Some`, or `None` if decoding failed + */ + def decode(data: Json): Option[A] + + /** + * Combines `this` decoder with `that` decoder. + * Returns a decoder that invokes both `this` decoder and `that` + * decoder and returns a pair of decoded value in case both succeed, + * or `None` if at least one failed. + */ + def zip[B](that: Decoder[B]): Decoder[(A, B)] = + Decoder.fromFunction { json => + this.decode(json).zip(that.decode(json)) + } + + /** + * Transforms this `Decoder[A]` into a `Decoder[B]`, given a transformation function + * from `A` to `B`. + * + * This operation is also known as ?map?. + */ + def transform[B](f: A => B): Decoder[B] = + Decoder.fromFunction(json => this.decode(json).map(f)) +} + +object Decoder extends GivenDecoders { + + /** + * Convenient method to build a decoder instance from a function `f` + */ + def fromFunction[A](f: Json => Option[A]): Decoder[A] = new Decoder[A] { + def decode(data: Json): Option[A] = f(data) + } + + /** + * Alternative method for creating decoder instances + */ + def fromPartialFunction[A](pf: PartialFunction[Json, A]): Decoder[A] = + fromFunction(pf.lift) + +} + +trait GivenDecoders { + + /** A decoder for the `Unit` value */ + given Decoder[Unit] = + Decoder.fromPartialFunction { case Json.Null => () } + + /** A decoder for `Int` values. Hint: use the `isValidInt` method of `BigDecimal`. */ + // TODO Define a given `Decoder[Int]` instance + + /** A decoder for `String` values */ + // TODO Define a given `Decoder[String]` instance + + /** A decoder for `Boolean` values */ + // TODO Define a given `Decoder[Boolean]` instance + + /** + * A decoder for JSON arrays. It decodes each item of the array + * using the given `decoder`. The resulting decoder succeeds only + * if all the JSON array items are successfully decoded. + */ + given [A](given decoder: Decoder[A]): Decoder[List[A]] = + Decoder.fromFunction { + ??? + } + + /** + * A decoder for JSON objects. It decodes the value of a field of + * the supplied `name` using the given `decoder`. + */ + def field[A](name: String)(given decoder: Decoder[A]): Decoder[A] = + ??? + +} + +case class Person(name: String, age: Int) + +object Person extends PersonCodecs + +trait PersonCodecs { + + /** The encoder for `Person` */ + given Encoder[Person] = + ObjectEncoder.field[String]("name") + .zip(ObjectEncoder.field[Int]("age")) + .transform[Person](user => (user.name, user.age)) + + /** The corresponding decoder for `Person` */ + given Decoder[Person] = + ??? + +} + +case class Contacts(people: List[Person]) + +object Contacts extends ContactsCodecs + +trait ContactsCodecs { + + // TODO Define the encoder and the decoder for `Contacts` + // The JSON representation of a value of type `Contacts` should be + // a JSON object with a single field named ?people? containing an + // array of values of type `Person` (reuse the `Person` codecs) + +} + +// In case you want to try your code, here is a simple `Main` +// that can be used as a starting point. Otherwise, you can use +// the REPL (use the `console` sbt task). +object Main { + + def main(args: Array[String]): Unit = { + println(renderJson(42)) + println(renderJson("foo")) + + val maybeJsonString = parseJson(""" "foo" """) + val maybeJsonObj = parseJson(""" { "name": "Alice", "age": 42 } """) + val maybeJsonObj2 = parseJson(""" { "name": "Alice", "age": "42" } """) + // Uncomment the following lines as you progress in the assignment + // println(maybeJsonString.toOption.flatMap(_.decodeAs[Int])) + // println(maybeJsonString.toOption.flatMap(_.decodeAs[String])) + // println(maybeJsonObj.toOption.flatMap(_.decodeAs[Person])) + // println(maybeJsonObj2.toOption.flatMap(_.decodeAs[Person])) + // println(renderJson(Person("Bob", 66))) + } + +} diff --git a/src/main/scala/codecs/json.scala b/src/main/scala/codecs/json.scala new file mode 100644 index 0000000..ccd00a2 --- /dev/null +++ b/src/main/scala/codecs/json.scala @@ -0,0 +1,73 @@ +package codecs + +import org.typelevel.jawn.{ Parser, SimpleFacade } +import scala.collection.mutable +import scala.util.Try + +// Utility methods that decode values from `String` JSON blobs, and +// render values to `String` JSON blobs + +/** + * Parse a JSON document contained in a `String` value into a `Json` value + */ +def parseJson(s: String): Try[Json] = Parser.parseFromString[Json](s) + +/** + * Parse the JSON value from the supplied `s` parameter, and then try to decode + * it as a value of type `A` using the given `decoder`. + * + * Returns a failure if JSON parsing failed, or if decoding failed. + */ +def parseAndDecode[A](s: String)(given decoder: Decoder[A]): Try[A] = + for { + json <- parseJson(s) + a <- decoder.decode(json).toRight(new Exception("Decoding failed")).toTry + } yield a + +/** + * Render the supplied `value` into JSON using the given `encoder`. + */ +def renderJson[A](value: A)(given encoder: Encoder[A]): String = + render(encoder.encode(value)) + +private def render(json: Json): String = json match { + case Json.Null => "null" + case Json.Bool(b) => b.toString + case Json.Num(n) => n.toString + case Json.Str(s) => renderString(s) + case Json.Arr(vs) => vs.map(render).mkString("[", ",", "]") + case Json.Obj(vs) => vs.map { case (k, v) => s"${renderString(k)}:${render(v)}" }.mkString("{", ",", "}") +} + +private def renderString(s: String): String = { + val sb = new StringBuilder + sb.append('"') + var i = 0 + val len = s.length + while (i < len) { + s.charAt(i) match { + case '"' => sb.append("\\\"") + case '\\' => sb.append("\\\\") + case '\b' => sb.append("\\b") + case '\f' => sb.append("\\f") + case '\n' => sb.append("\\n") + case '\r' => sb.append("\\r") + case '\t' => sb.append("\\t") + case c => + if (c < ' ') sb.append("\\u%04x" format c.toInt) + else sb.append(c) + } + i += 1 + } + sb.append('"').toString +} + +given SimpleFacade[Json] { + def jnull() = Json.Null + def jtrue() = Json.Bool(true) + def jfalse() = Json.Bool(false) + def jnum(s: CharSequence, decIndex: Int, expIndex: Int) = Json.Num(BigDecimal(s.toString)) + def jstring(s: CharSequence) = Json.Str(s.toString) + def jarray(vs: List[Json]) = Json.Arr(vs) + def jobject(vs: Map[String, Json]) = Json.Obj(vs) +} diff --git a/src/test/scala/codecs/CodecsSuite.scala b/src/test/scala/codecs/CodecsSuite.scala new file mode 100644 index 0000000..1e3ffa1 --- /dev/null +++ b/src/test/scala/codecs/CodecsSuite.scala @@ -0,0 +1,115 @@ +package codecs + +import org.scalacheck +import org.scalacheck.{ Gen, Prop } +import org.scalacheck.Prop.propBoolean +import org.junit.{ Assert, Test } +import scala.reflect.ClassTag + +class CodecsSuite extends GivenEncoders, GivenDecoders, PersonCodecs, ContactsCodecs, TestEncoders, TestDecoders { + + def checkProperty(prop: Prop): Unit = { + val result = scalacheck.Test.check(scalacheck.Test.Parameters.default, prop) + def fail(labels: Set[String], fallback: String): Nothing = + if labels.isEmpty then throw new AssertionError(fallback) + else throw new AssertionError(labels.mkString(". ")) + result.status match { + case scalacheck.Test.Passed | _: scalacheck.Test.Proved => () + case scalacheck.Test.Failed(_, labels) => fail(labels, "A property failed.") + case scalacheck.Test.PropException(_, e, labels) => fail(labels, s"An exception was thrown during property evaluation: $e.") + case scalacheck.Test.Exhausted => fail(Set.empty, "Unable to generate data.") + } + } + + /** + * Check that a value of an arbitrary type `A` can be encoded and then successfully + * decoded with the given pair of encoder and decoder. + */ + def encodeAndThenDecodeProp[A](a: A)(given encA: Encoder[A], decA: Decoder[A]): Prop = { + val maybeDecoded = decA.decode(encA.encode(a)) + maybeDecoded.contains(a) :| s"Encoded value '$a' was not successfully decoded. Got '$maybeDecoded'." + } + + @Test def `it is possible to encode and decode the 'Unit' value (0pts)`(): Unit = { + checkProperty(Prop.forAll((unit: Unit) => encodeAndThenDecodeProp(unit))) + } + + @Test def `it is possible to encode and decode 'Int' values (1pt)`(): Unit = { + checkProperty(Prop.forAll((x: Int) => encodeAndThenDecodeProp(x))) + } + + @Test def `the 'Int' decoder should reject invalid 'Int' values (2pts)`(): Unit = { + val decoded = summon[Decoder[Int]].decode(Json.Num(4.2)) + assert(decoded.isEmpty, "decoding 4.2 as an integer value should fail") + } + + @Test def `a 'String' value should be encoded as a JSON string (1pt)`(): Unit = { + assert(summon[Encoder[String]].encode("foo") == Json.Str("foo")) + } + + @Test def `it is possible to encode and decode 'String' values (1pt)`(): Unit = { + checkProperty(Prop.forAll((s: String) => encodeAndThenDecodeProp(s))) + } + + @Test def `a 'Boolean' value should be encoded as a JSON boolean (1pt)`(): Unit = { + val encoder = summon[Encoder[Boolean]] + assert(encoder.encode(true) == Json.Bool(true)) + assert(encoder.encode(false) == Json.Bool(false)) + } + + @Test def `it is possible to encode and decode 'Boolean' values (1pt)`(): Unit = { + checkProperty(Prop.forAll((b: Boolean) => encodeAndThenDecodeProp(b))) + } + + @Test def `a 'List[A]' value should be encoded as a JSON array (0pts)`(): Unit = { + val xs = 1 :: 2 :: Nil + val encoder = summon[Encoder[List[Int]]] + assert(encoder.encode(xs) == Json.Arr(List(Json.Num(1), Json.Num(2)))) + } + + @Test def `it is possible to encode and decode lists (5pts)`(): Unit = { + checkProperty(Prop.forAll((xs: List[Int]) => encodeAndThenDecodeProp(xs))) + } + + @Test def `a 'Person' value should be encoded as a JSON object (1pt)`(): Unit = { + val person = Person("Alice", 42) + val json = Json.Obj(Map("name" -> Json.Str("Alice"), "age" -> Json.Num(42))) + val encoder = summon[Encoder[Person]] + assert(encoder.encode(person) == json) + } + + @Test def `it is possible to encode and decode people (4pts)`(): Unit = { + checkProperty(Prop.forAll((s: String, x: Int) => encodeAndThenDecodeProp(Person(s, x)))) + } + + @Test def `a 'Contacts' value should be encoded as a JSON object (1pt)`(): Unit = { + val contacts = Contacts(List(Person("Alice", 42))) + val json = Json.Obj(Map("people" -> + Json.Arr(List(Json.Obj(Map("name" -> Json.Str("Alice"), "age" -> Json.Num(42))))) + )) + val encoder = summon[Encoder[Contacts]] + assert(encoder.encode(contacts) == json) + } + + @Test def `it is possible to encode and decode contacts (4pts)`(): Unit = { + val peopleGenerator = Gen.listOf(Gen.resultOf((s: String, x: Int) => Person(s, x))) + checkProperty(Prop.forAll(peopleGenerator)(people => encodeAndThenDecodeProp(Contacts(people)))) + } + +} + +trait TestEncoders extends EncoderFallbackInstance + +trait EncoderFallbackInstance { + + given [A](given ct: ClassTag[A]): Encoder[A] = throw new AssertionError(s"No given instance of `Encoder[${ct.runtimeClass.getSimpleName}]`") + +} + +trait TestDecoders extends DecoderFallbackInstance + +trait DecoderFallbackInstance { + + given [A](given ct: ClassTag[A]): Decoder[A] = throw new AssertionError(s"No given instance of `Decoder[${ct.runtimeClass.getSimpleName}]") + +} \ No newline at end of file diff --git a/student.sbt b/student.sbt new file mode 100644 index 0000000..855fa0c --- /dev/null +++ b/student.sbt @@ -0,0 +1,9 @@ +// Used for base64 encoding +libraryDependencies += "commons-codec" % "commons-codec" % "1.10" + +// Used for Coursera submussion +// libraryDependencies += "org.scalaj" %% "scalaj-http" % "2.4.2" +// libraryDependencies += "com.typesafe.play" %% "play-json" % "2.7.4" + +// Student tasks (i.e. packageSubmission) +enablePlugins(StudentTasks)