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

Conversation

kubinio123
Copy link
Contributor

No description provided.

@kubinio123 kubinio123 marked this pull request as ready for review March 15, 2021 09:53
@kubinio123 kubinio123 requested a review from adamw March 15, 2021 09:53
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

case Left(_) =>
DecodeResult.Error(statusText, new IllegalArgumentException("Unable to parse Content-Type header"))
}
.getOrElse(DecodeResult.Error(statusText, new IllegalStateException("Missing Content-Type header")))
Copy link
Member

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)?

Copy link
Contributor Author

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
Copy link
Member

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

Copy link
Contributor Author

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 {
Copy link
Member

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

Copy link
Contributor Author

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 =
Copy link
Member

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
Copy link
Member

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

@kubinio123 kubinio123 requested a review from adamw March 25, 2021 08:55
meta.statusText,
new IllegalArgumentException(s"Cannot find mapping for status code ${meta.code} in outputs $output")
)
mappingsForStatus.headOption
Copy link
Member

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, _), _) =>
Copy link
Member

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

Copy link
Contributor Author

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]]
Copy link
Member

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?

Copy link
Member

Choose a reason for hiding this comment

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

bump :)

@adamw
Copy link
Member

adamw commented Mar 25, 2021

Will need a rebase/merge unfortunately

@adamw
Copy link
Member

adamw commented Mar 25, 2021

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)
Copy link
Member

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[_]]

Copy link
Member

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))
Copy link
Member

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)
Copy link
Member

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

@kubinio123 kubinio123 marked this pull request as draft March 29, 2021 07:42
@kubinio123 kubinio123 self-assigned this Mar 29, 2021
# 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
@kubinio123 kubinio123 requested a review from adamw March 29, 2021 13:48
)
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


trait ServerRequest extends RequestMetadata {
lazy val ranges: Either[String, immutable.Seq[ContentTypeRange]] = Accepts.parse(headers)
Copy link
Member

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

Copy link
Member

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
@kubinio123 kubinio123 requested a review from adamw March 30, 2021 10:19
@adamw adamw marked this pull request as ready for review March 30, 2021 14:09
@adamw adamw merged commit 0e091f5 into master Mar 30, 2021
@mergify mergify bot deleted the interceptors-content-negotiation branch March 30, 2021 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants