alejandrohdezma/tapir-anyof
Tapir extension to simplify creating error outputs for sealed traits
Tapir extension to simplify creating error outputs for sealed traits
Extension library for Tapir that allows creating an EndpointOutput for a certain error type with multiple errors per status-code.
endpoint.get
.in("v1" / "users" / path[String]("id"))
.out(stringBody)
.errorOut(anyOf[UserNotFound, WrongPassword, WrongUser])See generated OpenApi
paths:
/v1/users/{id}:
get:
operationId: getV1UsersId
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
content:
text/plain:
schema:
type: string
'403':
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/WrongPassword'
- $ref: '#/components/schemas/WrongUser'
discriminator:
propertyName: error
mapping:
wrong-password: '#/components/schemas/WrongPassword'
wrong-user: '#/components/schemas/WrongUser'
'404':
content:
application/json:
schema:
$ref: '#/components/schemas/UserNotFound'
components:
schemas:
UserNotFound:
required:
- name
- error
type: object
properties:
name:
type: string
error:
type: string
enum:
- user-not-found
WrongPassword:
required:
- id
- error
type: object
properties:
id:
type: string
error:
type: string
enum:
- wrong-password
WrongUser:
required:
- id
- error
type: object
properties:
id:
type: string
error:
type: string
enum:
- wrong-userInstallation
Add the following line to your build.sbt file:
libraryDependencies += "com.alejandrohdezma" %% "tapir-anyof" % "2.0.0")Usage
In order to use this library you need to follow these simple steps:
Create your error AST
First create your error as you would normally do using Tapir:
import io.circe.generic.extras.Configuration
import io.circe.generic.extras.ConfiguredJsonCodec
@ConfiguredJsonCodec sealed trait MyError
case class UserNotFound(name: String) extends MyError
case class WrongUser(id: String) extends MyError
case class WrongPassword(id: String) extends MyError
object MyError {
implicit val config: Configuration =
Configuration.default.withDiscriminator("error")
}In this case we are using circe's Json, but you could use any available output type.
Provide Schema instances
Then you need to ensure that every error type has a Schema instance and that it is annotated with @code indicating the status code that should be used when that error is returned:
import com.alejandrohdezma.tapir._
import sttp.tapir.generic.auto._
import sttp.tapir.Schema
import io.circe.generic.extras.Configuration
import io.circe.generic.extras.ConfiguredJsonCodec
import sttp.model.StatusCode._
@ConfiguredJsonCodec sealed trait MyError
@code(NotFound) final case class UserNotFound(name: String) extends MyError
@code(Forbidden) final case class WrongUser(id: String) extends MyError
@code(Forbidden) final case class WrongPassword(id: String) extends MyError
object MyError {
implicit val config: Configuration =
Configuration.default.withDiscriminator("error")
implicit lazy val MyErrorSchema: Schema[MyError] = Schema.derived[MyError]
}Add discriminator information to Schema
Populate the sealed trait schema with the discriminator information by calling addDiscriminator indicating the discriminator name. This will involve two things: first, adding the actual discriminator value to the SCoproduct itself and second, adding the discriminator as a field to every subtype of the coproduct.
import com.alejandrohdezma.tapir._
import sttp.tapir.generic.auto._
import sttp.tapir.Schema
import io.circe.generic.extras.Configuration
import io.circe.generic.extras.ConfiguredJsonCodec
import sttp.model.StatusCode._
@ConfiguredJsonCodec sealed trait MyError
@code(NotFound) final case class UserNotFound(name: String) extends MyError
@code(Forbidden) final case class WrongUser(id: String) extends MyError
@code(Forbidden) final case class WrongPassword(id: String) extends MyError
object MyError {
implicit val config: Configuration =
Configuration.default.withDiscriminator("error")
implicit lazy val MyErrorSchema: Schema[MyError] = Schema.derived[MyError].addDiscriminator("error")
}Note: if using a different discriminator than the class simple-name (for example, kebab-case) you can add a second parameter to
addDiscriminatorthat lets you modify the class simple-name:implicit val config: Configuration = Configuration.default.withDiscriminator("error").withKebabCaseConstructorNames implicit lazy val MyErrorSchema: Schema[MyError] = Schema.derived[MyError].addDiscriminator("error", config.transformConstructorNames)
Important: this method will fail with a runtime exception if used on a schema whose inner type is not a SCoproduct:
import com.alejandrohdezma.tapir._
import sttp.tapir.Schema
final case class SimpleError(name: String)
implicit val SimpleErrorSchema: Schema[SimpleError] = Schema.derived[SimpleError].addDiscriminator("error")
// java.lang.RuntimeException: Schema must be of type SCoproduct but schema is SProduct(List(SProductField(FieldName(name,name),Schema(SString(),None,false,None,None,None,None,false,false,All(List()),AttributeMap(Map())))))
// at scala.sys.package$.error(package.scala:27)
// at com.alejandrohdezma.tapir.package$SchemaDiscriminatorOps.addDiscriminatorAs(package.scala:93)
// at com.alejandrohdezma.tapir.package$SchemaDiscriminatorOps.addDiscriminator(package.scala:45)
// at repl.MdocSession$MdocApp3$$anonfun$31.apply$mcV$sp(README.md:139)
// at repl.MdocSession$MdocApp3$$anonfun$31.apply(README.md:135)
// at repl.MdocSession$MdocApp3$$anonfun$31.apply(README.md:135)Create your anyOf utility
Anywhere in your code create an anyOf utility using AnyOf:
import sttp.tapir.json.circe.jsonBody
object anyOf extends AnyOf[MyError](jsonBody)You don't necessarily have to use
jsonBody, you can use any of the available output types.
Use it when creating your endpoint ๐
import sttp.tapir._
val myEndpoint = endpoint.get
.in("v1" / "users" / path[String]("id"))
.out(stringBody)
.errorOut(anyOf[UserNotFound, WrongUser, WrongPassword])Important: remember that some of this utilities throw runtime exceptions, so you should ensure this won't fail on runtime by creating a simple test where you use the endpoint. If the
anyOfcall fails, the instantiation of the endpoint will fail.
See generated OpenApi
paths:
/v1/users/{id}:
get:
operationId: getV1UsersId
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
content:
text/plain:
schema:
type: string
'403':
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/WrongPassword'
- $ref: '#/components/schemas/WrongUser'
discriminator:
propertyName: error
mapping:
wrong-password: '#/components/schemas/WrongPassword'
wrong-user: '#/components/schemas/WrongUser'
'404':
content:
application/json:
schema:
$ref: '#/components/schemas/UserNotFound'
components:
schemas:
UserNotFound:
required:
- name
- error
type: object
properties:
name:
type: string
error:
type: string
enum:
- user-not-found
WrongPassword:
required:
- id
- error
type: object
properties:
id:
type: string
error:
type: string
enum:
- wrong-password
WrongUser:
required:
- id
- error
type: object
properties:
id:
type: string
error:
type: string
enum:
- wrong-user