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-user
Add the following line to your build.sbt
file:
libraryDependencies += "com.alejandrohdezma" %% "tapir-anyof" % "1.0.0")
In order to use this library you need to follow these simple steps:
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.
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]
}
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
addDiscriminator
that 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:94)
// at com.alejandrohdezma.tapir.package$SchemaDiscriminatorOps.addDiscriminator(package.scala:46)
// 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)
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.
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
anyOf
call 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