Skip to content

Commit

Permalink
Configurable EndpointAPI codecs (#2911)
Browse files Browse the repository at this point in the history
  • Loading branch information
987Nabil committed Aug 18, 2024
1 parent 89d67e1 commit c8af61d
Show file tree
Hide file tree
Showing 20 changed files with 295 additions and 191 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import zio.{Scope => _, _}
import zio.schema.{DeriveSchema, Schema}

import zio.http._
import zio.http.codec.HttpCodec
import zio.http.codec.{CodecConfig, HttpCodec}
import zio.http.endpoint.Endpoint

import cats.effect.unsafe.implicits.global
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ object OptionsGen {
def encodeOptions[A](name: String, codec: BinaryCodecWithSchema[A]): Options[String] =
HttpOptions
.optionsFromSchema(codec)(name)
.map(value => codec.codec.encode(value).asString)
.map(value => codec.codec(CodecConfig.defaultConfig).encode(value).asString)

lazy val anyBodyOption: Gen[Any, CliReprOf[Options[Retriever]]] =
Gen
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import zio.stream.ZStream
import zio.schema.{DeriveSchema, Schema}

import zio.http._
import zio.http.codec.HttpCodec
import zio.http.codec.{CodecConfig, HttpCodec}
import zio.http.endpoint._

object ServerSentEventAsJsonEndpoint extends ZIOAppDefault {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import zio._
import zio.stream.ZStream

import zio.http._
import zio.http.codec.HttpCodec
import zio.http.codec.{CodecConfig, HttpCodec}
import zio.http.endpoint._

object ServerSentEventEndpoint extends ZIOAppDefault {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import zio._
import zio.schema.{DeriveSchema, Schema}

import zio.http._
import zio.http.codec.{HeaderCodec, PathCodec}
import zio.http.codec.{CodecConfig, HeaderCodec, PathCodec}
import zio.http.endpoint.{AuthType, Endpoint}

object EndpointWithMultipleErrorsUsingEither extends ZIOAppDefault {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import zio._
import zio.schema.{DeriveSchema, Schema}

import zio.http._
import zio.http.codec.{HeaderCodec, HttpCodec, PathCodec}
import zio.http.codec.{CodecConfig, HeaderCodec, HttpCodec, PathCodec}
import zio.http.endpoint.{AuthType, Endpoint}

object EndpointWithMultipleUnifiedErrors extends ZIOAppDefault {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import zio.test._
import zio.{Scope, ZIO, durationInt}

import zio.http._
import zio.http.codec.HttpCodec
import zio.http.codec.{CodecConfig, HttpCodec}
import zio.http.internal.middlewares.AuthSpec.AuthContext

object AuthSpec extends ZIOSpecDefault {
Expand Down
14 changes: 14 additions & 0 deletions zio-http/jvm/src/test/scala/zio/http/endpoint/RoundtripSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ object RoundtripSpec extends ZIOHttpSpec {
implicit val schema: Schema[PostWithAge] = DeriveSchema.gen[PostWithAge]
}

case class Outs(ints: List[Int])

implicit val outsSchema: Schema[Outs] = DeriveSchema.gen[Outs]

def makeExecutor(client: Client, port: Int) = {
val locator = EndpointLocator.fromURL(
URL.decode(s"http://localhost:$port").toOption.get,
Expand Down Expand Up @@ -506,6 +510,16 @@ object RoundtripSpec extends ZIOHttpSpec {
assert(r.isFailure)(isTrue) // We expect it to fail but complete
}
},
test("Override default CodecConfig") {
val api = Endpoint(GET / "test").out[Outs]
testEndpointCustomRequestZIO(
api.implement(_ => ZIO.succeed(Outs(Nil))).toRoutes @@ CodecConfig.withConfig(
CodecConfig(ignoreEmptyCollections = false),
),
Request.get("/test"),
response => response.body.asString.map(s => assertTrue(s == """{"ints":[]}""")),
)
},
).provide(
Server.customized,
ZLayer.succeed(Server.Config.default.onAnyOpenPort.enableRequestStreaming),
Expand Down
3 changes: 3 additions & 0 deletions zio-http/shared/src/main/scala/zio/http/Routes.scala
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ final case class Routes[-Env, +Err](routes: Chunk[zio.http.Route[Env, Err]]) { s
def isDefinedAt(request: Request)(implicit ev: Err <:< Response): Boolean =
tree(Trace.empty, ev).get(request.method, request.path).nonEmpty

def provide[Env1 <: Env](env: Env1)(implicit tag: Tag[Env1]): Routes[Any, Err] =
provideEnvironment(ZEnvironment(env))

/**
* Provides the specified environment to the HTTP application, returning a new
* HTTP application that has no environmental requirements.
Expand Down
6 changes: 3 additions & 3 deletions zio-http/shared/src/main/scala/zio/http/ServerSentEvent.scala
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,10 @@ object ServerSentEvent {
}

implicit def contentCodec[T](implicit
tCodec: BinaryCodec[T],
schema: Schema[T],
codecT: BinaryCodec[T],
schemaT: Schema[T],
): HttpContentCodec[ServerSentEvent[T]] = HttpContentCodec.from(
MediaType.text.`event-stream` -> BinaryCodecWithSchema.fromBinaryCodec(binaryCodec),
MediaType.text.`event-stream` -> BinaryCodecWithSchema(binaryCodec(codecT), schema(schemaT)),
)

implicit def defaultContentCodec[T](implicit
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,56 @@
package zio.http.codec

import zio._

import zio.schema.Schema
import zio.schema.codec.BinaryCodec

final case class BinaryCodecWithSchema[A](codec: BinaryCodec[A], schema: Schema[A])
import zio.http.Middleware

final case class BinaryCodecWithSchema[A](codecFn: (Schema[A], CodecConfig) => BinaryCodec[A], schema: Schema[A]) {
private var cached = Map.empty[CodecConfig, BinaryCodec[A]]
def codec(config: CodecConfig): BinaryCodec[A] =
cached.getOrElse(
config, {
val codec = codecFn(schema, config)
cached += (config -> codec)
codec
},
)
}

object BinaryCodecWithSchema {
def fromBinaryCodec[A](codec: BinaryCodec[A])(implicit schema: Schema[A]): BinaryCodecWithSchema[A] =
def apply[A](codec: BinaryCodec[A], schema: Schema[A]): BinaryCodecWithSchema[A] =
BinaryCodecWithSchema((_, _) => codec, schema)

def fromBinaryCodec[A](codec: (Schema[A], CodecConfig) => BinaryCodec[A])(implicit
schema: Schema[A],
): BinaryCodecWithSchema[A] =
BinaryCodecWithSchema(codec, schema)

def fromBinaryCodec[A](codec: BinaryCodec[A])(implicit schema: Schema[A]): BinaryCodecWithSchema[A] =
BinaryCodecWithSchema((_, _) => codec, schema)
}

/**
* Configuration that is handed over when creating a binary codec
* @param ignoreEmptyCollections
* if true, empty collections will be ignored when encoding. This is currently
* only used for the JSON codec
*/

final case class CodecConfig(
ignoreEmptyCollections: Boolean = true,
)

object CodecConfig {
val defaultConfig: CodecConfig = CodecConfig()

def configLayer(config: CodecConfig): ULayer[Unit] =
ZLayer(codecRef.set(config))

def withConfig(config: CodecConfig): Middleware[Any] =
Middleware.runBefore(codecRef.set(config))

private[http] val codecRef: FiberRef[CodecConfig] = FiberRef.unsafe.make(defaultConfig)(Unsafe)
}
55 changes: 24 additions & 31 deletions zio-http/shared/src/main/scala/zio/http/codec/HttpCodec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -171,18 +171,23 @@ sealed trait HttpCodec[-AtomTypes, Value] {
* Uses this codec to decode the Scala value from a request.
*/
final def decodeRequest(request: Request)(implicit trace: Trace): Task[Value] =
decode(request.url, Status.Ok, request.method, request.headers, request.body)
CodecConfig.codecRef.getWith(
encoderDecoder.decode(_, request.url, Status.Ok, request.method, request.headers, request.body),
)

/**
* Uses this codec to decode the Scala value from a response.
* Uses this codec to decode the Scala value from a request.
*/
final def decodeResponse(response: Response)(implicit trace: Trace): Task[Value] =
decode(URL.empty, response.status, Method.GET, response.headers, response.body)
final def decodeRequest(request: Request, config: CodecConfig)(implicit trace: Trace): Task[Value] =
encoderDecoder.decode(config, request.url, Status.Ok, request.method, request.headers, request.body)

private final def decode(url: URL, status: Status, method: Method, headers: Headers, body: Body)(implicit
/**
* Uses this codec to decode the Scala value from a response.
*/
final def decodeResponse(response: Response, config: CodecConfig = CodecConfig.defaultConfig)(implicit
trace: Trace,
): Task[Value] =
encoderDecoder.decode(url, status, method, headers, body)
encoderDecoder.decode(config, URL.empty, response.status, Method.GET, response.headers, response.body)

def doc: Option[Doc] = {
@tailrec
Expand All @@ -197,10 +202,17 @@ sealed trait HttpCodec[-AtomTypes, Value] {
}

/**
* Uses this codec to encode the Scala value into a request.
* Uses this codec and [[CodecConfig.defaultConfig]] to encode the Scala value
* into a request.
*/
final def encodeRequest(value: Value): Request =
encodeWith(value, Chunk.empty)((url, _, method, headers, body) =>
encodeRequest(value, CodecConfig.defaultConfig)

/**
* Uses this codec to encode the Scala value into a request.
*/
final def encodeRequest(value: Value, config: CodecConfig): Request =
encodeWith(config, value, Chunk.empty)((url, _, method, headers, body) =>
Request(
url = url,
method = method.getOrElse(Method.GET),
Expand All @@ -211,37 +223,18 @@ sealed trait HttpCodec[-AtomTypes, Value] {
),
)

/**
* Uses this codec to encode the Scala value as a patch to a request.
*/
final def encodeRequestPatch(value: Value): Request.Patch =
encodeWith(value, Chunk.empty)((url, _, _, headers, _) =>
Request.Patch(
addQueryParams = url.queryParams,
addHeaders = headers,
),
)

/**
* Uses this codec to encode the Scala value as a response.
*/
final def encodeResponse[Z](value: Value, outputTypes: Chunk[MediaTypeWithQFactor]): Response =
encodeWith(value, outputTypes)((_, status, _, headers, body) =>
final def encodeResponse[Z](value: Value, outputTypes: Chunk[MediaTypeWithQFactor], config: CodecConfig): Response =
encodeWith(config, value, outputTypes)((_, status, _, headers, body) =>
Response(headers = headers, body = body, status = status.getOrElse(Status.Ok)),
)

/**
* Uses this codec to encode the Scala value as a response patch.
*/
final def encodeResponsePatch[Z](value: Value, outputTypes: Chunk[MediaTypeWithQFactor]): Response.Patch =
encodeWith(value, outputTypes)((_, status, _, headers, _) =>
Response.Patch.addHeaders(headers) ++ status.map(Response.Patch.status(_)).getOrElse(Response.Patch.empty),
)

private final def encodeWith[Z](value: Value, outputTypes: Chunk[MediaTypeWithQFactor])(
private final def encodeWith[Z](config: CodecConfig, value: Value, outputTypes: Chunk[MediaTypeWithQFactor])(
f: (URL, Option[Status], Option[Method], Headers, Body) => Z,
): Z =
encoderDecoder.encodeWith(value, outputTypes.sortBy(mt => -mt.qFactor.getOrElse(1.0)))(f)
encoderDecoder.encodeWith(config, value, outputTypes.sortBy(mt => -mt.qFactor.getOrElse(1.0)))(f)

def examples(examples: Iterable[(String, Value)]): HttpCodec[AtomTypes, Value] =
HttpCodec.Annotated(self, Metadata.Examples(Chunk.fromIterable(examples).toMap))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,30 +27,36 @@ final case class HttpContentCodec[A](
def ++(that: HttpContentCodec[A]): HttpContentCodec[A] =
HttpContentCodec(choices ++ that.choices)

def decodeRequest(request: Request): Task[A] = {
def decodeRequest(request: Request, config: CodecConfig): Task[A] = {
val contentType = mediaTypeFromContentTypeHeader(request)
lookup(contentType) match {
case Some(codec) =>
request.body.asChunk.flatMap { bytes =>
ZIO.fromEither(codec.codec.decode(bytes))
ZIO.fromEither(codec.codec(config).decode(bytes))
}
case None =>
ZIO.fail(throw new IllegalArgumentException(s"No codec found for content type $contentType"))
}
}

def decodeResponse(response: Response): Task[A] = {
def decodeRequest(request: Request): Task[A] =
CodecConfig.codecRef.getWith(decodeRequest(request, _))

def decodeResponse(response: Response, config: CodecConfig): Task[A] = {
val contentType = mediaTypeFromContentTypeHeader(response)
lookup(contentType) match {
case Some(codec) =>
response.body.asChunk.flatMap { bytes =>
ZIO.fromEither(codec.codec.decode(bytes))
ZIO.fromEither(codec.codec(config).decode(bytes))
}
case None =>
ZIO.fail(throw new IllegalArgumentException(s"No codec found for content type $contentType"))
}
}

def decodeResponse(response: Response): Task[A] =
CodecConfig.codecRef.getWith(decodeResponse(response, _))

private def mediaTypeFromContentTypeHeader(header: HeaderOps[_]) = {
if (header.headers.contains(Header.ContentType.name)) {
val contentType = header.headers.getUnsafe(Header.ContentType.name)
Expand All @@ -64,11 +70,11 @@ final case class HttpContentCodec[A](
}
}

def encode(value: A): Either[String, Body] = {
def encode(value: A, config: CodecConfig = CodecConfig.defaultConfig): Either[String, Body] = {
if (choices.isEmpty) {
Left("No codec defined")
} else {
Right(Body.fromChunk(choices.head._2.codec.encode(value), mediaType = choices.head._1))
Right(Body.fromChunk(choices.head._2.codec(config).encode(value), mediaType = choices.head._1))
}
}

Expand Down Expand Up @@ -153,15 +159,16 @@ final case class HttpContentCodec[A](
throw new IllegalArgumentException(s"No codec defined")
}

val defaultCodec: BinaryCodec[A] = choices.headOption.map(_._2.codec).getOrElse {
throw new IllegalArgumentException(s"No codec defined")
}
private[http] val defaultCodec: BinaryCodec[A] =
choices.headOption.map(_._2.codec(CodecConfig.defaultConfig)).getOrElse {
throw new IllegalArgumentException(s"No codec defined")
}

val defaultSchema: Schema[A] = choices.headOption.map(_._2.schema).getOrElse {
private[http] val defaultSchema: Schema[A] = choices.headOption.map(_._2.schema).getOrElse {
throw new IllegalArgumentException(s"No codec defined")
}

val defaultBinaryCodecWithSchema: BinaryCodecWithSchema[A] =
private[http] val defaultBinaryCodecWithSchema: BinaryCodecWithSchema[A] =
choices.headOption.map(_._2).getOrElse {
throw new IllegalArgumentException(s"No codec defined")
}
Expand Down Expand Up @@ -261,7 +268,10 @@ object HttpContentCodec {
ListMap(
MediaType.application.`json` ->
BinaryCodecWithSchema(
JsonCodec.schemaBasedBinaryCodec[A](JsonCodec.Config(ignoreEmptyCollections = true)),
(schema, config) =>
JsonCodec.schemaBasedBinaryCodec[A](
JsonCodec.Config(ignoreEmptyCollections = config.ignoreEmptyCollections),
)(schema),
schema,
),
),
Expand Down
Loading

0 comments on commit c8af61d

Please sign in to comment.