Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ OpenAPI Spec generation filters:
- Display OperationId in SwaggerUI
- Extract Type schema from TypedResult endpoint response types.
- Ensure that non-nullable properties are marked as required in the OpenAPI document. It is no longer necessary to add `[Required]` attributes to object properties.
- Order responses by status code for consistent OpenAPI document generation.
- (Optional) Fallback to use controller name as OperationId when there is no OperationId explicitly defined for the endpoint.

Roslyn Analyzers to help validate usage typed responses:
Expand Down
9 changes: 9 additions & 0 deletions src/Workleap.Extensions.OpenAPI/Builder/OpenApiBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Swashbuckle.AspNetCore.SwaggerGen;
using Swashbuckle.AspNetCore.SwaggerUI;
using Workleap.Extensions.OpenAPI.OperationId;
using Workleap.Extensions.OpenAPI.Ordering;
using Workleap.Extensions.OpenAPI.RequiredType;
using Workleap.Extensions.OpenAPI.TypedResult;

Expand All @@ -28,6 +30,13 @@ internal OpenApiBuilder(IServiceCollection services)
options.OperationFilter<ExtractSchemaTypeResultFilter>();
options.SchemaFilter<ExtractRequiredAttributeFromNullableType>();
});

// Use PostConfigure to ensure ordering happens after all other filters
this._services.PostConfigure<SwaggerGenOptions>(options =>
{
options.DocumentFilter<OrderResponseFilter>();
});

this._services.AddSingleton<IStartupFilter, JsonOptionsFilter>();
}

Expand Down
40 changes: 40 additions & 0 deletions src/Workleap.Extensions.OpenAPI/Ordering/OrderResponseFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;

namespace Workleap.Extensions.OpenAPI.Ordering;

/// <summary>
/// This filter ensures consistent ordering for better source control diffs and predictable documentation.
/// </summary>
internal sealed class OrderResponseFilter : IDocumentFilter
{
public void Apply(OpenApiDocument document, DocumentFilterContext context)
{
var paths = document.Paths.ToList();
document.Paths.Clear();
document.Paths = new OpenApiPaths();
foreach (var path in paths)
{
document.Paths.Add(path.Key, path.Value);

var sortedOperations = path.Value.Operations.OrderBy(op => (int)op.Key).ToList();
path.Value.Operations.Clear();
foreach (var operation in sortedOperations)
{
path.Value.Operations.Add(operation.Key, operation.Value);

// Sort responses by status code (200, 400, 403, 404, 500, etc.)
// This is critical because responses from both controller-level ProducesResponseType
// and method-level attributes are added in the order they're processed, not by status code.
// Without sorting, a 403 from a controller-level attribute might appear before a 200
// from the method-level TypedResult, causing unpredictable ordering and noisy diffs.
var sortedResponse = operation.Value.Responses.OrderBy(responseKvp => responseKvp.Key, StringComparer.Ordinal).ToList();
operation.Value.Responses.Clear();
foreach (var response in sortedResponse)
{
operation.Value.Responses.Add(response.Key, response.Value);
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ public Ok<string> GivenTemplatedOkTypedResultAndNoContenTypeThenContentTypeAppli
return TypedResults.Ok("example");
}

[HttpGet]
[EndpointName("NoContentType")]
[Route("/useApplicationJsonContentTypeWithNoContent")]
public NoContent GivenNoContentTypeResultThenNoContentTypeApplicationJson()
{
return TypedResults.NoContent();
}

[HttpGet]
[EndpointName("ResultsNoContentType")]
[Route("/useApplicationJsonContentTypeWithResultsType")]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using System.Net.Mime;
using Workleap.Extensions.OpenAPI.TypedResult;

namespace WebApi.OpenAPI.SystemTest.Ordering;

// Repro case of a bug where a ProducesResponseType on the controller level messes up the ordering of the responses
[ApiController]
[Route("ordering")]
[ProducesResponseType(typeof(IEnumerable<ForbiddenReason>), 403, MediaTypeNames.Application.Json)]
public class OrderingController : ControllerBase
{
[HttpPost("withLotsOfResults")]
public async Task<Results<Ok<string>, NotFound, BadRequest, InternalServerError<ProblemDetails>>> WithLotsOfResults()
{
await Task.CompletedTask;
return TypedResults.Ok("Result");
}

private sealed record ForbiddenReason(string Reason);
}
64 changes: 55 additions & 9 deletions src/tests/WebApi.OpenAPI.SystemTest/openapi-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,36 @@ paths:
responses:
'200':
description: OK
/ordering/withLotsOfResults:
post:
tags:
- Ordering
operationId: WithLotsOfResults
responses:
'200':
description: OK
content:
application/json:
schema:
type: string
'400':
description: Bad Request
'403':
description: Forbidden
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ForbiddenReason'
'404':
description: Not Found
'500':
description: Internal Server Error
content:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
/recordClassRequiredType:
get:
tags:
Expand Down Expand Up @@ -226,7 +256,7 @@ paths:
schema:
$ref: '#/components/schemas/TypedResultExample'
'202':
description: OK
description: Accepted
'422':
description: Unprocessable Content
/withNoAnnotationForAcceptedAndUnprocessableResponseWithType:
Expand All @@ -248,7 +278,7 @@ paths:
schema:
$ref: '#/components/schemas/TypedResultExample'
'202':
description: OK
description: Accepted
content:
application/json:
schema:
Expand Down Expand Up @@ -324,7 +354,7 @@ paths:
format: int32
responses:
'204':
description: Accepted
description: No Content
'401':
description: Unauthorized
/voidOk:
Expand Down Expand Up @@ -449,6 +479,12 @@ paths:
responses:
'200':
description: OK
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/TypedResultExample'
'202':
description: Accepted
content:
Expand All @@ -461,12 +497,6 @@ paths:
application/json:
schema:
type: string
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/TypedResultExample'
'403':
description: Forbidden
content:
Expand Down Expand Up @@ -499,6 +529,14 @@ paths:
application/json:
schema:
type: string
/useApplicationJsonContentTypeWithNoContent:
get:
tags:
- TypedResultProperContentType
operationId: NoContentType
responses:
'204':
description: No Content
/useApplicationJsonContentTypeWithResultsType:
get:
tags:
Expand Down Expand Up @@ -573,6 +611,14 @@ paths:
type: string
components:
schemas:
ForbiddenReason:
required:
- reason
type: object
properties:
reason:
type: string
additionalProperties: false
OperationEnum:
enum:
- Foo
Expand Down
64 changes: 55 additions & 9 deletions src/tests/expected-openapi-document.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,36 @@ paths:
responses:
'200':
description: OK
/ordering/withLotsOfResults:
post:
tags:
- Ordering
operationId: WithLotsOfResults
responses:
'200':
description: OK
content:
application/json:
schema:
type: string
'400':
description: Bad Request
'403':
description: Forbidden
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ForbiddenReason'
'404':
description: Not Found
'500':
description: Internal Server Error
content:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
/recordClassRequiredType:
get:
tags:
Expand Down Expand Up @@ -226,7 +256,7 @@ paths:
schema:
$ref: '#/components/schemas/TypedResultExample'
'202':
description: OK
description: Accepted
'422':
description: Unprocessable Content
/withNoAnnotationForAcceptedAndUnprocessableResponseWithType:
Expand All @@ -248,7 +278,7 @@ paths:
schema:
$ref: '#/components/schemas/TypedResultExample'
'202':
description: OK
description: Accepted
content:
application/json:
schema:
Expand Down Expand Up @@ -324,7 +354,7 @@ paths:
format: int32
responses:
'204':
description: Accepted
description: No Content
'401':
description: Unauthorized
/voidOk:
Expand Down Expand Up @@ -449,6 +479,12 @@ paths:
responses:
'200':
description: OK
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/TypedResultExample'
'202':
description: Accepted
content:
Expand All @@ -461,12 +497,6 @@ paths:
application/json:
schema:
type: string
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/TypedResultExample'
'403':
description: Forbidden
content:
Expand Down Expand Up @@ -499,6 +529,14 @@ paths:
application/json:
schema:
type: string
/useApplicationJsonContentTypeWithNoContent:
get:
tags:
- TypedResultProperContentType
operationId: NoContentType
responses:
'204':
description: No Content
/useApplicationJsonContentTypeWithResultsType:
get:
tags:
Expand Down Expand Up @@ -573,6 +611,14 @@ paths:
type: string
components:
schemas:
ForbiddenReason:
required:
- reason
type: object
properties:
reason:
type: string
additionalProperties: false
OperationEnum:
enum:
- Foo
Expand Down