-
Notifications
You must be signed in to change notification settings - Fork 714
Have namespaces always been ignored when creating api docs? #1097
-
Hi. I put this here as I'm fairly sure it isn't a bug. We recently updated to .NET8 and also to AspNet-Api-Versioning. But now our docs don't build. I get an error telling me that a key, "Providers", has already been added to the dictionary. Tracking this down seems to lead me to the [ApiExplorerSettings(GroupName = $"Providers_v2")]
and the GroupName. To distinguish between versions I added _v2
as a test and now my docs build correctly. In the previous version we didn't need to do this. But I came across this closed issue where it states namespaces are ignored; #1084
So I was wondering if something had changed between versions?
Beta Was this translation helpful? Give feedback.
All reactions
Replies: 2 comments 7 replies
-
OpenAPI (formerly Swagger) never cared about namespaces because that is a .NET language concept which doesn't necessarily translate to other languages. If you have something like Sales.Order
and Purchase.Order
, in the same OpenAPI document, the name Order
will collide. This doesn't happen often, but it can.
[ApiExplorerSettings]
only sort of plays a role here. By default, the ApiDescription.GroupName
will be based on the formatted API version. This collates APIs by version, which is what most people want. Futhermore, it doesn't require you to put [ApiExplorerSettings]
everywhere, which can also be painful when things update. There are edge cases where you might want to the GroupName
, however. In years gone by, there was a time when you would have had to do the manipulation yourself, but it is now baked in with a feature.
The rules are as follows (in order):
- If not otherwise specified,
ApiDescription.GroupName
will be a formattedApiVersion
according toApiExplorerOptions.GroupNameFormat
- If
[ApiExplorerSettings(GroupName="")]
is set (alone), it is honored as is, which can potentially lead to collisions; especially across versions - If
[ApiExplorerSettings(GroupName="")]
andApiExplorerOptions.FormatGroupName
is specified, you can define a callback that determines what the combination ofApiExplorerSettings.GroupName
and the formattedApiVersion
should be.- ex:
options.FormatGroupName = (group, version) => $"{group}_{version}";
- ex:
OpenAPI and the Swagger UI do not support more than one level of grouping out-of-the-box. This is the closest you could ever get to it with the default tooling unless you change quite a number of things. If you group was [ApiExplorerSettings(GroupName = "Providers")]
, the ApiVersion
is 2.0
, GroupNameFormat = "'v'VVV"
, and FormatGroupName = (group, version) => $"{group}_{version}"
, then the resultant string for the group in OpenAPI and its document will be Providers_v2
.
This feature is documented in the wiki and was introduced in 6.2
.
Beta Was this translation helpful? Give feedback.
All reactions
-
Here is a link to our current docs based on .NET 6: docs This is still using the Microsoft.AspNetCore.Mvc.Versioning packages for versioning.
After updating to .NET 8 and updating all the associated packages this no longer works. I get the exception about the dictionary key, the GroupName, already existing. I have been able to get it to work doing something similar to what you suggested by appending the apiVersion to the GroupName however we'd like to avoid that if at all possible. Something somewhere has changed but I have yet to figure it out.
I also tried grouping by namespace but that was just confusing.
Beta Was this translation helpful? Give feedback.
All reactions
-
Every version of API Versioning has affinity to ASP.NET Core and its .NET TFM. Microsoft.AspNetCore.Mvc.Versioning
targeted .NET 5 and was not officially supported for .NET 6; however, I'm glad it worked for you.
In thinking more about the problem you are describing, there is something else happening. At least from API Versioning, there is no Dictionary<String, ?>
that is based on GroupName
. The results of the IApiDescriptionProvider
produces an IList<ApiDescription>
. Each ApiDescription
has a GroupName
. They definitely will not be unique. Someone could turn that into a Dictionary<String, IReadOnlyList<ApiDescription>>
based on the group name. Something like:
let dictionary = provider.Items .GroupName(api => api.GroupName) .ToDictionary(group => group.Key, group => group.ToArray());
This still wouldn't produce a duplicate key on GroupName
. I'm not really sure where this dictionary you are referring to comes from. If there's anything else you can share about your configuration, customization, any snippets of source, or even an exception stack trace, that would be useful to help you troubleshoot the source.
Beta Was this translation helpful? Give feedback.
All reactions
-
here is the full exception:
[08:10:04 ERR] Connection id "0HN4HUDRN3VJK", Request id "0HN4HUDRN3VJK:00000001": An unhandled exception was thrown by the application.
System.ArgumentException: An item with the same key has already been added. Key: MDS
at System.Collections.Generic.Dictionary2.TryInsert(TKey key, TValue value, InsertionBehavior behavior) at System.Collections.Generic.Dictionary
2.Add(TKey key, TValue value)
at Microsoft.Extensions.DependencyInjection.SwaggerGenOptionsExtensions.SwaggerDoc(SwaggerGenOptions swaggerGenOptions, String name, OpenApiInfo info)
at SimpleLtc.Api.Configuration.SwaggerConfigurationOptions.Configure(SwaggerGenOptions options) in SimpleLTC.API\Configuration\SwaggerConfigurationOptions.cs:line 25
at Microsoft.Extensions.Options.OptionsFactory1.Create(String name) at Microsoft.Extensions.Options.UnnamedOptionsManager
1.get_Value()
at Swashbuckle.AspNetCore.SwaggerGen.ConfigureSwaggerGeneratorOptions..ctor(IOptions1 swaggerGenOptionsAccessor, IServiceProvider serviceProvider, IWebHostEnvironment hostingEnv) at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) at System.Reflection.MethodBaseInvoker.InvokeDirectByRefWithFewArgs(Object obj, Span
1 copyOfArgs, BindingFlags invokeAttr)
at System.Reflection.MethodBaseInvoker.InvokeWithFewArgs(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
at System.Reflection.RuntimeConstructorInfo.Invoke(BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument) at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitDisposeCache(ServiceCallSite transientCallSite, RuntimeResolverContext context) at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor
2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitIEnumerable(IEnumerableCallSite enumerableCallSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument) at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor
2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument) at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitDisposeCache(ServiceCallSite transientCallSite, RuntimeResolverContext context) at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor
2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument) at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitRootCache(ServiceCallSite callSite, RuntimeResolverContext context) at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor
2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.Resolve(ServiceCallSite callSite, ServiceProviderEngineScope scope)
at Microsoft.Extensions.DependencyInjection.ServiceProvider.CreateServiceAccessor(ServiceIdentifier serviceIdentifier)
at System.Collections.Concurrent.ConcurrentDictionary2.GetOrAdd(TKey key, Func
2 valueFactory)
at Microsoft.Extensions.DependencyInjection.ServiceProvider.GetService(ServiceIdentifier serviceIdentifier, ServiceProviderEngineScope serviceProviderEngineScope)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope.GetService(Type serviceType)
at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(IServiceProvider provider, Type serviceType)
at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService[T](IServiceProvider provider)
at Microsoft.Extensions.DependencyInjection.SwaggerGenServiceCollectionExtensions.<>c.b__0_1(IServiceProvider s)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument) at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitDisposeCache(ServiceCallSite transientCallSite, RuntimeResolverContext context) at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor
2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument) at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitDisposeCache(ServiceCallSite transientCallSite, RuntimeResolverContext context) at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor
2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.Resolve(ServiceCallSite callSite, ServiceProviderEngineScope scope)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.DynamicServiceProviderEngine.<>c__DisplayClass2_0.b__0(ServiceProviderEngineScope scope)
at Microsoft.Extensions.DependencyInjection.ServiceProvider.GetService(ServiceIdentifier serviceIdentifier, ServiceProviderEngineScope serviceProviderEngineScope)
at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope.GetService(Type serviceType)
at lambda_method4(Closure, Object, HttpContext, IServiceProvider)
at Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware.Invoke(HttpContext context)
at Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware.TryServeStaticFile(HttpContext context, String contentType, PathString subPath)
at Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware.Invoke(HttpContext context)
at Microsoft.AspNetCore.StaticFiles.DefaultFilesMiddleware.Invoke(HttpContext context)
at Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddlewareImpl.Invoke(HttpContext context)
--- End of stack trace from previous location ---
at Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddlewareImpl.HandleException(HttpContext context, ExceptionDispatchInfo edi)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol.ProcessRequests[TContext](IHttpApplication`1 application)
[08:10:04 INF] Request finished HTTP/2 GET https://localhost:6001/docs/v2/index.html - 500 0 null 53.1798ms
Beta Was this translation helpful? Give feedback.
All reactions
-
line 25 referenced in the exception is the options.SwaggerDoc(...)
line:
public void Configure(SwaggerGenOptions options)
{
var description = File.ReadAllText("./static/description.md");
foreach (var desc in _provider.ApiVersionDescriptions)
{
options.SwaggerDoc($"{desc.GroupName}", new Microsoft.OpenApi.Models.OpenApiInfo
{
Title = "SimpleLTC - API Spec",
Version = desc.GroupName,
Description = description
});
}
options.DocInclusionPredicate((docName, apiDesc) =>
{
if (!apiDesc.TryGetMethodInfo(out MethodInfo methodInfo)) return false;
var versions = methodInfo.DeclaringType
.GetCustomAttributes(true)
.OfType<ApiVersionAttribute>()
.SelectMany(attr => attr.Versions);
var mappedVersions = methodInfo.GetCustomAttributes(true)
.OfType<MapToApiVersionAttribute>()
.SelectMany(attr => attr.Versions);
// For controllers with more than one API version mapping, match on MapToApiVersionAttribute version
if (mappedVersions.Count() > 0)
return mappedVersions.Any(v => $"v{v}" == docName);
return versions.Any(v => $"v{v}" == docName);
});
options.TagActionsBy(api =>
{
if (api.GroupName != null)
{
return new[] { api.GroupName };
}
var controllerActionDescriptor = api.ActionDescriptor as ControllerActionDescriptor;
if (controllerActionDescriptor != null)
{
return new[] { controllerActionDescriptor.ControllerName };
}
throw new InvalidOperationException("Unable to determine tag for endpoint.");
});
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var filePath = Path.Combine(AppContext.BaseDirectory, xmlFile);
options.IncludeXmlComments(filePath);
}
Beta Was this translation helpful? Give feedback.
All reactions
-
This image is from stepping through the code with a breakpoint on line 25 in NET 8
image
This is the same from NET 6
image
It's as if the definition of GroupName
changed.
Beta Was this translation helpful? Give feedback.
All reactions
-
Ok. I think I know what's going on here. I don't know how GroupName
was ever used. If it is/was, you have not shown that. Somewhere, you clearly have [ApiExplorerSettings(GroupName = "MDS")]
or you've set the metadata some other way. In earlier versions of API Versioning (e.g. < 6.2
), this value would have been completely ignored. Anything that might have set it in ApiDescription.GroupName
would be overwritten with the formatted API version. This is why the old version shows "v1"
instead. If you want the only behavior, just get rid of [ApiExplorerSettings(GroupName = "MDS")]
. It doesn't appear to play a factor anyway.
The IApiVersionDescriptionProvider
collates a unique set of descriptions for API versions, but it does not guarantee uniqueness on GroupName
. If you apply [ApiExplorerSettings(GroupName = "MDS")]
on versions one and two, you'll produce the combinations: ("MDS", 1.0)
and ("MDS", 2.0)
. This is, in fact, a distinct set of described API versions. This isn't going to map to Swashbuckle because the options require a unique list of group names and they have to match up the OpenAPI document URL. If you hadn't used ApiExplorerSettings
, then the set would have been ("v1", 1.0)
and ("v2", 2.0)
. You're in control. If you want to set ApiExplorerSettings.GroupName
, API Versioning - now - honors it, but it might not be what you want or expect. This is precisely why the default was always just the formatted API version as it wouldn't have worked otherwise. There's a 3rd combination if you want both because this can lead to problem you are facing and it was quite a bit of work for people to combine them on their own. It doesn't sound like that's what you want though. Unless I've missed something else, I would recommend getting rid of [ApiExplorerSettings]
.
I'm not sure what you are trying to achieve with DocInclusionPredicate
and TagActionsBy
, but that is completely unnecessary. The API Explorer extensions do all of that work for you. Furthermore, if you want to retrieve the versioning metadata about an ApiDescription
, it is already provided for you. Reflection is the worst way to go about that and it's also incorrect. The metadata isn't limited to attributes, even that may hold true for your use case. There are a number of convenient extension methods on ApiDescription
or if you want all of the metadata, you can access it via ApiDescription.ActionDescriptor.GetApiVersionMetadata()
.
I would encourage you to review the OpenAPI example project and, in particular, the ConfigureSwaggerOptions.
Beta Was this translation helpful? Give feedback.
All reactions
-
So originally the docs looked like this:
image
Removing the [ApiExplorerSettings(GroupName = "MDS")]
, which as you correctly surmised is decorating one of our controllers results in this:
image
We've lost the name grouping which is what we are trying to achieve. However, it sounds like we got what we wanted by happy chance rather than design.
Thank you so much for your time and help.
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 1
-
Since you aren't using Swagger UI, it is still possible to transform the descriptions into another form. The default would by version and then API, but it seems you want to by group, version, and then API. This can be achieved with the GroupName
if you turn enough knobs, but you'll still have to due your own bucketization. The other options, which would have few landmines, is to define your own metadata that you look up later. For example:
[AttributeUsage(AttributeTargets.Class)] public sealed class ApiGroupAttribute(string name) : Attribute { public string Name => name; }
Then you can apply it with:
[ApiVersion(1.0)] [ApiGroup("MDS")] public class MdsController : ControllerBase { }
Attributes are automatically collected by the ApplicationModeProvider
and should be available in Endpoint.Metadata
. If not, you might need a ControllerModelProvider
to copy it over.
Using all of that, you should be able to get the level of grouping you want. You just need to make sure that document name matches up.
Beta Was this translation helpful? Give feedback.