Skip to content

Instantly share code, notes, and snippets.

@DanteDeRuwe
Last active June 13, 2024 14:44
Show Gist options
  • Save DanteDeRuwe/de3f745d4a3aad7065affe1fc9ed96d0 to your computer and use it in GitHub Desktop.
Save DanteDeRuwe/de3f745d4a3aad7065affe1fc9ed96d0 to your computer and use it in GitHub Desktop.
Swagger extensions for minimal apis
using Asp.Versioning.ApiExplorer;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace BFF.API.Extensions.OpenApi;
public class ConfigureSwaggerGenOptions(IApiVersionDescriptionProvider versionProvider) : IConfigureNamedOptions<SwaggerGenOptions>
{
public void Configure(string? name, SwaggerGenOptions options) => Configure(options);
public void Configure(SwaggerGenOptions options)
{
CreateDocPerVersion(options);
AddJwtBearerSecurity(options);
options.DescribeAllParametersInCamelCase();
}
private static void AddJwtBearerSecurity(SwaggerGenOptions options)
{
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Description = "JWT Authorization header using the Bearer scheme",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.Http,
Scheme = "Bearer",
BearerFormat = "JWT"
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
},
[]
}
});
}
private void CreateDocPerVersion(SwaggerGenOptions options)
{
foreach (var versionDescription in versionProvider.ApiVersionDescriptions)
{
var openApiInfo = new OpenApiInfo
{
Title = "Mediahuis CIAM Customer Support BFF",
Version = $"v{versionDescription.ApiVersion}",
Contact = new OpenApiContact { Name = "Mediahuis CIAM team - AE studio" }
};
options.SwaggerDoc(versionDescription.GroupName, openApiInfo);
}
}
}
using System.Net;
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Infrastructure;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
namespace BFF.Application.Util;
public static class EndpointBuilderExtensions
{
/// <summary>
/// Does the same thing as <see cref="OpenApiRouteHandlerBuilderExtensions.ProducesProblem"/>, but also usable on <see cref="RouteGroupBuilder"/> for example.
/// </summary>
public static TBuilder ProducesProblem<TBuilder>(this TBuilder builder, int statusCode, string contentType = "application/problem+json")
where TBuilder : IEndpointConventionBuilder
{
return builder.WithMetadata(new ProducesResponseTypeMetadata(statusCode, typeof(ProblemDetails), [contentType]));
}
/// <inheritdoc cref="ProducesProblem{TBuilder}(TBuilder,int,string)"/>
public static TBuilder ProducesProblem<TBuilder>(this TBuilder builder, HttpStatusCode statusCode,
string contentType = "application/problem+json")
where TBuilder : IEndpointConventionBuilder => builder.ProducesProblem((int)statusCode, contentType);
/// <summary>
/// Does the same thing as <see cref="OpenApiRouteHandlerBuilderExtensions.Produces"/>, but also usable on <see cref="RouteGroupBuilder"/> for example.
/// </summary>
public static TBuilder Produces<TBuilder>(this TBuilder builder, int statusCode, string contentType = "application/problem+json")
where TBuilder : IEndpointConventionBuilder
{
return builder.WithMetadata(new ProducesResponseTypeMetadata(statusCode, typeof(void), [contentType]));
}
/// <inheritdoc cref="Produces{TBuilder}(TBuilder,int,string)"/>
public static TBuilder Produces<TBuilder>(this TBuilder builder, HttpStatusCode statusCode,
string contentType = "application/problem+json")
where TBuilder : IEndpointConventionBuilder => builder.ProducesProblem((int)statusCode, contentType);
/// <summary>
/// Adds authorization like normal (<see cref="AuthorizationEndpointConventionBuilderExtensions.RequireAuthorization{TBuilder}(TBuilder)"/>), but also does the following things:
/// <list type="bullet">
/// <item>adds a section to the beginning of the description of the endpoint that lists the authorization requirements.</item>
/// <item>adds a <c>ProducesProblem()</c> to the endpoint with status codes <c>Forbidden</c> (403) and <c>Unauthorized</c> (401).</item>
/// </list>
/// </summary>
/// <remarks>Please call this AFTER any .WithDescription() call to preserve the existing description.</remarks>
public static RouteHandlerBuilder RequireCustomAuthorization(this RouteHandlerBuilder builder,
Action<AuthorizationPolicyBuilder>? configurePolicy = null)
{
var policyBuilder = new AuthorizationPolicyBuilder();
configurePolicy?.Invoke(policyBuilder);
var policy = policyBuilder.Build();
return builder
.RequireAuthorization(policy)
.ProducesProblem(HttpStatusCode.Forbidden)
.ProducesProblem(HttpStatusCode.Unauthorized)
.AddAuthSectionToDescription(policy.Requirements);
}
/// <summary>
/// Shorthand
/// </summary>
/// <param name="builder"></param>
/// <param name="scopes"></param>
/// <param name="claimName"></param>
/// <returns></returns>
public static AuthorizationPolicyBuilder RequireScope(this AuthorizationPolicyBuilder builder,
IEnumerable<string> scopes, string claimName = "scp") => builder.RequireClaim(claimName, scopes);
private static RouteHandlerBuilder AddAuthSectionToDescription(this RouteHandlerBuilder builder,
IEnumerable<IAuthorizationRequirement> policyRequirements)
{
builder.Add(endpointBuilder =>
{
List<string> roles = [];
List<string> scopes = [];
foreach (var requirement in policyRequirements)
{
switch (requirement)
{
case RolesAuthorizationRequirement rolesRequirement:
roles.AddRange(rolesRequirement.AllowedRoles);
break;
case ClaimsAuthorizationRequirement { ClaimType: "scope" or "scp" } claimsRequirement:
scopes.AddRange(claimsRequirement.AllowedValues);
break;
}
}
var description = GetFullDescription(endpointBuilder, roles, scopes);
if (description is not null) endpointBuilder.Metadata.Add(new EndpointDescriptionAttribute(description));
});
return builder;
string? GetFullDescription(EndpointBuilder endpointBuilder, ICollection<string> roles, ICollection<string> scopes)
{
// Since this method ADDS to the existing description, we need to get the existing description first. It is always the last one added.
var sb = endpointBuilder.Metadata.OfType<EndpointDescriptionAttribute>().LastOrDefault()?.Description is string existingDescription
? new StringBuilder("<h2>Description</h2>").Append(existingDescription) // Add a nice header
: new StringBuilder();
// If no roles or scopes are required, return already here
if (roles.Count <= 0 && scopes.Count <= 0) return sb.ToString();
// Start a new section if necessary
sb.AppendIfNotEmpty("<br/><br/><hr/><br/><br/>");
// Add the auth roles and scopes to the existing description
sb.Append("<h2>Authorization</h2>");
if (roles.Count > 0) AddSection("Allowed roles", roles);
if (scopes.Count > 0) AddSection("Allowed scopes", scopes);
return sb.ToString();
void AddSection(string title, IEnumerable<string> items) => sb
.Append("<strong>").Append(title).Append("</strong> ")
.AppendJoin(", ", items.Select(x => $"<code>{x}</code>"))
.Append("<br/><br/>");
}
}
}
using System.Text;
namespace BFF.Application.Util;
public static class StringBuilderExtensions
{
public static StringBuilder AppendIf(this StringBuilder sb, bool condition, string content) => condition ? sb.Append(content) : sb;
public static StringBuilder AppendIfEmpty(this StringBuilder sb, string content) => sb.AppendIf(sb.Length == 0, content);
public static StringBuilder AppendIfNotEmpty(this StringBuilder sb, string content) => sb.AppendIf(sb.Length > 0, content);
}
namespace BFF.API.Extensions.OpenApi;
public static class SwaggerExtensions
{
/// <summary>
/// Adds Swagger services to the service collection
/// </summary>
public static IServiceCollection AddCustomSwagger(this IServiceCollection services)
{
services.AddEndpointsApiExplorer();
services.AddSwaggerGen();
services.ConfigureOptions<ConfigureSwaggerGenOptions>();
return services;
}
/// <summary>
/// Adds Swagger middleware to the web application pipeline
/// </summary>
/// <remarks>Call this AFTER all routes have been mapped onto the app.</remarks>
public static WebApplication UseCustomSwagger(this WebApplication app)
{
app.UseSwagger();
app.UseSwaggerUI(options =>
{
foreach (var versionName in app.DescribeApiVersions().Select(v => v.GroupName))
{
options.SwaggerEndpoint($"/swagger/{versionName}/swagger.json", versionName);
}
});
return app;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment