Skip to content

Commit

Permalink
Add methods to fetch degrees by their acronyms.
Browse files Browse the repository at this point in the history
Update dependencies.
  • Loading branch information
Lasering committed Jan 26, 2021
1 parent 5a3c3bf commit e67f8d2
Show file tree
Hide file tree
Showing 12 changed files with 73 additions and 40 deletions.
8 changes: 4 additions & 4 deletions build.sbt
Expand Up @@ -5,7 +5,7 @@ name := "fenixedu-scala-sdk"
// ==== Compile Options =================================================================================================
// ======================================================================================================================
javacOptions ++= Seq("-Xlint", "-encoding", "UTF-8", "-Dfile.encoding=utf-8")
scalaVersion := "2.13.3"
scalaVersion := "2.13.4"

scalacOptions ++= Seq(
"-encoding", "utf-8", // Specify character encoding used by source files.
Expand Down Expand Up @@ -54,13 +54,13 @@ initialCommands in console :=
// ==== Dependencies ====================================================================================================
// ======================================================================================================================
libraryDependencies ++= Seq("blaze-client", "circe").map { module =>
"org.http4s" %% s"http4s-$module" % "1.0.0-M3"
"org.http4s" %% s"http4s-$module" % "1.0.0-M13"
} ++ Seq(
"io.circe" %% "circe-derivation" % "0.13.0-M4",
"io.circe" %% "circe-derivation" % "0.13.0-M5",
"io.circe" %% "circe-parser" % "0.13.0",
"com.beachape" %% "enumeratum-circe" % "1.6.1",
"ch.qos.logback" % "logback-classic" % "1.2.3" % Test,
"org.scalatest" %% "scalatest" % "3.2.0" % Test,
"org.scalatest" %% "scalatest" % "3.2.3" % Test,
)
addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1")

Expand Down
2 changes: 1 addition & 1 deletion project/build.properties
@@ -1 +1 @@
sbt.version=1.3.10
sbt.version=1.4.6
6 changes: 3 additions & 3 deletions src/main/scala/org/fenixedu/sdk/FenixEduClient.scala
@@ -1,12 +1,12 @@
package org.fenixedu.sdk

import cats.effect.Sync
import cats.effect.Concurrent
import org.fenixedu.sdk.models.{Course => _, _}
import org.fenixedu.sdk.services._
import org.http4s.client.Client
import org.http4s.Uri
import org.http4s.client.Client

class FenixEduClient[F[_]](val baseUri: Uri)(implicit client: Client[F], F: Sync[F]) {
class FenixEduClient[F[_]](val baseUri: Uri)(implicit client: Client[F], F: Concurrent[F]) {
val uri: Uri = baseUri / "v1"

/** @return returns some basic information about the institution where the application is deployed.
Expand Down
10 changes: 4 additions & 6 deletions src/main/scala/org/fenixedu/sdk/services/BaseService.scala
@@ -1,7 +1,6 @@
package org.fenixedu.sdk.services

import cats.effect.Sync
import cats.syntax.flatMap._
import cats.effect.Concurrent
import cats.syntax.functor._
import fs2.{Chunk, Stream}
import io.circe.{Decoder, HCursor}
Expand All @@ -11,15 +10,15 @@ import org.http4s.client.Client
import org.http4s.client.dsl.Http4sClientDsl
import org.http4s.{Query, Request, Uri}

abstract class BaseService[F[_], T: Decoder](baseUri: Uri, val name: String)(implicit client: Client[F], F: Sync[F]) {
abstract class BaseService[F[_], T: Decoder](baseUri: Uri, val name: String)(implicit client: Client[F], F: Concurrent[F]) {
protected val dsl = new Http4sClientDsl[F] {}
import dsl._

val pluralName = s"${name}s"
val uri: Uri = baseUri / pluralName

/** Takes a request and unwraps its return value. */
protected def unwrap(request: F[Request[F]]): F[T] = client.expect[Map[String, T]](request).map(_.apply(name))
protected def unwrap(request: Request[F]): F[T] = client.expect[Map[String, T]](request).map(_.apply(name))
/** Puts a value inside a wrapper. */
protected def wrap(value: T): Map[String, T] = Map(name -> value)

Expand All @@ -32,8 +31,7 @@ abstract class BaseService[F[_], T: Decoder](baseUri: Uri, val name: String)(imp
Stream.unfoldChunkEval[F, Option[Uri], R](Some(uri)) {
case Some(uri) =>
for {
request <- GET(uri.copy(query = uri.query ++ query.pairs))
(next, entries) <- client.expect[(Option[Uri], List[R])](request)
(next, entries) <- client.expect[(Option[Uri], List[R])](GET(uri.copy(query = uri.query ++ query.pairs)))
} yield Some((Chunk.iterable(entries), next))
case None => F.pure(None)
}
Expand Down
4 changes: 2 additions & 2 deletions src/main/scala/org/fenixedu/sdk/services/Course.scala
@@ -1,11 +1,11 @@
package org.fenixedu.sdk.services

import cats.effect.Sync
import cats.effect.Concurrent
import org.fenixedu.sdk.models.{AttendingStudents, Evaluation, Group, Schedule, Course => CouseModel}
import org.http4s.Uri
import org.http4s.client.Client

final class Course[F[_]: Sync](val id: String, baseUri: Uri)(implicit client: Client[F]) {
final class Course[F[_]: Concurrent](val id: String, baseUri: Uri)(implicit client: Client[F]) {
val uri: Uri = baseUri / "courses" / id

/** A course is a concrete unit of teaching that typically lasts one academic term.
Expand Down
49 changes: 44 additions & 5 deletions src/main/scala/org/fenixedu/sdk/services/Degrees.scala
@@ -1,19 +1,58 @@
package org.fenixedu.sdk.services

import cats.effect.Sync
import cats.effect.Concurrent
import cats.implicits._
import org.fenixedu.sdk.models.{CourseRef, Degree}
import org.http4s.Uri
import org.http4s.client.Client

final class Degrees[F[_]: Sync](baseUri: Uri)(implicit client: Client[F]) {
final class Degrees[F[_]: Concurrent](baseUri: Uri)(implicit client: Client[F]) {
val uri: Uri = baseUri / "degrees"

/** @return the information for all degrees. If no academicTerm is defined it returns the degree information for the currentAcademicTerm. */
def list(academicTerm: Option[String] = None): F[List[Degree]] = client.expect(uri.+??("academicTerm", academicTerm))
def list(academicTerm: Option[String] = None): F[List[Degree]] =
client.expect(uri.withOptionQueryParam("academicTerm", academicTerm))

/** @return the information for the `id` degree. If no academicTerm is defined it returns the degree information for the currentAcademicTerm. */
def get(id: String, academicTerm: Option[String] = None): F[Degree] = client.expect((uri / id).+??("academicTerm", academicTerm))
def get(id: String, academicTerm: Option[String] = None): F[Degree] =
client.expect((uri / id).withOptionQueryParam("academicTerm", academicTerm))

/** @return the information for the degree with `acronym` on the specified `academicTerm`.
* If no academicTerm is defined it returns the degree information for the currentAcademicTerm. */
def getByAcronym(acronym: String, academicTerm: Option[String] = None): F[Option[Degree]] = {
// The API is not very expressive, so we need to do it using list.
list(academicTerm).map(_.find(_.acronym == acronym))
}
/** @return the information for the degree with `acronym` on the specified `academicTerm`, assuming the Degree exists.
* If no academicTerm is defined it returns the degree information for the currentAcademicTerm. */
def applyByAcronym(acronym: String, academicTerm: Option[String] = None): F[Degree] =
getByAcronym(acronym, academicTerm).flatMap {
case Some(degree) => Concurrent[F].pure(degree)
case None => Concurrent[F].raiseError(new NoSuchElementException(s"""Could not find Degree with acronym "$acronym"."""))
}


/** @return the information for a degree’s courses. If no academicTerm is defined it returns the degree information for the currentAcademicTerm. */
def courses(id: String, academicTerm: Option[String] = None): F[List[CourseRef]] = client.expect((uri / id / "courses").+??("academicTerm", academicTerm))
def courses(id: String, academicTerm: Option[String] = None): F[List[CourseRef]] =
client.expect((uri / id / "courses").withOptionQueryParam("academicTerm", academicTerm))

/** @return the information for the degree with `acronym` on the specified `academicTerm`.
* If no academicTerm is defined it returns the degree information for the currentAcademicTerm. */
def getCoursesByAcronym(acronym: String, academicTerm: Option[String] = None): F[Option[List[CourseRef]]] =
for {
courseOpt <- getByAcronym(acronym, academicTerm)
courses <- courseOpt match {
case Some(degree) => courses(degree.id, academicTerm).map(Option.apply)
case None => Concurrent[F].pure(Option.empty)
}
} yield courses

/** @return the information for the degree with `acronym` on the specified `academicTerm`, assuming the Degree exists.
* If no academicTerm is defined it returns the degree information for the currentAcademicTerm. */
def applyCoursesByAcronym(acronym: String, academicTerm: Option[String] = None): F[List[CourseRef]] =
getCoursesByAcronym(acronym, academicTerm).flatMap {
case Some(degree) => Concurrent[F].pure(degree)
case None => Concurrent[F].raiseError(new NoSuchElementException(s"""Could not find Degree with acronym "$acronym"."""))
}

}
8 changes: 4 additions & 4 deletions src/main/scala/org/fenixedu/sdk/services/Spaces.scala
Expand Up @@ -2,15 +2,15 @@ package org.fenixedu.sdk.services

import java.time.LocalDate
import java.time.format.DateTimeFormatter
import cats.effect.Sync
import cats.effect.Concurrent
import fs2.Stream
import org.fenixedu.sdk.models.{Space, SpaceRef}
import org.http4s.Method.GET
import org.http4s.Uri
import org.http4s.client.Client
import org.http4s.client.dsl.Http4sClientDsl

final class Spaces[F[_]: Sync](baseUri: Uri)(implicit client: Client[F]) {
final class Spaces[F[_]: Concurrent](baseUri: Uri)(implicit client: Client[F]) {
val uri: Uri = baseUri / "spaces"

protected val dsl = new Http4sClientDsl[F] {}
Expand All @@ -22,10 +22,10 @@ final class Spaces[F[_]: Sync](baseUri: Uri)(implicit client: Client[F]) {
/** @return information about the space for a given `id`. The `id` can be for a Campus, Building, Floor or Room. */
def get(id: String, day: Option[LocalDate] = None): F[Space] = {
val dateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd/MM/yyyy")
client.expect((uri / id).+??("day", day.map(d => dateTimeFormatter.format(d))))
client.expect((uri / id).withOptionQueryParam("day", day.map(d => dateTimeFormatter.format(d))))
}

/** @return the space’s blueprint in the required format.*/
def blueprint(id: String, format: Option[String] = Some("jpeg")): Stream[F, Byte] =
Stream.eval(GET((uri / id / "blueprint").+??("format", format))).flatMap(client.stream).flatMap(_.body)
client.stream(GET((uri / id / "blueprint").withOptionQueryParam("format", format))).flatMap(_.body)
}
6 changes: 3 additions & 3 deletions src/main/scala/org/fenixedu/sdk/services/package.scala
@@ -1,16 +1,16 @@
package org.fenixedu.sdk

import cats.Applicative
import cats.effect.Sync
import cats.effect.Concurrent
import io.circe.{Decoder, Encoder, Printer}
import org.http4s.{EntityDecoder, EntityEncoder, circe}

package object services {
val jsonPrinter: Printer = Printer.noSpaces.copy(dropNullValues = true)
implicit def jsonEncoder[F[_]: Applicative, A: Encoder]: EntityEncoder[F, A] = circe.jsonEncoderWithPrinterOf[F, A](jsonPrinter)
implicit def jsonDecoder[F[_]: Sync, A: Decoder]: EntityDecoder[F, A] = circe.accumulatingJsonOf[F, A]
implicit def jsonDecoder[F[_]: Concurrent, A: Decoder]: EntityDecoder[F, A] = circe.accumulatingJsonOf[F, A]

// Without this decoding to Unit wont work. This makes the EntityDecoder[F, Unit] defined in EntityDecoder companion object
// have a higher priority than the jsonDecoder defined above. https://github.com/http4s/http4s/issues/2806
implicit def void[F[_]: Sync]: EntityDecoder[F, Unit] = EntityDecoder.void
implicit def void[F[_]: Concurrent]: EntityDecoder[F, Unit] = EntityDecoder.void
}
3 changes: 2 additions & 1 deletion src/test/scala/org/fenixedu/sdk/CoursesSpec.scala
@@ -1,5 +1,6 @@
package org.fenixedu.sdk

import cats.effect.unsafe.implicits.global // I'm sure there is a better way to do this. Please do not copy paste
import cats.instances.list._
import cats.syntax.parallel._
import org.http4s.Status.InternalServerError
Expand All @@ -26,7 +27,7 @@ class CoursesSpec extends Utils {
}
s"get the course schedule ${courseRef.acronym} (${courseRef.id})" in {
courseService.schedule.value(_.lessonPeriods.length should be >= 0).recover {
case UnexpectedStatus(_: InternalServerError.type) =>
case UnexpectedStatus(_: InternalServerError.type, _, _) =>
// There is an error on the server, since we cannot easily exclude the Courses with the error we simply ignore it
assert(true)
}
Expand Down
2 changes: 1 addition & 1 deletion src/test/scala/org/fenixedu/sdk/DegreeSpec.scala
Expand Up @@ -6,7 +6,7 @@ import cats.syntax.traverse._

class DegreeSpec extends Utils {
"Degrees service" should {
for (year <- 2000 to Year.now.getValue) {
for (year <- 2000 until Year.now.getValue) {
val academicTerm = s"$year/${year + 1}"
s"list degrees of academic term $academicTerm" in {
client.degrees.list(academicTerm = Some(academicTerm)).map(_.length should be > 1)
Expand Down
13 changes: 4 additions & 9 deletions src/test/scala/org/fenixedu/sdk/Utils.scala
@@ -1,9 +1,9 @@
package org.fenixedu.sdk

import scala.concurrent.ExecutionContext.Implicits.global
import cats.effect.unsafe.implicits.global // I'm sure there is a better way to do this. Please do not copy paste
import scala.concurrent.duration.DurationInt
import scala.concurrent.{ExecutionContext, Future}
import cats.effect.{ContextShift, IO, Timer}
import scala.concurrent.Future
import cats.effect.IO
import cats.instances.list._
import cats.syntax.traverse._
import org.http4s.Uri
Expand All @@ -23,12 +23,7 @@ trait LowPriority {
abstract class Utils extends AsyncWordSpec with Matchers with BeforeAndAfterAll with LowPriority {
val logger: Logger = getLogger

implicit override def executionContext = ExecutionContext.global

implicit val timer: Timer[IO] = IO.timer(executionContext)
implicit val cs: ContextShift[IO] = IO.contextShift(executionContext)

val (_httpClient, finalizer) = BlazeClientBuilder[IO](global)
val (_httpClient, finalizer) = BlazeClientBuilder[IO](global.compute)
.withResponseHeaderTimeout(20.seconds)
.withCheckEndpointAuthentication(false)
.resource.allocated.unsafeRunSync()
Expand Down
2 changes: 1 addition & 1 deletion version.sbt
@@ -1 +1 @@
version := "0.1.0"
version := "0.2.0-SNAPSHOT"

0 comments on commit e67f8d2

Please sign in to comment.