Skip to content

[release/7.0] Infer response metadata in RequestDelegateFactory #43961

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

Merged
merged 4 commits into from
Sep 14, 2022

Conversation

halter73
Copy link
Member

@halter73 halter73 commented Sep 13, 2022

RequestDelegateFactory should infer metadata about the response bodies it produces similar to the way it infers metadata about the request bodies it consumes.

Description

RequestDelegateFactory already infers IAcceptsMetadata when it's reading JSON request bodies or multipart form data, so it's peculiar that it does not infer IProducesResponseTypeMetadata when our TypedResults do.

With this PR, RequestDelegateFactory now infers and adds "application/json" and "text/plain" ProducesResponseTypeMetadata with the appropriate return Type (for POCOs, not for string).

Fixes #43675

Customer Impact

@DamianEdwards mentioned:

So I just hit a scenario where this might help. I was attempting to get JWT bearer auth and the new problem details service support to work in an app, such that that Swagger UI enables logging in and making requests from the browser.

var examples = app.MapGroup("/").WithTags("Examples");

// Describe that all APIs can return errors as JSON or plain text
examples.WithMetadata(new ProducesResponseTypeAttribute(typeof(ProblemDetails), 401, "application/problem+json", "text/plain"));
examples.WithMetadata(new ProducesResponseTypeAttribute(typeof(ProblemDetails), 403, "application/problem+json", "text/plain"));

examples.MapGet("/secret", (ClaimsPrincipal user) => "shhh").RequireAuthorization();

app.Run();

This results in the API being described as only returning 401 or 403, as the inferred response (200: text/plain) is omitted due to the explicit metadata in the group. This PR makes it so RDF now infers the 200 "text\plain" response as well.

Regression?

  • Yes
  • No

This is a brand-new scenario though.

Risk

  • High
  • Medium
  • Low

This mostly impacts the new WithOpenApi() and MapGroup() features.

Verification

  • Manual (required)
  • Automated

Packaging changes reviewed?

  • Yes
  • No
  • N/A

@DamianEdwards @captainsafia @Pilchie

@halter73 halter73 added feature-minimal-actions Controller-like actions for endpoint routing old-area-web-frameworks-do-not-use *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels labels Sep 13, 2022
@halter73 halter73 added this to the 7.0-rc2 milestone Sep 13, 2022
@halter73 halter73 added the Servicing-consider Shiproom approval is required for the issue label Sep 13, 2022
@ghost
Copy link

ghost commented Sep 13, 2022

Hi @halter73. Please make sure you've updated the PR description to use the Shiproom Template. Also, make sure this PR is not marked as a draft and is ready-to-merge.

To learn more about how to prepare a servicing PR click here.

@halter73 halter73 linked an issue Sep 13, 2022 that may be closed by this pull request
@DamianEdwards
Copy link
Member

@halter73 do we not need to update the ApiExplorer provider now too, as what was inferred from MethodInfo before is now already in metadata?

@halter73
Copy link
Member Author

halter73 commented Sep 13, 2022

@halter73 do we not need to update the ApiExplorer provider now too, as what was inferred from MethodInfo before is now already in metadata?

No. The following logic now prevents CreateDefaultApiResponseType(Type responseType) from ever being called since there is now IProducesResponseTypeMetadata associated with the endpoint.

var responseProviderMetadata = endpointMetadata.GetOrderedMetadata<IApiResponseMetadataProvider>();
var producesResponseMetadata = endpointMetadata.GetOrderedMetadata<IProducesResponseTypeMetadata>();
var errorMetadata = endpointMetadata.GetMetadata<ProducesErrorResponseTypeAttribute>();
var defaultErrorType = errorMetadata?.Type ?? typeof(void);
var contentTypes = new MediaTypeCollection();
var responseProviderMetadataTypes = ApiResponseTypeProvider.ReadResponseMetadata(
responseProviderMetadata, responseType, defaultErrorType, contentTypes);
var producesResponseMetadataTypes = ApiResponseTypeProvider.ReadResponseMetadata(producesResponseMetadata, responseType);
// We favor types added via the extension methods (which implements IProducesResponseTypeMetadata)
// over those that are added via attributes.
var responseMetadataTypes = producesResponseMetadataTypes.Values.Concat(responseProviderMetadataTypes.Values);
if (responseMetadataTypes.Any())

This does mean that just like WithOpenApi(), ApiExplorer will still generate an "empty" 200 response in void cases where RDF still does not infer IProducesResponseTypeMetadata.

RDF reasonably makes no claim to be able to infer anything about the response in void-returning cases. WithOpenApi() and ApiExplorer do though, and this avoids a breaking behavioral change with both these APIs.

Compatibility is also why I was unable to delete the internal response type inference based on the return type in WithOpenApi() and ApiExplorer. There's a chance that these are still used in cases where RDF hasn't inferred IProducesResponseTypeMetadata and I don't think we want to break those scenarios.

@halter73
Copy link
Member Author

@Pilchie should I send a tactics email for this? Or is still unnecessary since we haven't branched rc2 yet?

@@ -193,6 +193,8 @@ private static OpenApiResponses GetOpenApiResponses(MethodInfo method, EndpointM
foreach (var annotation in eligibileAnnotations)
{
var statusCode = annotation.Key.ToString(CultureInfo.InvariantCulture);

// TODO: Use the discarded response Type for schema generation
Copy link
Member

Choose a reason for hiding this comment

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

Super nit: we don't need to have this in code since it's tracked in the issue locker and that's a lot more resilient than in-code TODOs.

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm glad there's an issue. I agree that's more important, but I think the comment is also fine. I don't want to rekick the build just for this at least.


if (returnType == typeof(string))
{
builder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(type: null, statusCode: 200, PlaintextContentType));
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
builder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(type: null, statusCode: 200, PlaintextContentType));
builder.Metadata.Add(ProducesResponseTypeMetadata.CreateUnvalidated(type: typeof(string), statusCode: 200, PlaintextContentType));

Why null over typeof(string) here?

Copy link
Member Author

@halter73 halter73 Sep 14, 2022

Choose a reason for hiding this comment

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

I left a brief comment here but I immediately resolved it. Basically, the caller shouldn't care if internally we used a Utf8ContentHttpResult and ReadOnlySpan<byte> or string. It has no observable impact on the API, so string doesn't have any impact on the schema. "text\plain" should be sufficient.

/// <summary>
/// Gets or sets the type of the value returned by an action.
/// </summary>
public Type Type { get; set; }
public Type? Type { get; set; }
Copy link
Member

Choose a reason for hiding this comment

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

Not sure making this nullable is the right call here. Can you clarify your reasoning as to why this is?

Copy link
Member Author

Choose a reason for hiding this comment

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

This type is internal. The interface is public, and Type is already nullable there. Everything reading Type is using the interface is dealing with the possible null anyway for custom interface implementations which is why I didn't need to react.

Assert.True(defaultOperation.Content.ContainsKey("text/plain"));

var annotatedOperation = operation.Responses["201"];
// Produces doesn't special case string??
Copy link
Member

Choose a reason for hiding this comment

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

What do you mean by this comment?

Copy link
Member Author

Choose a reason for hiding this comment

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

I was just surprised when updating this test that .Produces<string>(201); creates "application/json" metadata. I would have expected "text\plain", but this is existing behavior. You can still explicitly set the content-type if you want.

Copy link
Member

Choose a reason for hiding this comment

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

Wait it does? That seems like a bug.

Copy link
Member

Choose a reason for hiding this comment

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

Yep, this has been behavior for a while. The Produces extension method sets the type to application/json by default unless explicitly defined.

We had some extensive conversations about this when we were building out the defaults and decided that setting the default to application/json would be sensible for most minimal cases.

Copy link
Member Author

Choose a reason for hiding this comment

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

#43979. If we agree it's important, I'll try to get a PR out by tomorrow before branching. I'm also going to open an API proposal about making Produces and Accepts work with groups. I think we should probably just retarget these from RouteHandlerBuilder to IEndopintConventionBuilder, but we can consider RouteGroupBuilder-specific overloads although that's less extensible.

@Pilchie
Copy link
Member

Pilchie commented Sep 14, 2022

should I send a tactics email for this? Or is still unnecessary since we haven't branched rc2 yet?

if it’s ready to go by end of day tomorrow I can approve. We are branch on Thursday.

@Pilchie
Copy link
Member

Pilchie commented Sep 14, 2022

Approved for .NET 7 RC2.

@Pilchie Pilchie merged commit 0bb3d68 into release/7.0 Sep 14, 2022
@Pilchie Pilchie deleted the halter73/43675 branch September 14, 2022 17:51
@halter73 halter73 restored the halter73/43675 branch September 14, 2022 19:41
@halter73 halter73 deleted the halter73/43675 branch September 14, 2022 20:56
@amcasey amcasey added the area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc label Jun 2, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc feature-minimal-actions Controller-like actions for endpoint routing old-area-web-frameworks-do-not-use *DEPRECATED* This label is deprecated in favor of the area-mvc and area-minimal labels Servicing-consider Shiproom approval is required for the issue
Projects
None yet
Development

Successfully merging this pull request may close these issues.

RequestDelegateFactory should infer IProducesResponseTypeMetadata
5 participants