Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Have namespaces always been ignored when creating api docs? #1097

Unanswered
nathanjwtx asked this question in Q&A
Discussion options

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?

You must be logged in to vote

Replies: 2 comments · 7 replies

Comment options

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):

  1. If not otherwise specified, ApiDescription.GroupName will be a formatted ApiVersion according to ApiExplorerOptions.GroupNameFormat
  2. If [ApiExplorerSettings(GroupName="")] is set (alone), it is honored as is, which can potentially lead to collisions; especially across versions
  3. If [ApiExplorerSettings(GroupName="")] and ApiExplorerOptions.FormatGroupName is specified, you can define a callback that determines what the combination of ApiExplorerSettings.GroupName and the formatted ApiVersion should be.
    • ex: options.FormatGroupName = (group, version) => $"{group}_{version}";

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.

You must be logged in to vote
5 replies
@nathanjwtx
Comment options

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.

@commonsensesoftware
Comment options

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.

@nathanjwtx
Comment options

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.Dictionary2.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.UnnamedOptionsManager1.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, Span1 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.CallSiteVisitor2.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.CallSiteVisitor2.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.CallSiteVisitor2.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.CallSiteVisitor2.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, Func2 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.CallSiteVisitor2.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.CallSiteVisitor2.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

@nathanjwtx
Comment options

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);
        }
@nathanjwtx
Comment options

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.

Comment options

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.

You must be logged in to vote
2 replies
@nathanjwtx
Comment options

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.

@commonsensesoftware
Comment options

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Category
🙏
Q&A
Labels
None yet
2 participants
Morty Proxy This is a proxified and sanitized view of the page, visit original site.