Unable to create multiple OpenApi specifications with Swashbuckle - asp.net-core

I am building a solution with Asp.Net Boilerplate / Asp.Net Zero
I have created two OpenApi specifications (HostApiv1 and TenantApiv1) as follows in Startup.cs:
services.AddSwaggerGen(options =>
{
options.SwaggerDoc("HostApiv1", new Info { Title = "Host API v1", Version = "v1" });
options.SwaggerDoc("TenantApiv1", new Info { Title = "Tenant API v1", Version = "v1" });
options.DocInclusionPredicate((docName, description) => true);
options.IgnoreObsoleteActions();
options.IgnoreObsoleteProperties();
options.OrderActionsBy((apiDesc) => $"{apiDesc.RelativePath}");
options.DescribeAllEnumsAsStrings();
});
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint(_appConfiguration["App:HostApiSwaggerEndPoint"], "Host API v1");
options.SwaggerEndpoint(_appConfiguration["App:TenantApiSwaggerEndPoint"], "Tenant API v1");
//...
});
However, when I decorate my AppService classes with [ApiExplorerSettings(GroupName = "HostApiv1")], the grouping is ignored and the tag (AppService controller), along with all of its operations (actions / methods), still appear under both documents.
Any idea what is wrong, or how I can debug it?

Swashbuckle depends on ApiExplorer, and the use of the ApiExplorer attribute limits us to specifying only a single groupname per controller / action. ABP service proxies are generated via NSwag for angular project, and it seems that during this process a dependency is broken.
The workaround is to create a custom Attribute for delimiting one-or-more groupnames for an appservice controller or action, and subsequently use reflection in the DocInclusionPredicateFunction option to retrieve the groupnames for an action or its containing controller.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class SwaggerDocAttribute: Attribute
{
public SwaggerDocAttribute(params string[] includeInDocuments)
{
IncludeInDocuments = includeInDocuments;
}
public string[] IncludeInDocuments { get; }
}
options.DocInclusionPredicate((docName, apiDesc) =>
{
if (!apiDesc.ActionDescriptor.IsControllerAction())
{
return false;
}
apiDesc.TryGetMethodInfo(out MethodInfo methodInfo);
var actionDocs = methodInfo.GetCustomAttributes<SwaggerDocAttribute>()
.SelectMany(a => a.IncludeInDocuments);
var controllerDocs = methodInfo.DeclaringType.GetCustomAttributes<SwaggerDocAttribute>()
.SelectMany(a => a.IncludeInDocuments);
switch (docName)
{
case "HostApiv1":
return apiDesc.GroupName == null ||
actionDocs.Contains("HostApiv1") ||
controllerDocs.Contains("HostApiv1");
case "TenantApiv1":
return apiDesc.GroupName == null ||
actionDocs.Contains("TenantApiv1") ||
controllerDocs.Contains("TenantApiv1");
default:
return true;
}
});
Usage
[DisableAuditing]
[AbpAuthorize(AppPermissions.HostSpecific.Dashboard.Access)]
//[ApiExplorerSettings(GroupName = "HostApiv1")] // <== Don't use this
[SwaggerDoc("HostApiv1")] // <== Use this in stead
public class MyDemoAppService : ZenDetectAppServiceBase, IHostDashboardAppService
{
//...
}

Related

Custom OpenIddictApplication field always returns null

I am using OpenIDDict and extending OpenIddictEntityFrameworkCoreApplication to include my own custom field:
public class TenantApplication : OpenIddictEntityFrameworkCoreApplication<long, TenantAuthorization, TenantToken> {
public long? TenantID { get; set; }
}
public class TenantAuthorization : OpenIddictEntityFrameworkCoreAuthorization<long, TenantApplication, TenantToken> { }
public class TenantScope : OpenIddictEntityFrameworkCoreScope<long> { }
public class TenantToken : OpenIddictEntityFrameworkCoreToken<long, TenantApplication, TenantAuthorization> { }
I register under AddDbContext:
builder.Services.AddDbContext<ApplicationDbContext>(options => {
options.UseNpgsql(connectionString);
options.UseOpenIddict<TenantApplication, TenantAuthorization, TenantScope, TenantToken, long>();
});
and also under AddCore:
// Register the OpenIddict core components.
.AddCore(options =>
options.UseEntityFrameworkCore()
.UseDbContext<ApplicationDbContext>()
.ReplaceDefaultEntities<TenantApplication, TenantAuthorization, TenantScope, TenantToken, long>();
options.UseQuartz();
})
In my Worker, I create the application record if it doesn't exist, but TenantID is always inserted with null:
if (await manager.FindByClientIdAsync("postman", cancellationToken) is null)
{
await manager.CreateAsync(new TokenOpenIddictApplicationDescriptor
{
TenantID = 2,
ClientId = "postman",
ClientSecret = "388D45FA-B36B-4988-BA59-B187D329C207",
DisplayName = "My client application",
Permissions =
{
Permissions.Endpoints.Token,
Permissions.GrantTypes.ClientCredentials
}
});
Similarly, manager.FindByClientIdAsync also returns the Application instance with a null TenantID (after I manually set it in the db). What step am I missing?
I figured this out on my own by diving into the OpenIDDict source code. In order to extend the Application fields, I extended OpenIddictApplicationManager and OpenIddictEntityFrameworkCoreApplicationStore. OpenIddictApplicationManager has a PopulateAsync method which converts a OpenIddictApplicationDescriptor to an Application instance. You need to override this method so that your custom Application fields will be set.
When initializing everything in Program.cs you also need to add your custom store and replace the application manager.
options.AddApplicationStore<TenantOpenIddictEntityFrameworkCoreApplicationStore>();
options.ReplaceApplicationManager<TenantOpenIddictApplicationManager>();
Maybe there is a better way of doing this, but this worked for me. It would be nice if there was some more easier to find examples with more complex scenarios.

How to configure Swashbuckle to ignore property on model for a specific api version only

I needed to add a property to a model and I have implemented what is suggested in the selected answer here and it's working in that it removes the property I have tagged with SwaggerIgnorePropertyAttribute attribute of the model.
My question is , if I have several API versions of my application,how to remove it from the swagger doc/schema for certain versions? I only care not to see the added property in swagger. I only really have 1 version of the model across the application even though I have several version of the app.
I added the version like this:
services.AddSwaggerGen(
swaggerOptions =>
{
swaggerOptions.SwaggerDoc(
"v1",
new Info
{
Title = "Titlebla1",
Description = "bla1",
Version = "v1"
});
swaggerOptions.SwaggerDoc(
"v2",
new Info
{
Title = "Titlebla2",
Description = "bla2",
Version = "v2"
});
etc
I know that I can find the version by using SwaggerDocument and doing something like this: swaggerDoc.Info.Version but how can I get access to SwaggerDocument from the Apply in my SwaggerExcludePropertySchemaFilter class below ?
private class SwaggerExcludePropertySchemaFilter : ISchemaFilter
{
public void Apply(Schema schema, SchemaFilterContext context)
{
if (schema?.Properties == null)
{
return;
}
var excludedProperties = context.SystemType.GetProperties().Where(t => t.GetCustomAttribute<SwaggerIgnorePropertyAttribute>() != null);
foreach (var excludedProperty in excludedProperties)
{
var propertyToRemove = schema.Properties.Keys.SingleOrDefault(x => string.Equals(x, excludedProperty.Name, StringComparison.OrdinalIgnoreCase));
if (propertyToRemove != null)
{
schema.Properties.Remove(propertyToRemove);
}
}
}
}
Try use IDocumentFilter , see example:
public class CustomSwaggerFilter : IDocumentFilter
{
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
var nonRequiredMYPropertyAPIs = swaggerDoc.Paths
.Where(x => !x.Key.ToLower().Contains("v1") /*the version you want to remove the property */)
.ToList();
nonRequiredMYPropertyAPIs.ForEach(x => {
swaggerDoc.Components.Schemas["YOUR_CLASS_MODEL"]
.Properties.Remove("PROPERTY_NAME_YOU_WANT_TO_IGNORE");
});
}
}
Don't forget register the filter:
c.DocumentFilter<CustomSwaggerFilter>();

RequireAuthorization and Swashbuckle IOperationFilter

I am looking for a way to determine if endpoint requires authorization (.Net Core 3.1) using IOperationFilter.
If Authorization is setup via filter or explicitly as attribute, it can be found in OperationFilterContext context.ApiDescription.ActionDescriptor.FilterDescriptors.Select(filterInfo => filterInfo.Filter).Any(filter => filter is AuthorizeFilter) and context.ApiDescription.CustomAttributes().OfType<AuthorizeAttribute>().
But if authorization is set as
endpoints.MapControllers().RequireAuthorization();, which should add AuthorizationAttribute to all endpoints, it is not appeared neither in filters nor in attributes. Any thoughts on how to catch if auth is applied to endpoints in this case?
I was able to beat this today like so (swashbuckle 5.63):
Make a new class like this
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace YourNameSpace
{
public class SwaggerGlobalAuthFilter : IOperationFilter
{
public void Apply( OpenApiOperation operation, OperationFilterContext context )
{
context.ApiDescription.TryGetMethodInfo( out MethodInfo methodInfo );
if ( methodInfo == null )
{
return;
}
var hasAllowAnonymousAttribute = false;
if ( methodInfo.MemberType == MemberTypes.Method )
{
// NOTE: Check the controller or the method itself has AllowAnonymousAttribute attribute
hasAllowAnonymousAttribute =
methodInfo.DeclaringType.GetCustomAttributes( true ).OfType<AllowAnonymousAttribute>().Any() ||
methodInfo.GetCustomAttributes( true ).OfType<AllowAnonymousAttribute>().Any();
}
if ( hasAllowAnonymousAttribute )
{
return;
}
// NOTE: This adds the "Padlock" icon to the endpoint in swagger,
// we can also pass through the names of the policies in the List<string>()
// which will indicate which permission you require.
operation.Security = new List<OpenApiSecurityRequirement>
{
new OpenApiSecurityRequirement()
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "oauth2" // note this 'Id' matches the name 'oauth2' defined in the swagger extensions config section below
},
Scheme = "oauth2",
Name = "Bearer",
In = ParameterLocation.Header,
},
new List<string>()
}
}
};
}
}
}
In swagger config extensions
options.AddSecurityDefinition( "oauth2", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows
{
Implicit = new OpenApiOAuthFlow
{
//_swaggerSettings is a custom settings object of our own
AuthorizationUrl = new Uri( _swaggerSettings.AuthorizationUrl ),
Scopes = _swaggerSettings.Scopes
}
}
} );
options.OperationFilter<SwaggerGlobalAuthFilter>();
Put together from docs, other SO and decompiled code of built-in SecurityRequirementsOperationFilter
AFAIK, it is defining a global auth setup for all your routed endpoints except those that explicitly have AllowAnonymousAttribute on controller or endpoint. since, as your original question hints at, using the extension RequireAuthorization() when setting up routing implicitly puts that attribute on all endpoints and the built-in SecurityRequirementsOperationFilter which detect the Authorize attribute fails to pick it up. Since your routing setup effectively is putting Authorize on every controller/route it seems setting up a default global filter like this that excludes AllowAnonymous would be in line with what you are configuring in the pipeline.
I suspect there may be a more 'built-in' way of doing this, but I could not find it.
Apparently, this is an open issue on the NSwag repo as well (for people like me that drive by with the same issue, but with NSwag instead of Swashbuckle):
https://github.com/RicoSuter/NSwag/issues/2817
Where there's also another example of solving the issue (not only securityrequirement, but also its scopes).
I know it's been a long time since this question was asked.
But I was facing a similar issue, and following the advice from an issue in GitHub here, managed to resolve it using this implementation of IOperationFilter (and now works like a charm):
public class AuthorizeCheckOperationFilter : IOperationFilter
{
private readonly EndpointDataSource _endpointDataSource;
public AuthorizeCheckOperationFilter(EndpointDataSource endpointDataSource)
{
_endpointDataSource = endpointDataSource;
}
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var descriptor = _endpointDataSource.Endpoints.FirstOrDefault(x =>
x.Metadata.GetMetadata<ControllerActionDescriptor>() == context.ApiDescription.ActionDescriptor);
var hasAuthorize = descriptor.Metadata.GetMetadata<AuthorizeAttribute>()!=null;
var allowAnon = descriptor.Metadata.GetMetadata<AllowAnonymousAttribute>() != null;
if (!hasAuthorize || allowAnon) return;
operation.Responses.Add("401", new OpenApiResponse { Description = "Unauthorized" });
operation.Responses.Add("403", new OpenApiResponse { Description = "Forbidden" });
operation.Security = new List<OpenApiSecurityRequirement>
{
new()
{
[
new OpenApiSecurityScheme {Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "oauth2"}
}
] = new[] {"api1"}
}
};
}
}
The issue stated this:
ControllerActionDescriptor.EndpointMetadata only reflects the metadata
discovered on the controller action. Any metadata configured via the
endpoint APIs do not show up here. It was primarily the reason we
documented it as being infrastructure-only since it's a bit confusing
to use.
There's a couple of options you could use
a) You could decorate your controllers using [Authorize]. That should allow the metadata to show up in the property.
b) You could look up the metadata by reading from EndpointDataSource.

'Missing type map configuration or unsupported mapping.' Automapperexception in ASP.NET Core WebApi

I'm trying to add AutoMapper to a API (built using ASP.NET Core 3) but it gives me the 'Missing type map configuration or unsupported mapping.'-exception and my google-searches doesn't help me at all... :).
The exception is thrown (as described below) in "GetAllObject1"-method
This is my current setup:
[HttpGet]
public IActionResult GetAllObject1()
{
var object1Items = _myService.GetAllObject1();
Object1ViewModel ouViewModel = _mapper.Map<Object1ViewModel>(Object1Items); // <= This line gives the exception above!!
return Ok(ouViewModel);
}
"AutoMapping.cs":
namespace DataAccess.AutoMapper
{
public class AutoMapping : Profile
{
public AutoMapping()
{
CreateMap < KollOrganizationalUnit, KollOrganizationalUnitViewModel>();
}
}
}
"Startup.cs":
public virtual void ConfigureServices(IServiceCollection services)
{
if (services == null)
throw new ArgumentNullException(nameof(services));
services.AddMvc()
.SetCompatibilityVersion(CompatibilityVersion.Version_3_0);
services.AddControllers()
.AddNewtonsoftJson(options =>
{
options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
})
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.IgnoreNullValues = true;
});
services.AddDbContext<RepositoryContext>(opts => opts.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
services.RegisterDAL();
services.RegisterBizServices();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo
{
Version = "v1",
Title = "API",
Description = "Integration API for XXX",
TermsOfService = new Uri("https://www.xxx.se/terms-of-service"),
Contact = new OpenApiContact()
{
Name = "Integrationcontact",
Email = "integration#xxx.se",
Url = new Uri("https://www.xxx.se")
},
});
});
services.AddAutoMapper(typeof(Startup).Assembly);
}
Am I missing something obious here?
It's hard to say without knowing the return type of _myService.GetAllObject1(), but it seems like it's returning a collection of Object1Unit. If that's the case, you can try something like:
IEnumerable<Object1ViewModel> ouViewModels = Object1Items.Select(x => _mapper.Map<Object1ViewModel>(x));
return Ok(ouViewModels);
If you're trying to map a collection to a single item, you have to tell AutoMapper how to do that or you will get the exception you're seeing (your AutoMapping class only creates a map from a single item to a single item).

Can I get a the RouteTemplate from AspNetCore FilterContext?

In AspNetCore, given a FilterContext, I'm looking to get a route template e.g.
{controller}/{action}/{id?}
In Microsoft.AspNet.WebApi I could get the route template from:
HttpControllerContext.RouteData.Route.RouteTemplate
In System.Web.Mvc I could get this from:
ControllerContext.RouteData.Route as RouteBase
In AspNetCore there is:
FilterContext.ActionDescriptor.AttributeRouteInfo.Template
However, not all routes are attribute routes.
Based on inspection if the attribute is not available, default routes and/or mapped routes can be assembled from:
FilterContext.RouteData.Routers.OfType<Microsoft.AspNetCore.Routing.RouteBase>().First()
but I'm looking for a documented or a simply better approach.
Update (24 Jan 2021)
There is a much much simpler way of retrieving the RoutePattern directly via the HttpContext.
FilterContext filterContext;
var endpoint = filterContext.HttpContext.GetEndpoint() as RouteEndpoint;
var template = endpoint?.RoutePattern?.RawText;
if (template is null)
throw new Exception("No route template found, that's absurd");
Console.WriteLine(template);
GetEndpoint() is an extension method provided in EndpointHttpContextExtensions class inside Microsoft.AspNetCore.Http namespace
Old Answer (Too much work)
All the route builders for an ASP.NET Core app (at least for 3.1) are exposed and registered via IEndpointRouteBuilder, but unfortunately, this is not registered with the DI container, so you can't acquire it directly.The only places where I have seen this interface being exposed, are in the middlewares.
So you can build a collection or dictionary out of one of those middlewares, and then use that for your purposes.
e.g
Program.cs
Extension class to build your endpoint collection / dictionary
internal static class IEndpointRouteBuilderExtensions
{
internal static void BuildMap(this IEndpointRouteBuilder endpoints)
{
foreach (var item in endpoints.DataSources)
foreach (RouteEndpoint endpoint in item.Endpoints)
{
/* This is needed for controllers with overloaded actions
* Use the RoutePattern.Parameters here
* to generate a unique display name for the route
* instead of this list hack
*/
if (Program.RouteTemplateMap.TryGetValue(endpoint.DisplayName, out var overloadedRoutes))
overloadedRoutes.Add(endpoint.RoutePattern.RawText);
else
Program.RouteTemplateMap.Add(endpoint.DisplayName, new List<string>() { endpoint.RoutePattern.RawText });
}
}
}
public class Program
{
internal static readonly Dictionary<string, List<string>> RouteTemplateMap = new Dictionary<string, List<string>>();
/* Rest of things */
}
Startup.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
/* all other middlewares */
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
//Use this at the last middlware exposing IEndpointRouteBuilder so that all the routes are built by this point
endpoints.BuildMap();
});
}
And then you can use that Dictionary or Collection, to retrieve the Route Template from the FilterContext.
FilterContext filterContext;
Program.RouteTemplateMap.TryGetValue(filterContext.ActionDescriptor.DisplayName, out var template);
if (template is null)
throw new Exception("No route template found, that's absurd");
/* Use the ActionDescriptor.Parameters here
* to figure out which overloaded action was called exactly */
Console.WriteLine(string.Join('\n', template));
To tackle the case of overloaded actions, a list of strings is used for route template (instead of just a string in the Dictionary)
You can use the ActionDescriptor.Parameters in conjunction with RoutePattern.Parameters to generate a unique display name for that route.
These are the assembled versions, but still looking for a better answer.
AspNetCore 2.0
FilterContext context;
string routeTemplate = context.ActionDescriptor.AttributeRouteInfo?.Template;
if (routeTemplate == null)
{
// manually mapped routes or default routes
// todo is there a better way, not 100% sure that this is correct either
// https://github.com/aspnet/Routing/blob/1b0258ab8fccff1306e350fd036d05c3110bbc8e/src/Microsoft.AspNetCore.Routing/Template/TemplatePart.cs
IEnumerable<string> segments = context.RouteData.Routers.OfType<Microsoft.AspNetCore.Routing.RouteBase>()
.FirstOrDefault()?.ParsedTemplate.Segments.Select(s => string.Join(string.Empty, s.Parts
.Select(p => p.IsParameter ? $"{{{(p.IsCatchAll ? "*" : string.Empty)}{p.Name}{(p.IsOptional ? "?" : string.Empty)}}}" : p.Text)));
if (segments != null)
{
routeTemplate = string.Join("/", segments);
}
}
AspNetCore 3.0 with Endpoint Routing
RoutePattern routePattern = null;
var endpointFeature = context.HttpContext.Features[typeof(Microsoft.AspNetCore.Http.Features.IEndpointFeature)]
as Microsoft.AspNetCore.Http.Features.IEndpointFeature;
var endpoint = endpointFeature?.Endpoint;
if (endpoint != null)
{
routePattern = (endpoint as RouteEndpoint)?.RoutePattern;
}
string formatRoutePart(RoutePatternPart part)
{
if (part.IsParameter)
{
RoutePatternParameterPart p = (RoutePatternParameterPart)part;
return $"{{{(p.IsCatchAll ? "*" : string.Empty)}{p.Name}{(p.IsSeparator ? " ? " : string.Empty)}}}";
}
else if (part.IsLiteral)
{
RoutePatternLiteralPart p = (RoutePatternLiteralPart)part;
return p.Content;
}
else if(part.IsSeparator)
{
RoutePatternSeparatorPart p = (RoutePatternSeparatorPart)part;
return p.Content;
}
else
{
throw new NotSupportedException("Unknown Route PatterPart");
}
}
if (routePattern != null)
{
// https://github.com/aspnet/Routing/blob/1b0258ab8fccff1306e350fd036d05c3110bbc8e/src/Microsoft.AspNetCore.Routing/Template/TemplatePart.cs
routeString = string.Join("/", routePattern.PathSegments.SelectMany(s => s.Parts).Select(p => formatRoutePart(p)));
}