Implement contracts

This commit is contained in:
Paul-Henri Froidmont 2025-10-06 18:30:22 +02:00
parent 31014d1a0c
commit efdc50eb1d
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
33 changed files with 879 additions and 173 deletions

View file

@ -1,41 +1,45 @@
package lu.foyer
import lu.foyer.JsonApiResponse.Many
import lu.foyer.JsonApiResponse.One
import zio.*
import zio.schema.*
import zio.http.*
import zio.http.Header.Forwarded
import zio.http.codec.*
import zio.http.codec.PathCodec.path
import zio.http.endpoint.*
import lu.foyer.JsonApiResponse.Many
import lu.foyer.JsonApiResponse.One
import zio.schema.*
import zio.schema.annotation.discriminatorName
import zio.schema.annotation.fieldName
import java.util.UUID
import scala.annotation.targetName
import zio.schema.annotation.discriminatorName
object JsonApiResponse:
case class One[T](
data: Entity[T],
links: Links)
case class One[T](data: Entity[T]) derives Schema
case class Many[T](data: List[Entity[T]], links: Map[String, String], meta: Meta) derives Schema
case class Meta(totalRecords: Option[Long], totalPages: Option[Long], page: Page) derives Schema
case class Page(number: Int, size: Int) derives Schema
case class Entity[T](
`type`: String,
id: UUID,
attributes: T,
relationships: Option[Relationships] = None,
links: Map[String, String])
derives Schema
case class Many[T](
data: List[Entity[T]],
links: Links,
meta: Meta)
derives Schema
case class Links(
self: String,
first: Option[String] = None,
prev: Option[String] = None,
next: Option[String] = None,
last: Option[String] = None)
derives Schema
case class Meta(totalRecords: Option[Long], totalPages: Option[Long]) derives Schema
case class Entity[T](id: UUID, `type`: String, attributes: T) derives Schema
case class Relationships(_entity: RelationshipsEntity) derives Schema
object Relationships:
def apply(id: UUID, `type`: String, entityUrl: String): Relationships = Relationships(
RelationshipsEntity(RelationshipsData(id, `type`), RelationshipsLinks(entityUrl))
)
case class RelationshipsEntity(data: RelationshipsData, links: RelationshipsLinks) derives Schema
case class RelationshipsData(id: UUID, `type`: String) derives Schema
case class RelationshipsLinks(related: String) derives Schema
@discriminatorName("errorType")
enum Error(title: String) derives Schema:
@ -46,16 +50,47 @@ object JsonApiResponse:
given Schema[Error.InternalServerError] = DeriveSchema.gen
end JsonApiResponse
trait JsonApiController:
final case class ProxyHeaders(
protocol: Option[String],
host: Option[String],
prefix: Option[String])
derives Schema:
def rootUrl =
(for
proto <- protocol
host <- host
prefix <- prefix
yield s"$proto://$host$prefix")
.getOrElse("")
trait JsonApiController:
def onthology: String
def entityName: String
def allEntities: List[String]
val proxyHeadersAspect = HandlerAspect.interceptIncomingHandler(
Handler.fromFunction[Request](request =>
(
request,
ProxyHeaders(
protocol = request.headers.get("X-Forwarded-Proto"),
host = request.headers.get("X-Forwarded-Host"),
prefix = request.headers.get("X-Forwarded-Prefix")
)
)
)
)
extension [PathInput, Input, Auth <: AuthType](
endpoint: Endpoint[PathInput, Input, ZNothing, ZNothing, AuthType.None]
)
def jsonApiOne[Output: Schema] =
inline def jsonApiOne[Output: Schema]
: Endpoint[PathInput, Input, JsonApiResponse.Error, One[Output], AuthType.None.type] =
jsonApiOneWithStatus(Status.Ok)
def jsonApiOneWithStatus[Output: Schema](status: Status) =
endpoint
.out[JsonApiResponse.One[Output]]
.out[JsonApiResponse.One[Output]](status)
.outErrors[JsonApiResponse.Error](
HttpCodec.error[JsonApiResponse.Error.NotFound](Status.NotFound),
HttpCodec.error[JsonApiResponse.Error.InternalServerError](Status.InternalServerError)
@ -71,36 +106,70 @@ trait JsonApiController:
def implementJsonApiOne[Env, A](
f: Input => RIO[Env, Option[A]],
getId: A => UUID,
getEntity: A => Output
getEntity: A => Output,
onthology: String = this.onthology,
links: (A, ProxyHeaders) => Map[String, String] = (_: A, _: ProxyHeaders) => Map.empty,
relationships: (A, ProxyHeaders) => Option[JsonApiResponse.Relationships] = (
_: A,
_: ProxyHeaders
) => None
)(implicit trace: Trace
): Route[Env, Nothing] =
): Route[Env & ProxyHeaders, Nothing] =
endpoint.implement(input =>
f(input)
.mapError(e => JsonApiResponse.Error.InternalServerError(e.getMessage))
.someOrFail(JsonApiResponse.Error.NotFound(input.toString))
.map(item =>
JsonApiResponse.One(
JsonApiResponse.Entity(
id = getId(item),
`type` = onthology,
attributes = getEntity(item)
),
JsonApiResponse.Links("https://api.example.org") // TODO
)
for
item <- f(input)
.mapError(e => JsonApiResponse.Error.InternalServerError(e.getMessage))
.flatMap {
case Some(paged) => ZIO.succeed(paged)
case None => ZIO.fail(JsonApiResponse.Error.NotFound(input.toString))
}
headers <- ZIO.service[ProxyHeaders]
yield JsonApiResponse.One(
JsonApiResponse.Entity(
id = getId(item),
`type` = onthology,
attributes = getEntity(item),
relationships = relationships(item, headers),
links = links(item, headers)
)
)
)
def implementJsonApiOneEntity[Env](
f: Input => RIO[Env, Option[Entity[Output]]]
)(implicit trace: Trace
): Route[Env, Nothing] =
implementJsonApiOne(f, _.entityId, _.data)
): Route[Env & ProxyHeaders, Nothing] =
implementJsonApiOne(
f,
_.entityId,
_.data,
links = (e, headers) =>
Map(
"self" -> s"${headers.rootUrl}/$entityName/${e.entityId}",
"events" -> s"${headers.rootUrl}/$entityName/${e.entityId}/events"
)
)
def implementJsonApiOneEvent[Env](
f: Input => RIO[Env, Option[Event[Output]]]
)(implicit trace: Trace
): Route[Env, Nothing] =
implementJsonApiOne(f, _.eventId, _.data)
): Route[Env & ProxyHeaders, Nothing] =
implementJsonApiOne(
f,
_.eventId,
_.data,
onthology + ":event",
links = (e, headers) =>
Map("self" -> s"${headers.rootUrl}/$entityName/${e.entityId}/events/${e.eventId}"),
relationships = (e, headers) =>
Some(
JsonApiResponse.Relationships(
e.entityId,
onthology,
s"${headers.rootUrl}/$entityName/${e.entityId}"
)
)
)
end extension
extension [PathInput, Input, Output, Auth <: AuthType](
@ -109,33 +178,60 @@ trait JsonApiController:
def implementJsonApiMany[Env, A](
f: Input => RIO[Env, Paged[A]],
getId: A => UUID,
getEntity: A => Output
getEntity: A => Output,
links: (A, ProxyHeaders) => Map[String, String] = (_: A, _: ProxyHeaders) => Map.empty
)(implicit trace: Trace
): Route[Env, Nothing] =
endpoint.implement(
f(_)
.map(paged =>
JsonApiResponse.Many(
paged.items.map(item =>
JsonApiResponse.Entity(id = getId(item), `type` = onthology, getEntity(item))
),
JsonApiResponse.Links("https://api.example.org"),
meta = JsonApiResponse.Meta(paged.totals, None) // TODO
): Route[Env & ProxyHeaders, Nothing] =
endpoint.implement(input =>
(for
paged <- f(input)
headers <- ZIO.service[ProxyHeaders]
yield JsonApiResponse.Many(
paged.items.map(item =>
JsonApiResponse.Entity(
id = getId(item),
`type` = onthology,
attributes = getEntity(item),
links = links(item, headers)
)
),
links = Map( // TODO compute proper pagination
"self" -> s"${headers.rootUrl}/$entityName",
"prev" -> s"${headers.rootUrl}/$entityName?page[number]=0&page[size]=10",
"first" -> s"${headers.rootUrl}/$entityName?page[number]=0&page[size]=10",
"next" -> s"${headers.rootUrl}/$entityName?page[number]=0&page[size]=10",
"last" -> s"${headers.rootUrl}/$entityName?page[number]=0&page[size]=10"
)
++ allEntities.map(e => e -> s"${headers.rootUrl}/$e"),
meta = JsonApiResponse
.Meta(
totalRecords = paged.totals,
totalPages = Some(1),
page = JsonApiResponse.Page(number = 0, size = 10)
)
))
.mapError(e => JsonApiResponse.Error.InternalServerError(e.getMessage))
)
inline def implementJsonApiManyEntity[Env](
f: Input => RIO[Env, Paged[Entity[Output]]]
)(implicit trace: Trace
): Route[Env, Nothing] =
implementJsonApiMany(f, _.entityId, _.data)
): Route[Env & ProxyHeaders, Nothing] =
implementJsonApiMany(
f,
_.entityId,
_.data,
links = (e, headers) =>
Map(
"self" -> s"${headers.rootUrl}/$entityName/${e.entityId}",
"events" -> s"${headers.rootUrl}/$entityName/${e.entityId}/events"
)
)
inline def implementJsonApiManyEvent[Env](
f: Input => RIO[Env, Paged[Event[Output]]]
)(implicit trace: Trace
): Route[Env, Nothing] =
): Route[Env & ProxyHeaders, Nothing] =
implementJsonApiMany(f, _.eventId, _.data)
end extension