Skip to content

Commit

Permalink
Merge branch 'master' into add-typo
Browse files Browse the repository at this point in the history
  • Loading branch information
KapStorm authored Sep 21, 2023
2 parents cee0079 + 91e9859 commit 7618417
Show file tree
Hide file tree
Showing 16 changed files with 60 additions and 239 deletions.
4 changes: 3 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ val enumeratum = "1.7.2"
val scalaJavaTime = "2.5.0"
val tapir = "1.5.0"
val typoVersion = "0.3.1"
val chimney = "0.8.0-RC1"

val consoleDisabledOptions = Seq("-Werror", "-Ywarn-unused", "-Ywarn-unused-import")

Expand Down Expand Up @@ -377,7 +378,8 @@ lazy val server = (project in file("server"))
"javax.el" % "javax.el-api" % "3.0.0",
"org.glassfish" % "javax.el" % "3.0.0",
"com.beachape" %% "enumeratum" % enumeratum,
"com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % tapir
"com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % tapir,
"io.scalaland" %% "chimney" % chimney
)
)

Expand Down
148 changes: 41 additions & 107 deletions docs/swagger-integration.md
Original file line number Diff line number Diff line change
@@ -1,134 +1,68 @@
[//]: # (TODO: update swagger integration docs)

# Swagger integration

We have a swagger integration so that users can explore the server API through swagger-ui.

**Disclaimer**: This integration can be annoying to work with, it uses Java annotations which end up with lots of repetitive code.

Still, if you decide that swagger is worth for your project, this document explains the known quirks.

Some highlights:

- We are using [sbt-swagger-play](https://github.com/dwickern/sbt-swagger-play) which integrates a [swagger-play](https://github.com/dwickern/swagger-play) fork, while these modules are out of date, they work for most common scenarios.
- [Swagger-Annotations 1.5.x](https://github.com/swagger-api/swagger-core/wiki/Annotations-1.5.X) are the supported annotations.
- This swagger version does not support cookie-based authentication, while this prevents you from specifying cookies at `SecurityDefinition`, you can still invoke the login endpoint from swagger-ui so that the cookie gets propagated by the browser.
- Swagger-ui is exposed locally at [localhost:9000/docs/index.html](http://localhost:9000/docs/index.html)
- Avoid adding quite a lot of swagger annotation changes at once, when there are problems with those, the error could not be obvious, you are encouraged to check swagger-ui after updating an API.
- Be sure to check existing [controllers](../server/src/main/scala/controllers/) to see real examples.
- Swagger belongs to the http layer, hence, swagger annotations must be only at the [controllers](../server/src/main/scala/controllers/) and [api-models](../lib/api/shared/src/main/scala/net/wiringbits/api/models/) packages.
- Returning arrays can be tricky, reflection notation is required.
- `Option[T]` values need explicit types so that swagger definition is accurate (the default inferred type is wrong).

**NOTE**: Swagger errors are not clear, check previous highlights, and, refresh swagger-ui frequently to make sure everything works the way you expect.

## Expose a controller
Controllers are not exposed by default, in order to do so, you need to annotate your controller with `@Api`, for example:

```scala
@Api("Auth")
class AuthController
```

In this case, `Auth` is the tag for the APIs exposed on `AuthController`, such tag is used to group the APIs on swagger-ui.
- We are using [tapir](https://tapir.softwaremill.com/) which integrates an [open-api](https://tapir.softwaremill.com/en/latest/docs/openapi.html) module for swagger.
- Swagger-ui is exposed locally at [http://localhost:9000/docs](http://localhost:9000/docs).
- Be sure to check existing [endpoints](../lib/api/shared/src/main/scala/net/wiringbits/api/endpoints) to see real examples.
- `Option[T]` values are supported, sending a json without the key and value will be interpreted as `None`, otherwise, `Some(value)` will be sent.

**NOTE**: swagger-play will match the controller methods to the endpoints defined at the [routes](../server/src/main/resources/routes) file.

## Controller authentication details
Any controller exposing APIs requiring user authentication must be annotated with `@SwaggerDefinition` to include the available security definitions, you will find yourself mostly writing this once and pasting it on all controllers, for example:
## Creating an endpoint definition
We have to define our endpoints at the [endpoints](../lib/api/shared/src/main/scala/net/wiringbits/api/endpoints) package, for example:

```scala
@SwaggerDefinition(
securityDefinition = new SecurityDefinition(
apiKeyAuthDefinitions = Array(
new ApiKeyAuthDefinition(
name = "Cookie",
key = "auth_cookie",
in = ApiKeyAuthDefinition.ApiKeyLocation.HEADER,
description =
"The user's session cookie retrieved when logging into the app, invoke the login API to get the cookie stored in the browser"
val basicPostEndpoint = endpoint
.post("basic") // points to POST http://localhost:9000/basic
.tag("Misc") // tags the endpoint as "Misc" on swagger-ui
.in(
jsonBody[Basic.Request].example( // expects a JSON body of type BasicGet.Request with example values
BasicGet.Request(
name = "Alexis",
email = "[email protected]"
)
)
)
.out(
jsonBody[Basic.Response].example( // returns a JSON body of type BasicGet.Response with example values
BasicGet.Response(
message = "Hello Alexis!"
)
)
)
)
@Api("Auth")
class AuthController
```

This means that any endpoint can define the auth-definition key to mark the endpoint as protected, in this case `auth_cookie`, for example:
Api models must have an `implicit Schema` defined, for example:

```scala
@ApiOperation(
value = "Logout from the app",
notes = "Clears the session cookie that's stored securely",
authorizations = Array(new Authorization(value = "auth_cookie"))
)
def logout = ???
Schema
.derived[Response]
.name(Schema.SName("BasicResponse"))
.description("Says hello to the user")
```

## Controller methods
A controller method would usually look like this (removing pieces when necessary):
And then integrate the endpoint to the [ApiRouter](../server/src/main/scala/controllers/ApiRouter.scala) file:

```scala
@ApiOperation(
value = "Logout from the app",
notes = "Clears the session cookie that's stored securely",
authorizations = Array(new Authorization(value = "auth_cookie"))
)
@ApiImplicitParams(
Array(
new ApiImplicitParam(
name = "body",
value = "JSON-encoded request",
required = true,
paramType = "body",
dataTypeClass = classOf[Logout.Request]
)
)
)
@ApiResponses(
Array(
new ApiResponse(code = 200, message = "Successful logout", response = classOf[Logout.Response]),
new ApiResponse(code = 400, message = "Invalid or missing arguments")
)
object ApiRouter {
private def routes(implicit ec: ExecutionContext): List[AnyEndpoint] = List(
basicPostEndpoint
)
}
```

- `ApiOperation` defines the summary for the API, including authentication details when necessary.
- `ApiImplicitParams` defines the request body type (such class needs its own swagger-annotations).
- `ApiResponses` defines the potential responses for this method.

## Endpoint user authentication details

## Annotations on models
We use Play Session cookie for user authentication, this is a cookie that's stored securely and is sent on every request, this cookie is used to identify the user and to check if the user is authenticated.

This is one of the most tricky side from this integration:

- We commonly use classes nested inside objects to define request/response models, we need to declare explicit swagger names for these models.
- Wrapper values get default weird values in swagger, for example, `Option[T]`, our typed models like `class Email private (val string: String) extends WrappedString`, hence, swagger needs an explicit type defined.
- Arrays get weird values too by default, reflection notation needs to be used for these.
- `ApiModel`/`ApiModelProperty` annotation parameters ordering matters! This is one of the more obscure details, if you get any weird error, check the annotation parameter ordering.
- Primitive values work fine.

Let's see an example:
Any endpoint that requieres user authentication must include our implicit [userAuth](../lib/api/shared/src/main/scala/net/wiringbits/api/endpoints/package.scala) handler and convert the endpoint `val` to `def` that receives an implicit handler `implicit
authHandler: ServerRequest => Future[UUID]`, for example:

[//]: # (TODO: change Future[UUID] to Future[UserId] after mergin typo)
```scala
object CreateUser {
@ApiModel(value = "CreateUserRequest", description = "Request for the create user API")
case class Request(
@ApiModelProperty(dataType = "string")
email: Email,
@ApiModelProperty(dataType = "[Ljava.lang.Long;")
longSeqOpt: Option[Seq[Long]],
@ApiModelProperty(dataType = "[Ljava.lang.String;")
stringSeq: Seq[String],
@ApiModelProperty(dataType = "integer")
intOpt: Option[Int],
@ApiModelProperty(dataType = "boolean")
booleanOpt: Option[Boolean],
int: Int,
boolean: Boolean,
long: Long,
string: String
)
}
```
def basicEndpoint(implicit authHandler: ServerRequest => Future[UUID]) = endpoint.get
.in(userAuth)
```

For more information about creating endpoints, please check the [tapir documentation](https://tapir.softwaremill.com/en/latest/).
18 changes: 0 additions & 18 deletions lib/api/js/src/main/scala/io/swagger/annotations/ApiModel.scala

This file was deleted.

This file was deleted.

17 changes: 0 additions & 17 deletions lib/api/js/src/main/scala/io/swagger/annotations/Extension.scala

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ object AdminEndpoints {
List(
AdminGetUserLogs.Response
.UserLog(
id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"),
userLogId = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"),
message = "Message",
createdAt = Instant.parse("2021-01-01T00:00:00Z")
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ object UsersEndpoints {
GetUserLogs.Response(
List(
GetUserLogs.Response.UserLog(
id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"),
userLogId = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"),
message = "Message",
createdAt = Instant.parse("2021-01-01T00:00:00Z")
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ object AdminGetUserLogs {
.description("Includes the logs for a single user")

object Response {
case class UserLog(id: UUID, message: String, createdAt: Instant)
case class UserLog(userLogId: UUID, message: String, createdAt: Instant)
implicit val adminGetUserLogsResponseUserLogFormat: Format[UserLog] = Json.format[UserLog]
implicit val adminGetUserLogsResponseUserLogSchema: Schema[UserLog] = Schema
.derived[UserLog]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ object GetUserLogs {
case class Response(data: List[Response.UserLog])

object Response {
case class UserLog(id: UUID, message: String, createdAt: Instant)
case class UserLog(userLogId: UUID, message: String, createdAt: Instant)
implicit val getUserLogsResponseFormat: Format[UserLog] = Json.format[UserLog]
implicit val getUserLogsResponseSchema: Schema[UserLog] =
Schema.derived[UserLog].name(Schema.SName("GetUserLogsResponseUserLog"))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package net.wiringbits.actions

import io.scalaland.chimney.dsl.transformInto
import net.wiringbits.api.models.CreateUser
import net.wiringbits.apis.ReCaptchaApi
import net.wiringbits.config.UserTokensConfig
Expand Down Expand Up @@ -55,7 +56,7 @@ class CreateUserAction @Inject() (
),
token
)
} yield CreateUser.Response(id = createUser.id, email = createUser.email, name = createUser.name)
} yield createUser.transformInto[CreateUser.Response]
}

private def validations(request: CreateUser.Request) = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package net.wiringbits.actions

import io.scalaland.chimney.dsl.transformInto
import net.wiringbits.api.models.GetCurrentUser
import net.wiringbits.repositories.UsersRepository
import net.wiringbits.repositories.models.User
Expand All @@ -15,12 +16,7 @@ class GetUserAction @Inject() (
def apply(userId: UUID): Future[GetCurrentUser.Response] = {
for {
user <- unsafeUser(userId)
} yield GetCurrentUser.Response(
id = user.id,
email = user.email,
name = user.name,
createdAt = user.createdAt
)
} yield user.transformInto[GetCurrentUser.Response]
}

private def unsafeUser(userId: UUID): Future[User] = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package net.wiringbits.actions

import io.scalaland.chimney.dsl.transformInto
import net.wiringbits.api.models.GetUserLogs
import net.wiringbits.repositories.UserLogsRepository

Expand All @@ -14,13 +15,7 @@ class GetUserLogsAction @Inject() (
def apply(userId: UUID): Future[GetUserLogs.Response] = {
for {
logs <- userLogsRepository.logs(userId)
items = logs.map { x =>
GetUserLogs.Response.UserLog(
id = x.userLogId,
message = x.message,
createdAt = x.createdAt
)
}
items = logs.map(_.transformInto[GetUserLogs.Response.UserLog])
} yield GetUserLogs.Response(items)
}
}
Loading

0 comments on commit 7618417

Please sign in to comment.