commit f0992ae1e4fee72d2aa442d7b17cb1b9b45f28c4 Author: Timothée Floure Date: Tue Nov 26 16:28:27 2019 +0100 Import codecs handout 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 0000000..46ea8d1 Binary files /dev/null and b/grading-tests.jar differ 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)