-
Notifications
You must be signed in to change notification settings - Fork 422
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
Conversation
refactor EndpointToOperationResponse - join all outputs first instead of merging Response later
# Conflicts: # tests/src/main/scala/sttp/tapir/tests/package.scala
…stment) added charset comparing while decoding outputs updated validation of endpoint outputs by comparing charsets
# Conflicts: # core/src/main/scala/sttp/tapir/server/interpreter/ServerInterpreter.scala
example: Option[ExampleValue], | ||
examples: ListMap[String, ReferenceOr[Example]], | ||
content: ListMap[String, MediaType] | ||
description: Option[String] = None, |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
no, adjusted
case Left(_) => | ||
DecodeResult.Error(statusText, new IllegalArgumentException("Unable to parse Content-Type header")) | ||
} | ||
.getOrElse(DecodeResult.Error(statusText, new IllegalStateException("Missing Content-Type header"))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what if one of the response variants has no body (e.g. a redirect or some other non-200 status code)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
right, it has to be handled, adjusted
|
||
def matchesContent(content: MediaType): Boolean = { | ||
val contentWithCharset = content match { | ||
case m @ MediaType(_, _, None) => m.charset(StandardCharsets.UTF_8.name()) // default UTF-8 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we should add UTF-8 as the default. Quite the contrary, most media types except text/*
don't support charsets
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
right, only text has a default one
private[interpreter] class MediaTypeNegotiator(headers: Seq[Header]) { | ||
|
||
private val acceptedMediaTypes: Seq[(MediaType, Float)] = | ||
ContentNegotiation.extract(HeaderNames.Accept, headers)(MediaType.unsafeParse).sortBy { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a good start, but I think the more natural place for this would be in sttp-model. We already have there a couple of classes which parse specific header values (cache directives, etags). Similarly, we could have a parser for the Accept
header value, which would include the q values & the sorting logic.
We could also add some methods to MediaType
to check if the main/sub types is a wildcard *
to avoid these constants here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I opened a PR in sttp-model softwaremill/sttp-model#76
|
||
def indexOf(mediaType: MediaType): Int = acceptedMediaTypes.indexWhere { case (mt, _) => mt.noCharset == mediaType.noCharset } | ||
|
||
private def matches(a: MediaType, b: MediaType): Boolean = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this could also be a method on MediaType
, rather than here
case EndpointOutput.MappedPair(wrapped, _) => apply(wrapped, ParamsAsAny(encoded[Any]), ov) | ||
} | ||
} | ||
|
||
private def charset[R](mediaType: MediaType, bodyType: RawBodyType[R]): Option[Charset] = bodyType match { | ||
private def charset[R](bodyType: RawBodyType[R]): Option[Charset] = bodyType match { | ||
// TODO: add to MediaType - setting optional charset if text |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess the TODO doesn't make sense anymore
…nd with no body output in mappings
meta.statusText, | ||
new IllegalArgumentException(s"Cannot find mapping for status code ${meta.code} in outputs $output") | ||
) | ||
mappingsForStatus.headOption |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
shouldn't we give priority to mappings without a body in such case? e.g. for a mapping
oneOf[Either[Unit, X]](
(Ok, jsonBody[X].map(Right(_)),
(Ok, emptyOutput.map(Left(_))
)
in case we get back an empty response, the second should be picked
val bodyMappings: Map[MediaType, StatusMapping[_]] = mappings | ||
.filter(_.appliesTo(enc)) | ||
.collect({ | ||
case sm @ StatusMapping(_, EndpointIO.Body(bodyType, codec, _), _) => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the body outputs might be nested, e.g. .output(jsonBody[X].map(...))
or .output(jsonBody[X].and(header(...)))
, so such a simple pattern match won't work in this case. I think you'll have to traverse the outputs to find the body
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, I keep forgetting about this nest here, adjusted
|
||
if (bodyMappings.nonEmpty) { | ||
val ranges = Accepts.unsafeParse(requestHeaders) | ||
val mediaTypes = bodyMappings.keys.toSeq.asInstanceOf[scala.collection.immutable.Seq[MediaType]] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why is the cast needed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bump :)
Will need a rebase/merge unfortunately |
Looks good, I think there are only the two issues remaining which I wrote about above :) |
# Conflicts: # docs/openapi-docs/src/test/scala/sttp/tapir/docs/openapi/VerifyYamlTest.scala # project/Versions.scala
Vector[(MediaType, StatusMapping[_])]( | ||
charset.map(ch => codec.format.mediaType.charset(ch.name())).getOrElse(codec.format.mediaType) -> sm | ||
) | ||
case EndpointIO.Empty(codec, _) => Vector[(MediaType, StatusMapping[_])](codec.format.mediaType -> sm) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure this will work as expected. The media type for an empty output will always be text/plain
(by definition). An empty output should match the no-media-type, so we'd get a Map[Option[MediaType], StatusMapping[_]]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And we can't really depend on Empty
here. The output might contain headers, but not be empty.
.bestMatch(mediaTypes, ranges) | ||
.flatMap(bodyMappings.get) | ||
.map(sm => apply(sm.output, ParamsAsAny(enc), sm.statusCode.map(ov.withStatusCode).getOrElse(ov))) | ||
.getOrElse(ov.withStatusCode(StatusCode.NotAcceptable)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure if we can do this here. When we get to EncodeOutputs
, the server logic has already run - can we return a 4xx response at this point? Shouldn't we either:
- perform the validation upfront, when decoding the requests - although we don't know what's the status code going to be, so we can only say if all or none match
- when no mapping is found, returning any response instead of "not acceptable"
Would be good to see how other libraries/frameworks behave in this case
ov.withBody(headers => rawToResponseBody.fromRawValue(encoded[Any], headers, codec.format, rawBodyType)) | ||
.withDefaultContentType(codec.format, charset(codec.format.mediaType, rawBodyType)) | ||
.withDefaultContentType(codec.format, maybeCharset) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
here the question is the same - should we check if whatever is in the Accept
of the request matches what we want to return? Or just return regardless? Either way, behavior should be consistent with that of oneOf
# Conflicts: # core/src/main/scala/sttp/tapir/internal/package.scala # core/src/main/scala/sttp/tapir/server/interpreter/EncodeOutputs.scala # core/src/test/scala/sttp/tapir/EndpointTest.scala # docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/EndpointToOperationResponse.scala # docs/openapi-docs/src/main/scala/sttp/tapir/docs/openapi/ExampleConverter.scala
) | ||
val firstNonBodyMapping = mappingsForStatus.find(_.output.traverseOutputs { | ||
case _ @(EndpointIO.Body(_, _, _) | EndpointIO.StreamBodyWrapper(_)) => Vector(false) | ||
case _ => Vector(true) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
|
||
trait ServerRequest extends RequestMetadata { | ||
lazy val ranges: Either[String, immutable.Seq[ContentTypeRange]] = Accepts.parse(headers) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd like to keep this class as simple as possible, without helper methods
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(I know there are some from HasHeaders
, but that's shared among sttp client/tapir :) )
# Conflicts: # server/tests/src/main/scala/sttp/tapir/server/tests/ServerBasicTests.scala
No description provided.