vital-software/scala-redox

View on GitHub
src/main/scala/com/github/vitalsoftware/scalaredox/client/RedoxClient.scala

Summary

Maintainability
A
2 hrs
Test Coverage
package com.github.vitalsoftware.scalaredox.client

import java.io.File

import akka.actor.ActorSystem
import akka.http.scaladsl.model._
import akka.stream.Materializer
import akka.stream.scaladsl.{ FileIO, Source }
import com.github.vitalsoftware.scalaredox._
import com.github.vitalsoftware.scalaredox.models.Upload
import com.github.vitalsoftware.util.JsonImplicits.JsValueExtensions
import com.github.vitalsoftware.util.RobustParsing
import com.typesafe.config.Config
import play.api.libs.json._
import play.api.libs.ws._
import play.api.libs.ws.ahc._
import play.api.mvc.MultipartFormData.FilePart

import scala.concurrent.ExecutionContext.Implicits._
import scala.concurrent.Future
import scala.language.implicitConversions

class RedoxClient(
  conf: ClientConfig,
  client: HttpClient,
  tokenManager: RedoxTokenManager,
  reducer: JsValue => JsValue = _.reduceNullSubtrees
)(
  implicit val system: ActorSystem,
  implicit val materializer: Materializer,
) extends RedoxClientComponents(client, conf.baseRestUri, reducer) {
  /**
   * Initialize and internal HttpClient and a token manager. Added for backward compatibility.
   * @deprecated prefer initializing the HttpClient and token manager outside RedoxClient. Do not create
   *             more than one RedoxClient with this constructor as it will initialize duplicate
   *             TokenManagers and http clients.
   */
  def this(
    conf: Config,
    client: HttpClient,
    reducer: JsValue => JsValue
  )(implicit system: ActorSystem, materializer: Materializer) = {
    this(conf, client, new RedoxTokenManager(client, ClientConfig(conf).baseRestUri), reducer)
  }

  /**
   * Use default reducer, with internally initialized http client and token manager.
   *
   * @deprecated prefer initializing the HttpClient and token manager outside RedoxClient. Do not create
   *             more than one RedoxClient with this constructor as it will initialize duplicate
   *             TokenManagers and http clients.
   */
  def this(
    conf: Config
  )(implicit system: ActorSystem, materializer: Materializer) {
    this(conf, StandaloneAhcWSClient(), _.reduceEmptySubtrees)
  }

  /** Send and receive an authorized request */
  private def sendReceive[T](request: StandaloneWSRequest)(implicit format: Reads[T]): Future[RedoxResponse[T]] =
    for {
      auth <- tokenManager.getAccessToken(conf.apiKey, conf.apiSecret)
      response <- execute[T](request.addHttpHeaders("Authorization" -> s"Bearer ${auth.accessToken}"))
    } yield response

  private def optionalQueryParam[T](
    el: Option[T],
    key: String,
    f: T => String = (o: T) => o.toString
  ): Map[String, String] =
    el.map(e => Map(key -> f(e))).getOrElse(Map.empty)

  /**
   * Send a query/read-request of type 'T' expecting a response of type 'U'
   * Ex. get[PatientQuery => case  ClinicalSummary](query)
   */
  def get[T, U](query: T)(implicit writes: Writes[T], reads: Reads[U]): Future[RedoxResponse[U]] =
    sendReceive[U](baseQuery.withBody(Json.toJson(query)))

  /**
   * Send a post/write-request of type 'T' expecting a response of type 'U'
   * Ex. post[ClinicalSummary => case  EmptyResponse](data)
   */
  def post[T, U](data: T)(implicit writes: Writes[T], reads: Reads[U]): Future[RedoxResponse[U]] =
    sendReceive[U](basePost.withBody(Json.toJson(data)))

  /**
   * Uploads a file to the Redox /uploads endpoint. Provides sensible defaults for
   * file content type and content length.
   *
   * @param file The file to upload to Redox.
   * @param fileContentType The content type of the file (used when converting the file to a source stream.
   *                        Defaults to 'text/plain'.
   * @param contentLength The length that will be used to set the 'content-length' on the request.
   *                      Provides a default value of of ~2MB (2,097,000 octets). This is needed as when we provide
   *                      a Source we have to manually set the length or the backend http client will chunk the
   *                      request. (src: play.api.libs.ws.ahc.StandaloneAhcWSRequest#buildRequest() line: 303).
   * @return A future containing the redox upload response.
   */
  def upload(
    file: File,
    fileContentType: String = "text/plain",
    contentLength: Long = 2097000L
  ): Future[RedoxResponse[Upload]] =
    sendReceive[Upload] {
      baseUpload
        .withBody(
          Source(
            FilePart(
              key = "file",
              filename = file.getName,
              contentType = Some(fileContentType),
              ref = FileIO.fromPath(file.toPath)
            ) :: List()
          )
        )
        .addHttpHeaders("Content-Length" -> contentLength.toString)
    }
}

object RedoxClient {
  /**
   * Receive a webhook message and turn it into a Scala class based on the message type Meta.DataModel
   * Ex. def webhook() = Action.async(parse.json) { implicit request => RedoxClient.webhook(request.body) }
   * Since this do robust parsing, The result may contain errors. Which means that with results, there may
   * or may not be errors.
   */
  def webhook(json: JsValue, reducer: JsValue => JsValue = identity): (Option[JsError], Option[AnyRef]) = {
    import com.github.vitalsoftware.scalaredox.models.DataModelTypes._
    import com.github.vitalsoftware.scalaredox.models.RedoxEventTypes._
    val reads = (__ \ "Meta").read[models.Meta]
    val unsupported = JsError("Not yet supported").errors
    val clinicalSummaryTypes = List(PatientQueryResponse, PatientPush)
    val visitTypes = List(VisitQueryResponse, VisitPush)

    json.validate[models.Meta](reads).asEither.flatMap { meta =>
      (meta.DataModel, meta.EventType) match {
        case (Order, GroupedOrders)                             => Right(implicitly[Reads[models.GroupedOrdersMessage]])
        case (Order, _)                                         => Right(implicitly[Reads[models.OrderMessage]])
        case (Claim, _)                                         => Left(unsupported)
        case (Device, _)                                        => Left(unsupported)
        case (Financial, _)                                     => Left(unsupported)
        case (Flowsheet, _)                                     => Right(implicitly[Reads[models.FlowSheetMessage]])
        case (Inventory, _)                                     => Left(unsupported)
        case (Media, _)                                         => Right(implicitly[Reads[models.MediaMessage]])
        case (Notes, _)                                         => Right(implicitly[Reads[models.NoteMessage]])
        case (PatientAdmin, _)                                  => Right(implicitly[Reads[models.PatientAdminMessage]])
        case (PatientSearch, _)                                 => Right(implicitly[Reads[models.PatientSearch]])
        case (Referral, _)                                      => Left(unsupported)
        case (Results, _)                                       => Right(implicitly[Reads[models.ResultsMessage]])
        case (Scheduling, _)                                    => Left(unsupported)
        case (SurgicalScheduling, _)                            => Left(unsupported)
        case (Vaccination, _)                                   => Left(unsupported)
        case (Medications, _)                                   => Right(implicitly[Reads[models.MedicationsMessage]])
        case _ if clinicalSummaryTypes.contains(meta.EventType) => Right(implicitly[Reads[models.ClinicalSummary]])
        case _ if visitTypes.contains(meta.EventType)           => Right(implicitly[Reads[models.Visit]])
        case _                                                  => Left(unsupported)
      }
    } match {
      case Left(error)  => (Some(JsError(error)), None)
      case Right(reads) => RobustParsing.robustParsing(reads, reducer(json))
    }
  }
}

case class ClientConfig(baseRestUri: Uri, apiKey: String, apiSecret: String)
object ClientConfig {
  implicit def apply(conf: Config): ClientConfig = {
    val apiKey = conf.getString("redox.apiKey")
    val apiSecret = conf.getString("redox.secret")
    val baseRestUri = Uri(conf.getString("redox.restApiBase"))

    ClientConfig(baseRestUri, apiKey, apiSecret)
  }
}