Effective API design with Scala types

Adam Fisher

@adamnfish

me

Today's talk

  • consider a simple API
  • look at some of the problems with it
  • work towards an alternative implementation

The problems

Poorly-typed code


  def exampleFunction(test: String, callback: () => Boolean, message: String): Any = {
    if ("yes" == test) {
      if (callback()) message
    } else if ("no" == test) "no"
    else false
  }

  • Hard to reason about ← internal
  • Hard to use ← external

Not generally a problem in Scala?

APIs

Multiple distributed services

Connected using APIs

On APIs:

APIs are the new classes

Example API

User authentication

Typical endpoints

  • authenticate (log in) a user
  • get user's profile information
  • update user's information

Update user information

  • Verifies authentication
  • Validates submission
  • Performs update
  • Returns updated user

Some example code


  def updateUser(userId: String) = Action { request =>
    val access: Access = Authentication.getAccess(request, userId)
    if (access.isOk) {
      val submission: User = UserData.fromSubmission(request)
      val updatedUser: User = UserRepository.update(userId, submission)
      val userResponse = UserResponse(updatedUser)
      Ok(Json.toJson(userResponse))
    } else {
      Forbidden("Access denied")
    }
  }


  def updateUser(userId: String) = Action { request =>
    val access: Access = Authentication.getAccess(request, userId)
    if (access.isOk) {
      try {
        val submission: User = UserData.fromSubmission(request)
        val updatedUser: User = UserRepository.update(userId, submission)
        val userResponse = UserResponse(updatedUser)
        Ok(Json.toJson(userResponse))
      } catch {
        case e: UserData.Invalid => BadRequest("Invalid user data")
        case e: UserData.InvalidEmailAddress => BadRequest("Invalid email address")
        case e: DatabaseError => InternalServerError("Could not connect to database")
      }
    } else {
      Forbidden("Access denied")
    }
  }


  def updateUser(userId: String) = Action { request =>
    val access = Authentication.getAccess(request, userId)
    if (access.isOk) {
      try {
        val (errors, submissionOption) = UserData.attemptFromSubmission(request)
        if (submissionOption.isDefined) {
          val updatedUser = UserRepository.update(userId, submissionOption.get)
          val userResponse = UserResponse(updatedUser)
          Ok(Json.toJson(userResponse))
        } else {
          val submissionErrors = ValidationErrorsResponse(errors)
          BadRequest(Json.toJson(submissionErrors))
        }
      } catch {
        case e: UserData.Invalid => BadRequest("Invalid user data")
        case e: DatabaseError => InternalServerError("Could not connect to database")
      }
    } else {
      Forbidden("Access denied")
    }
  }

Some problems

This is familiar!

Error handling boiler-plate


  def updateUser(userId: String) = Action { request =>
    val access = Authentication.getAccess(request, userId)
    if (access.isOk) {
      try {
        val (errors, submissionOption) = UserData.attemptFromSubmission(request)
        if (submissionOption.isDefined) {
          val updatedUser = UserRepository.update(userId, submissionOption.get)
          val userResponse = UserResponse(updatedUser)
          Ok(Json.toJson(userResponse))
        } else {
          val submissionErrors = ValidationErrorsResponse(errors)
          BadRequest(Json.toJson(submissionErrors))
        }
      } catch {
        case e: UserData.Invalid => BadRequest("Invalid user data")
        case e: DatabaseError => InternalServerError("Could not connect to database")
      }
    } else {
      Forbidden("Access denied")
    }
  }

Controller has to know a lot about the chunks of code it calls


  def updateUser(userId: String) = Action { request =>
    val access = Authentication.getAccess(request, userId)
    if (access.isOk) {
      try {
        val (errors, submissionOption) = UserData.attemptFromSubmission(request)
        if (submissionOption.isDefined) {
          val updatedUser = UserRepository.update(userId, submissionOption.get)
          val userResponse = UserResponse(updatedUser)
          Ok(Json.toJson(userResponse))
        } else {
          val submissionErrors = ValidationErrorsResponse(errors)
          BadRequest(Json.toJson(submissionErrors))
        }
      } catch {
        case e: UserData.Invalid => BadRequest("Invalid user data")
        case e: DatabaseError => InternalServerError("Could not connect to database")
      }
    } else {
      Forbidden("Access denied")
    }
  }

No type safety


  def updateUser(userId: String) = Action { request =>
    val access = Authentication.getAccess(request, userId)
    if (access.isOk) {
      try {
        val (errors, submissionOption) = UserData.attemptFromSubmission(request)
        if (submissionOption.isDefined) {
          val updatedUser = UserRepository.update(userId, submissionOption.get)
          val userResponse = UserResponse(updatedUser)
          Ok(Json.toJson(userResponse))
        } else {
          val submissionErrors = ValidationErrorsResponse(errors)
          BadRequest(Json.toJson(submissionErrors))
        }
      } catch {
        case e: UserData.Invalid => BadRequest("Invalid user data")
        case e: DatabaseError => InternalServerError("Could not connect to database")
      }
    } else {
      Forbidden("Access denied")
    }
  }

No consistent output representation


  def updateUser(userId: String) = Action { request =>
    val access = Authentication.getAccess(request, userId)
    if (access.isOk) {
      try {
        val (errors, submissionOption) = UserData.attemptFromSubmission(request)
        if (submissionOption.isDefined) {
          val updatedUser = UserRepository.update(userId, submissionOption.get)
          val userResponse = UserResponse(updatedUser)
          Ok(Json.toJson(userResponse))
        } else {
          val submissionErrors = ValidationErrorsResponse(errors)
          BadRequest(Json.toJson(submissionErrors))
        }
      } catch {
        case e: UserData.Invalid => BadRequest("Invalid user data")
        case e: DatabaseError => InternalServerError("Could not connect to database")
      }
    } else {
      Forbidden("Access denied")
    }
  }

How can we improve this?

Can we think about the type?

Return types

  • database error
  • network error
  • lookup failure
  • invalid submission
  • validation errors response
  • bad authentication
  • ...
  • successful update :-)

Two cases

Failure

  • database error
  • network error
  • lookup failure
  • invalid submission
  • email address doesn't match required format
  • bad authentication
  • ...
  • successful update

Success

  • database error
  • network error
  • lookup failure
  • invalid submission
  • email address doesn't match required format
  • bad authentication
  • ...
  • successful update

Either[Left, Right]

Either a Left type L or a Right type R

Option, with failure data

Either[L, R]

Option[R]

Creating instances


  Left(failureValue)


  Right(happyValue)

Can Either help us?

Our API

Either an error or a successful response.

The Left type - ApiErrors


            case class ApiErrors(errors: List[ApiError]) {
              def statusCode = errors.max(_.statusCode)
            }
        

            case class ApiError(message: String, friendlyMessage: String,
                                statusCode: Int, context: Option[String] = None)
        

ApiResponse

ApiResponse


            type ApiResponse[T] = Either[ApiErrors, T]
        

OK response JSON


  {
    "status": "ok",
    "response": {
      "id": "100001",
      "username": "user",
      "email": "user@example.com",
      "created": "2011-11-16T15:01:30Z",
      ...
    }
  }

Error response JSON


  {
    "status": "error",
    "statusCode": 500,
    "errors": [
      {
        "message": "Failed to connect to database",
        "friendlyMessage": "An error occurred saving your data, please try again shortly"
      }
    ]
  }

Creating HTTP response


  object ApiResponse extends Results {
    def apply[T](action: => ApiResponse[T])(implicit tjs: Writes[T]): Result = {
      action.fold(
        apiErrors =>
          Status(apiErrors.statusCode) {
            JsObject(Seq(
              "status" -> JsString("error"),
              "statusCode" -> JsNumber(apiErrors.statusCode),
              "errors" -> Json.toJson(apiErrors.errors)
            ))
          },
        t =>
          Ok {
            JsObject(Seq(
              "status" -> JsString("ok"),
              "response" -> Json.toJson(t)
            ))
          }
      )
    }
  }

Updating our application

Before


  object Authentication {
    def getAccess(request: RequestHeader, userId: String): Access = {
      // check for auth cookie
      // check for access token
      // check credentials in database
      throw new DatabaseError()
      // check credentials match userId
      Access.OK
    }
  }

After


  object Authentication {
    def getAccess(request: RequestHeader, userId: String): ApiResponse[Access] = {
      // check for auth cookie
      // check for access token
      // ensure valid credentials
      Left(ApiErrors(List(ApiError("Database connection failure", "We were unable to check your credentials, please try again shortly", 500))))
      // check credentials match userId
      Right(Access.OK)
    }
  }

Old controller


  def updateUser(userId: String) = Action { request =>
    val access = Authentication.getAccess(request, userId)
    if (access.isOk) {
      try {
        val (errors, submissionOption) = UserData.attemptFromSubmission(request)
        if (submissionOption.isDefined) {
          val updatedUser = UserRepository.update(userId, submissionOption.get)
          val userResponse = UserResponse(updatedUser)
          Ok(Json.toJson(userResponse))
        } else {
          val submissionErrors = ValidationErrorsResponse(errors)
          BadRequest(Json.toJson(submissionErrors))
        }
      } catch {
        case e: UserData.Invalid => BadRequest("Invalid user data")
        case e: DatabaseError => InternalServerError("Could not connect to database")
      }
    } else {
      Forbidden("Access denied")
    }
  }

Using ApiResponse


  def updateUser(userId: String) = Action { request =>
    ApiResponse {
      for {
        access <- Authentication.getAccess(request, userId).right
        submission <- UserData.fromSubmission(request).right
        updatedUser <- UserRepository.update(userId, submission).right
      } yield updatedUser
    }
  }

Wow!

But

Futures

Our API

At some point in the Future,
Either an error or a successful response.

ApiResponse


            type ApiResponse[T] = Future[Either[ApiErrors, T]]
        

Create our own ApiResponse type


  case class ApiResponse[A] private (underlying: Future[Either[ApiErrors, A]]) {
    def map[B](f: A => B)(implicit ec: ExecutionContext): ApiResponse[B] =
      ???

    def flatMap[B](f: A => ApiResponse[B])(implicit ec: ExecutionContext): ApiResponse[B] =
      ???

    def fold[B](failure: ApiErrors => B, success: A => B)(implicit ec: ...): Future[B] =
      ???

    ...
  }


  case class ApiResponse[A] private (underlying: Future[Either[ApiErrors, A]]) {
    def map[B](f: A => B)(implicit ec: ExecutionContext): ApiResponse[B] =
      flatMap(a => ApiResponse.ApiRight(f(a)))

    def flatMap[B](f: A => ApiResponse[B])
                  (implicit ec: ExecutionContext): ApiResponse[B] = ApiResponse {
      asFuture.flatMap {
        case Right(a) => f(a).asFuture
        case Left(e) => Future.successful(Left(e))
      }
    }

    def fold[B](failure: ApiErrors => B, success: A => B)
               (implicit ec: ExecutionContext): Future[B] = {
      asFuture.map(_.fold(failure, success))
    }

    def asFuture(implicit ec: ExecutionContext): Future[Either[ApiErrors, A]] = {
      underlying recover { case err =>
        val apiErrors = ApiErrors(List(ApiError.unexpected(err.getMessage)))
        scala.Left(apiErrors)
      }
    }
  }


  object ApiResponse extends Results {
    def apply[T](action: => ApiResponse[T])
                (implicit tjs: Writes[T], ec: ExecutionContext): Future[Result] = {
      action.fold(
        err =>
          Status(err.statusCode) {
            JsObject(Seq(
              "status" -> JsString("error"),
              "statusCode" -> JsNumber(err.statusCode),
              "errors" -> Json.toJson(err.errors)
            ))
          },
        t =>
          Ok {
            JsObject(Seq(
              "status" -> JsString("ok"),
              "response" -> Json.toJson(t)
            ))
          }
      )
    }
  }


  object ApiResponse {
    def Right[A](a: A): ApiResponse[A] =
      ApiResponse(Future.successful(scala.Right(a)))

    def Left[A](err: ApiErrors): ApiResponse[A] =
      ApiResponse(Future.successful(scala.Left(err)))

    object Async {
      def Right[A](fa: Future[A])(implicit ec: ExecutionContext): ApiResponse[A] =
        ApiResponse(fa.map(scala.Right(_)))

      def Left[A](ferr: Future[ApiErrors])(implicit ec: ExecutionContext): ApiResponse[A] =
        ApiResponse(ferr.map(scala.Left(_)))
    }
  }


  object Authentication {
    def getAccess(request: RequestHeader, userId: String): ApiResponse[Access] = {
      // check for auth cookie
      // check for access token
      // ensure valid credentials
      ApiResponse.Left(ApiErrors(List(ApiError("Database connection failure", "We were unable to check your credentials, please try again shortly", 500))))
      // check credentials match userId
      ApiResponse.Right(Access.OK)
    }
  }


  def updateUser(userId: String) = Action.async { request =>
    ApiResponse {
      for {
        access <- Authentication.getAccess(request, userId)
        submission <- UserData.fromSubmission(request)
        updatedUser <- UserRepository.update(userId, submission)
      } yield updatedUser
    }
  }

Comparison


  def updateUser(userId: String) = Action { request =>
    val access = Authentication.getAccess(request, userId)
    if (access.isOk) {
      try {
        val (errors, submissionOption) = UserData.attemptFromSubmission(request)
        if (submissionOption.isDefined) {
          val updatedUser = UserRepository.update(userId, submissionOption.get)
          val userResponse = UserResponse(updatedUser)
          Ok(Json.toJson(userResponse))
        } else {
          val submissionErrors = ValidationErrorsResponse(errors)
          BadRequest(Json.toJson(submissionErrors))
        }
      } catch {
        case e: UserData.Invalid => BadRequest("Invalid user data")
        case e: DatabaseError => InternalServerError("Could not connect to database")
      }
    } else {
      Forbidden("Access denied")
    }
  }


  def updateUser(userId: String) = Action.async { request =>
    ApiResponse {
      for {
        access <- Authentication.getAccess(request, userId)
        submission <- UserData.fromSubmission(request)
        updatedUser <- UserRepository.update(userId, submission)
      } yield updatedUser
    }
  }

Improvements

Internal

  • Typesafe
  • Removed boiler-plate
  • Better separation of concerns
  • Non-blocking

External

  • Consistent representation to the outside world
  • Much better error messages

Wow!

Take-away

Think about the types of your applications

Thank you

Questions?