Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interceptors content negotiation #1078

Merged
merged 36 commits into from
Mar 30, 2021
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
d4d00e6
validation of status code mappings
kubinio123 Mar 8, 2021
334ac9e
test for open api multiple media types to single status code
kubinio123 Mar 8, 2021
69a711a
test case mapping one status to multiple schemas in multiple formats
kubinio123 Mar 8, 2021
e45f6d4
select output based on content-type in client interpreters
kubinio123 Mar 9, 2021
92aee61
xml module added
kubinio123 Mar 9, 2021
f4e3755
test fixing
kubinio123 Mar 10, 2021
bdfd456
refactor
kubinio123 Mar 10, 2021
9cbdd63
filter.find -> collectFirst
kubinio123 Mar 10, 2021
b905b1d
improvement
kubinio123 Mar 11, 2021
4ceacad
initial ContentNegotiation impl
kubinio123 Mar 11, 2021
6d202f4
refactor
kubinio123 Mar 11, 2021
2392a87
removed validate from mapOut methods
kubinio123 Mar 11, 2021
3893a79
content negotiation introduced in logic + tests
kubinio123 Mar 12, 2021
5eba867
Merge branch 'feature/248' into interceptors-content-negotiation
kubinio123 Mar 12, 2021
f739155
merged changes from feature/248 (client side content negotiation adju…
kubinio123 Mar 15, 2021
ff07b9f
Merge branch 'interceptors' into interceptors-content-negotiation
kubinio123 Mar 15, 2021
e6aed63
described extract method
kubinio123 Mar 15, 2021
bbec3a3
charset extraction from StreamBody before picking best content
kubinio123 Mar 15, 2021
a7b545a
test case added, static imports
kubinio123 Mar 15, 2021
b7750ca
open api model adjustement
kubinio123 Mar 18, 2021
c73b232
decoding response adjustment, handle case with no content specified a…
kubinio123 Mar 18, 2021
615f4c1
default charset only for text type
kubinio123 Mar 18, 2021
4b9769e
Merge branch 'master' into interceptors-content-negotiation
kubinio123 Mar 24, 2021
da09172
new sttp-model adjustment
kubinio123 Mar 24, 2021
2f05eba
compilation fix
kubinio123 Mar 24, 2021
d8aaa3d
handled case when there's no body mappings
kubinio123 Mar 25, 2021
0d89ce3
traverse outputs, handle no content case with body and non body mappings
kubinio123 Mar 25, 2021
44befae
Merge branch 'master' into interceptors-content-negotiation
kubinio123 Mar 25, 2021
28396e5
compilation fix + merge
kubinio123 Mar 25, 2021
4933584
Merge branch 'master' into interceptors-content-negotiation
kubinio123 Mar 29, 2021
cf19df4
ContentTypeInterceptor added
kubinio123 Mar 29, 2021
a6afdc1
decoding client result unified
kubinio123 Mar 30, 2021
cc244b2
Merge branch 'master' into interceptors-content-negotiation
kubinio123 Mar 30, 2021
987ddde
removed commented code
kubinio123 Mar 30, 2021
cd725aa
Bring back ranges to ServerRequest
adamw Mar 30, 2021
8c531b7
Simplify
adamw Mar 30, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 64 additions & 59 deletions apispec/openapi-model/src/main/scala/sttp/tapir/openapi/OpenAPI.scala
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,18 @@ case class Components(

// todo: $ref
case class PathItem(
summary: Option[String],
description: Option[String],
get: Option[Operation],
put: Option[Operation],
post: Option[Operation],
delete: Option[Operation],
options: Option[Operation],
head: Option[Operation],
patch: Option[Operation],
trace: Option[Operation],
servers: List[Server],
parameters: List[ReferenceOr[Parameter]]
summary: Option[String] = None,
description: Option[String] = None,
get: Option[Operation] = None,
put: Option[Operation] = None,
post: Option[Operation] = None,
delete: Option[Operation] = None,
options: Option[Operation] = None,
head: Option[Operation] = None,
patch: Option[Operation] = None,
trace: Option[Operation] = None,
servers: List[Server] = List.empty,
parameters: List[ReferenceOr[Parameter]] = List.empty
) {
def mergeWith(other: PathItem): PathItem = {
PathItem(
Expand All @@ -93,32 +93,32 @@ case class PathItem(

// todo: external docs, callbacks, security
case class Operation(
tags: List[String],
summary: Option[String],
description: Option[String],
tags: List[String] = List.empty,
summary: Option[String] = None,
description: Option[String] = None,
operationId: String,
parameters: List[ReferenceOr[Parameter]],
requestBody: Option[ReferenceOr[RequestBody]],
responses: ListMap[ResponsesKey, ReferenceOr[Response]],
deprecated: Option[Boolean],
security: List[SecurityRequirement],
servers: List[Server]
parameters: List[ReferenceOr[Parameter]] = List.empty,
requestBody: Option[ReferenceOr[RequestBody]] = None,
responses: ListMap[ResponsesKey, ReferenceOr[Response]] = ListMap.empty,
deprecated: Option[Boolean] = None,
security: List[SecurityRequirement] = List.empty,
servers: List[Server] = List.empty
)

case class Parameter(
name: String,
in: ParameterIn.ParameterIn,
description: Option[String],
required: Option[Boolean],
deprecated: Option[Boolean],
allowEmptyValue: Option[Boolean],
style: Option[ParameterStyle.ParameterStyle],
explode: Option[Boolean],
allowReserved: Option[Boolean],
description: Option[String] = None,
required: Option[Boolean] = None,
deprecated: Option[Boolean] = None,
allowEmptyValue: Option[Boolean] = None,
style: Option[ParameterStyle.ParameterStyle] = None,
explode: Option[Boolean] = None,
allowReserved: Option[Boolean] = None,
schema: ReferenceOr[Schema],
example: Option[ExampleValue],
examples: ListMap[String, ReferenceOr[Example]],
content: ListMap[String, MediaType]
example: Option[ExampleValue] = None,
examples: ListMap[String, ReferenceOr[Example]] = ListMap.empty,
content: ListMap[String, MediaType] = ListMap.empty
)

object ParameterIn extends Enumeration {
Expand All @@ -145,47 +145,52 @@ object ParameterStyle extends Enumeration {
case class RequestBody(description: Option[String], content: ListMap[String, MediaType], required: Option[Boolean])

case class MediaType(
schema: Option[ReferenceOr[Schema]],
example: Option[ExampleValue],
examples: ListMap[String, ReferenceOr[Example]],
encoding: ListMap[String, Encoding]
schema: Option[ReferenceOr[Schema]] = None,
example: Option[ExampleValue] = None,
examples: ListMap[String, ReferenceOr[Example]] = ListMap.empty,
encoding: ListMap[String, Encoding] = ListMap.empty
)

case class Encoding(
contentType: Option[String],
headers: ListMap[String, ReferenceOr[Header]],
style: Option[ParameterStyle.ParameterStyle],
explode: Option[Boolean],
allowReserved: Option[Boolean]
contentType: Option[String] = None,
headers: ListMap[String, ReferenceOr[Header]] = ListMap.empty,
style: Option[ParameterStyle.ParameterStyle] = None,
explode: Option[Boolean] = None,
allowReserved: Option[Boolean] = None
)

sealed trait ResponsesKey
case object ResponsesDefaultKey extends ResponsesKey
case class ResponsesCodeKey(code: Int) extends ResponsesKey

// todo: links
case class Response(description: String, headers: ListMap[String, ReferenceOr[Header]], content: ListMap[String, MediaType]) {
case class Response(
description: String,
headers: ListMap[String, ReferenceOr[Header]] = ListMap.empty,
content: ListMap[String, MediaType] = ListMap.empty
)

def merge(other: Response): Response =
Response(
description,
headers ++ other.headers,
content ++ other.content
)
object Response {
val Empty: Response = Response("", ListMap.empty, ListMap.empty)
}

case class Example(summary: Option[String], description: Option[String], value: Option[ExampleValue], externalValue: Option[String])
case class Example(
summary: Option[String] = None,
description: Option[String] = None,
value: Option[ExampleValue] = None,
externalValue: Option[String] = None
)

case class Header(
description: Option[String],
required: Option[Boolean],
deprecated: Option[Boolean],
allowEmptyValue: Option[Boolean],
style: Option[ParameterStyle.ParameterStyle],
explode: Option[Boolean],
allowReserved: Option[Boolean],
schema: Option[ReferenceOr[Schema]],
example: Option[ExampleValue],
examples: ListMap[String, ReferenceOr[Example]],
content: ListMap[String, MediaType]
description: Option[String] = None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have default arguments in other places as well? would be keep to stay consistent in the model

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, adjusted

required: Option[Boolean] = None,
deprecated: Option[Boolean] = None,
allowEmptyValue: Option[Boolean] = None,
style: Option[ParameterStyle.ParameterStyle] = None,
explode: Option[Boolean] = None,
allowReserved: Option[Boolean] = None,
schema: Option[ReferenceOr[Schema]] = None,
example: Option[ExampleValue] = None,
examples: ListMap[String, ReferenceOr[Example]] = ListMap.empty,
content: ListMap[String, MediaType] = ListMap.empty
)
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
package sttp.tapir.client.play

import java.io.{ByteArrayInputStream, File, InputStream}
import java.nio.ByteBuffer
import java.nio.file.Files
import java.util.function.Supplier

import play.api.libs.ws.DefaultBodyReadables._
import play.api.libs.ws.DefaultBodyWritables._
import play.api.libs.ws._
import sttp.capabilities.Streams
import sttp.capabilities.akka.AkkaStreams
import sttp.model.Method
import sttp.model.{HeaderNames, MediaType, Method}
import sttp.tapir.Codec.PlainCodec
import sttp.tapir.internal.{CombineParams, Params, ParamsAsAny, RichEndpointInput, RichEndpointOutput, SplitParams}
import sttp.tapir.{
Expand All @@ -26,6 +21,10 @@ import sttp.tapir.{
StreamBodyIO
}

import java.io.{ByteArrayInputStream, File, InputStream}
import java.nio.ByteBuffer
import java.nio.file.Files
import java.util.function.Supplier
import scala.collection.Seq

private[play] class EndpointToPlayClient(clientOptions: PlayClientOptions, ws: StandaloneWSClient) {
Expand Down Expand Up @@ -95,15 +94,49 @@ private[play] class EndpointToPlayClient(clientOptions: PlayClientOptions, ws: S
case EndpointIO.FixedHeader(_, codec, _) => codec.decode(())
case EndpointIO.Empty(codec, _) => codec.decode(())
case EndpointOutput.OneOf(mappings, codec) =>
mappings
.find(mapping => mapping.statusCode.isEmpty || mapping.statusCode.contains(code)) match {
case Some(mapping) =>
getOutputParams(mapping.output, body, headers, code, statusText).flatMap(p => codec.decode(p.asAny))
val mappingsForStatus = mappings collect {
case m if m.statusCode.isEmpty || m.statusCode.contains(code) => m
}

val contentType = headers
.get(HeaderNames.ContentType)
.map { h => MediaType.parse(h.head) }

def applyMapping(m: EndpointOutput.StatusMapping[_]) =
getOutputParams(m.output, body, headers, code, statusText).flatMap(p => codec.decode(p.asAny))

contentType match {
case None =>
DecodeResult.Error(
statusText,
new IllegalArgumentException(s"Cannot find mapping for status code ${code} in outputs $output")
)
val firstNonBodyMapping = mappingsForStatus.find(_.output.traverseOutputs {
case _ @(EndpointIO.Body(_, _, _) | EndpointIO.StreamBodyWrapper(_)) => Vector(false)
case _ => Vector(true)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this won't recurse - see the traverse outputs impl. I think matching on _: EndpointOuput.Basic should do the trick here

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or better:

_.output.traverseOutputs {
  case Body => Vector(())
}.isEmpty

} forall (_ == true))

firstNonBodyMapping
.orElse(mappingsForStatus.headOption)
.map(applyMapping)
.getOrElse(
DecodeResult
.Error(statusText, new IllegalArgumentException(s"Cannot find mapping for status code $code in outputs $output"))
)

case Some(Right(content)) =>
val bodyMappingForStatus = mappingsForStatus collectFirst {
case m if m.output.hasBodyMatchingContent(content) => m
}

bodyMappingForStatus
.orElse(mappingsForStatus.headOption)
.map(applyMapping)
.getOrElse(
DecodeResult
.Error(
statusText,
new IllegalArgumentException(s"Cannot find mapping for status code $code and content $content in outputs $output")
)
)

case Some(Left(_)) => DecodeResult.Error(statusText, new IllegalArgumentException("Unable to parse Content-Type header"))
}

case EndpointIO.MappedPair(wrapped, codec) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
package sttp.tapir.client.sttp

import java.io.ByteArrayInputStream
import java.nio.ByteBuffer
import sttp.capabilities.Streams
import sttp.client3._
import sttp.model.{HeaderNames, Method, Part, ResponseMetadata, StatusCode, Uri}
import sttp.model._
import sttp.tapir.Codec.PlainCodec
import sttp.tapir._
import sttp.tapir.internal._
import sttp.ws.WebSocket

import java.io.ByteArrayInputStream
import java.nio.ByteBuffer

private[sttp] class EndpointToSttpClient[R](clientOptions: SttpClientOptions, wsToPipe: WebSocketToPipe[R]) {
def toSttpRequest[O, E, I](e: Endpoint[I, E, O, R], baseUri: Option[Uri]): I => Request[DecodeResult[Either[E, O]], R] = { params =>
val (uri, req1) =
Expand Down Expand Up @@ -62,15 +63,51 @@ private[sttp] class EndpointToSttpClient[R](clientOptions: SttpClientOptions, ws
case EndpointIO.FixedHeader(_, codec, _) => codec.decode(())
case EndpointIO.Empty(codec, _) => codec.decode(())
case EndpointOutput.OneOf(mappings, codec) =>
mappings
.find(mapping => mapping.statusCode.isEmpty || mapping.statusCode.contains(meta.code)) match {
case Some(mapping) =>
getOutputParams(mapping.output, body, meta).flatMap(p => codec.decode(p.asAny))
val mappingsForStatus = mappings collect {
case m if m.statusCode.isEmpty || m.statusCode.contains(meta.code) => m
}

val contentType = meta
.header(HeaderNames.ContentType)
.map(MediaType.parse)

def applyMapping(m: EndpointOutput.StatusMapping[_]) = getOutputParams(m.output, body, meta).flatMap(p => codec.decode(p.asAny))

contentType match {
case None =>
DecodeResult.Error(
meta.statusText,
new IllegalArgumentException(s"Cannot find mapping for status code ${meta.code} in outputs $output")
)
val firstNonBodyMapping = mappingsForStatus.find(_.output.traverseOutputs {
case _ @(EndpointIO.Body(_, _, _) | EndpointIO.StreamBodyWrapper(_)) => Vector(false)
case _ => Vector(true)
} forall (_ == true))

firstNonBodyMapping
.orElse(mappingsForStatus.headOption)
.map(applyMapping)
.getOrElse(
DecodeResult.Error(
meta.statusText,
new IllegalArgumentException(s"Cannot find mapping for status code ${meta.code} in outputs $output")
)
)

case Some(Right(content)) =>
val bodyMappingForStatus = mappingsForStatus collectFirst {
case m if m.output.hasBodyMatchingContent(content) => m
}

bodyMappingForStatus
.orElse(mappingsForStatus.headOption)
.map(applyMapping)
.getOrElse(
DecodeResult.Error(
meta.statusText,
new IllegalArgumentException(
s"Cannot find mapping for status code ${meta.code} and content $content in outputs $output"
)
)
)

case Some(Left(_)) => DecodeResult.Error(meta.statusText, new IllegalArgumentException("Unable to parse Content-Type header"))
}

case EndpointIO.MappedPair(wrapped, codec) => getOutputParams(wrapped, body, meta).flatMap(p => codec.decode(p.asAny))
Expand Down
Loading