Skip to content

Add support for generating OpenAPI request bodies #55040

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
Apr 16, 2024

Conversation

captainsafia
Copy link
Member

Note: I'm trying Copilot-generated pull request descriptions for the first time. 🤪

This pull request adds support for generating request body objects into the OpenAPI document.

Key changes include:

Addition of form parameters handling:

Enhancements to OpenApiComponentService:

Refinements to OpenApiDocumentService:

New extension methods and tests:

Minor changes and fixes:

@captainsafia captainsafia added area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates feature-openapi area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc labels Apr 9, 2024
@captainsafia captainsafia requested a review from a team as a code owner April 9, 2024 22:32
Comment on lines +99 to +111
if (bodyParameters.Count() == 1)
{
bodyParameter = bodyParameters.Single();
Copy link
Member

Choose a reason for hiding this comment

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

Use SingleOrDefault()?

Copy link
Member

Choose a reason for hiding this comment

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

I think that throws if there are multiple elements? (Unless you meant under the if, in which case, why?)

Copy link
Member

Choose a reason for hiding this comment

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

I would have gone the other way and suggested First since there's no reason to do another bounds check.

Copy link
Member

Choose a reason for hiding this comment

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

Oh that's me misremembering then. I thought Single() was one or exception, and SingleOrDefault() was one or null/default.

Copy link
Member Author

Choose a reason for hiding this comment

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

SingleOrDefault will only return null/default if the list is empty. If there are multiple elements, it'll through an exception. Although based on the check that we are doing in the if, that should never be the case hence my inclination to use Single. First could also be used here but I feel it obfuscates the intention that there should always be only one parameter resolved from the JSON request body in a given request.

return prefix + "AnonymousType";
}

return prefix + type.Name.Split('`').First();
Copy link
Member

Choose a reason for hiding this comment

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

(Teeny) perf vs readability option:

Suggested change
return prefix + type.Name.Split('`').First();
return prefix + type.Name.Split('`')[0];

Copy link
Member

Choose a reason for hiding this comment

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

That's not the thing that's going to slow this code down. 😆

Comment on lines +99 to +111
if (bodyParameters.Count() == 1)
{
bodyParameter = bodyParameters.Single();
Copy link
Member

Choose a reason for hiding this comment

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

I think that throws if there are multiple elements? (Unless you meant under the if, in which case, why?)

Comment on lines +99 to +111
if (bodyParameters.Count() == 1)
{
bodyParameter = bodyParameters.Single();
Copy link
Member

Choose a reason for hiding this comment

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

I would have gone the other way and suggested First since there's no reason to do another bounds check.

{
if (!type.IsConstructedGenericType)
{
return type.Name.Replace("[]", "Array");
Copy link
Member

Choose a reason for hiding this comment

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

typeof(int[,])

/// </summary>
/// <param name="type">The <see cref="Type"/> to resolve a schema reference identifier for.</param>
/// <returns>The schema reference identifier associated with <paramref name="type"/>.</returns>
public static string GetSchemaReferenceId(this Type type)
Copy link
Member

Choose a reason for hiding this comment

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

Can type be a nested type?

}

var prefix = type.GetGenericArguments()
.Select(GetSchemaReferenceId)
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 be a little nervous about blowing the stack here.

public static bool IsAnonymousType(this Type type) =>
type.GetTypeInfo().IsClass
&& type.GetTypeInfo().IsDefined(typeof(CompilerGeneratedAttribute))
&& !type.IsNested
Copy link
Member

Choose a reason for hiding this comment

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

Why can't anonymous types be nested?

return prefix + "AnonymousType";
}

return prefix + type.Name.Split('`').First();
Copy link
Member

Choose a reason for hiding this comment

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

That's not the thing that's going to slow this code down. 😆

{
// Assume "application/x-www-form-urlencoded" as the default media type
// to match the default assumed in IFormFeature.
supportedRequestFormats = [new ApiRequestFormat { MediaType = "application/x-www-form-urlencoded" }];
Copy link
Member

Choose a reason for hiding this comment

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

How does one change this?

Copy link
Member Author

Choose a reason for hiding this comment

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

For overriding whatever content-type is used, end-users can add Accepts metadata to the given endpoint.

[(new { Id = 1, Name = "Todo" }).GetType(), "Int32StringAnonymousType"],
[typeof(IFormFile), "IFormFile"],
[typeof(IFormFileCollection), "IFormFileCollection"],
[typeof(Results<Ok<TodoWithDueDate>, Ok<Todo>>), "TodoWithDueDateOkTodoOkResults"],
Copy link
Member

Choose a reason for hiding this comment

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

Is the name generation defined in a spec or are we making it up?

Copy link
Member

Choose a reason for hiding this comment

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

If this is supposed to be human-readable, some underscores might help.

Copy link
Member Author

Choose a reason for hiding this comment

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

It's not defined in the spec. The only constraints that the spec puts on these keys is that they have to be case sensitive.

I opted for alphanumeric values (avoiding things like "<" or "`").

If this is supposed to be human-readable, some underscores might help.

Adding this would help although underscores as separators isn't a convention I've usually seen. Conventionally, most APIs tend to use PascalCase for type names.

Copy link
Member

Choose a reason for hiding this comment

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

I meant between types, not within types. TodoWithDueDateOk_TodoOk_Results

Copy link
Member

Choose a reason for hiding this comment

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

That would be TodoWithDueDate_Ok_Todo_Ok_Results in this case?
Throwing out random thought: ResultsOf_OkOf_TodoWithDueDate_And_OkOf_Todo

Copy link
Member

Choose a reason for hiding this comment

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

There's enough context that the printer could skip it for arity 1, but sure, might as well go all the way. I like "Of", but I find "And" pretty wordy.

return null;
}

private OpenApiRequestBody GetFormRequestBody(ApiDescription description, IEnumerable<ApiParameterDescription> formParameters)
Copy link
Member

Choose a reason for hiding this comment

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

Nit: Personally, I would find it cleaner to pass in the supported request formats. It feels weird to pull the parameters out of the description and then pass both those parameters and the description they came from to this helper.

And below.

return _schemas.GetOrAdd(schemaId, _ => CreateSchema());
}

internal static OpenApiSchema CreateSchema()
Copy link
Member

Choose a reason for hiding this comment

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

private?

schema.Properties[parameter.Name] = _componentService.GetOrCreateSchema(parameter.Type);
}

foreach (var requestForm in supportedRequestFormats)
Copy link
Member

Choose a reason for hiding this comment

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

I might call this "requestFormat" since we're talking about forms in the same scope.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, I believe this was a typo on my part. 😅

[(new { Id = 1, Name = "Todo" }).GetType(), "Int32StringAnonymousType"],
[typeof(IFormFile), "IFormFile"],
[typeof(IFormFileCollection), "IFormFileCollection"],
[typeof(Results<Ok<TodoWithDueDate>, Ok<Todo>>), "TodoWithDueDateOkTodoOkResults"],
Copy link
Member

Choose a reason for hiding this comment

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

If this is supposed to be human-readable, some underscores might help.

[typeof(IFormFile), false],
[typeof(IFormFileCollection), false],
[typeof(Results<Ok<TodoWithDueDate>, Ok<Todo>>), false],
[typeof(TestDelegate), false]
Copy link
Member

Choose a reason for hiding this comment

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

Test a nested type?

@captainsafia captainsafia force-pushed the safia/get-requestbody branch from 0f78d11 to 987a385 Compare April 11, 2024 15:58
@captainsafia
Copy link
Member Author

After noodling on it some more, I decide to key the dictionary tracking the schemas by Type instead of reference ID. We will still need to solve the problem of generating reference IDs from .NET types when we materialize the schemas that are stored into the cache into the document.

My original prototype keyed by Type as well and I diverged from this here but realized that there's value in caching by type because having information about the Type associated with the schema makes it easier to apply schema filters.

Also, I accidentally amended a commit while rebasing changes instead of creating a new commit. Force of habit. 😅 I'm used to rebasing and amending on branches before I make them public in a PR. This unfortunately means that the history for this PR is a little wonky. 😞

Copy link
Member

@amcasey amcasey left a comment

Choose a reason for hiding this comment

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

I think not rolling your own type stringification was the right call. 😆

@captainsafia
Copy link
Member Author

I think not rolling your own type stringification was the right call. 😆

Yes -- and luckily we won't have to include this in our codepath in the long-term because the runtime's implementation will support generating schema reference IDs.

@captainsafia captainsafia merged commit d82dab5 into feature/openapi Apr 16, 2024
21 checks passed
@captainsafia captainsafia deleted the safia/get-requestbody branch April 16, 2024 18:02
captainsafia added a commit that referenced this pull request Apr 18, 2024
Co-authored-by: Martin Costello <[email protected]>
Co-authored-by: Rick Anderson <[email protected]>

This PR adds support for OpenAPI document generation, sans schema generation to Microsoft.AspNetCore.OpenApi. Relevant changes are available in individual PRs:

- Add entry-point APIs for OpenAPI support (#54789)
- Support resolving OpenApiPaths entries from document (#54847) 
- Support generating OpenAPI operation and associated fields (#54903) 
- Add APIs for OpenAPI document transformers (#54935) 
- Add support for generating OpenAPI parameters (#55041)
- Add support for generating OpenAPI responses (#55020) 
- Add support for generating OpenAPI request bodies (#55040)
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 area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates feature-openapi
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants