Skip to content

How to support multiple swagger files per version/group? #516

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
mattmazzola opened this issue Jul 12, 2019 · 53 comments
Closed

How to support multiple swagger files per version/group? #516

mattmazzola opened this issue Jul 12, 2019 · 53 comments

Comments

@mattmazzola
Copy link

mattmazzola commented Jul 12, 2019

This is a question about best practices to achieve the multi swagger generation per version rather than specific issue with library. Hope someone can help or suggest a work around.

Background:

We have existing Asp.net core service that was using hard coded url versioning such as [Route("api/v1/app")]. We also have certain controller actions separated into groups to generate two different swagger files for Azure APIM. These different operations needed to be billed and managed differently (throttling, quota, etc). We did this by using the group name:

[ApiExplorerSettings(GroupName = "authoring")]
Controller1

[ApiExplorerSettings(GroupName = "session")]
Controller2

and then manually generating matching swagger files

c.SwaggerEndpoint("/swagger/authoring/swagger.json", ... )
c.SwaggerEndpoint("/swagger/session/swagger.json", ... )

Intention

We wanted to add a new version (2.0) to the app to change the urls to more closely align with the Microsoft REST guidelines and wanted to set it up in a scalable way based on the sample https://github.com/microsoft/aspnet-api-versioning/tree/master/samples/aspnetcore/SwaggerSample
this is involved changes to AddApiVersioning, AddVersionedApiExplorer, and most notably how the swagger files are generated.

We wanted to preserve the separate of authoring and session operates and hoped to be able to generate two files per version descriptor such as:

foreach (var description in provider.ApiVersionDescriptions)
{
    c.SwaggerEndpoint($"/swagger/authoring-{description.GroupName}/swagger.json", description.GroupName.ToUpperInvariant());
    c.SwaggerEndpoint($"/swagger/session-{description.GroupName}/swagger.json", description.GroupName.ToUpperInvariant());
}

to achieve something like this:

/v1
  /authoring/swagger.json
  /sesssion/swagger.json
/v2
  /authoring/swagger.json
  /session/swagger.json

However there seems to be catch-22 situation. It seems Swagger/Swashbuckle was using group name to associate operations and generate files. If we change group name to be generic ''v'VVV" it means we can no longer use the split the actions/operations between the groups using the [ApiExplorerSettings(GroupName = "session")] attribute since it is one group per version. If we keep the specific groups then we can't generate them dynamically from the version descriptors because the group names ("authoring" and "session" won't map to the versions "v1" and "v2" etc.

Our thought was maybe there is some hack like making the version name include a slash such as
v{version}/authoring and v{version}/session but that would increase complexity and put us off standard sample for people to help us.

Our only current idea is to go to generic versioning with single swagger per version and sepearate the authoring vs session groups by moving it to a new .net core project. That is a much larger work than we thought as we have to re-architect to share resources across them and thus creating this issue asking for help.

@commonsensesoftware
Copy link
Collaborator

commonsensesoftware commented Jul 14, 2019

Most Swagger generations, at least all the ones I've seen, provide a single hierarchy in the user interface. In ASP.NET Core, this is typically driven by the GroupName. The API Versioning extensions always group by API version, which is typically what service authors want.

In your particular case, you have two hierarchical levels. This cannot be achieved directly though the GroupName alone; you need two properties/discriminators. To get your desired result, you've to customize things on the Swashbuckle side; specifically, the user interface. It should be simple enough to group and build endpoint URLs by API version and billing name, but you'll need two dropdown lists to drive it.

To minimize the work, I would consider moving your billing group name to a custom attribute that you can retrieve by away of an extension method. Something like:

[ApiVersion("1.0")]
[BillingGroup("authoring")]
public class Controller1 : ControllerBase
{
}

Of course, you don't really need to have split the Swagger documents up this way unless you're trying to reduce the size of the documents or focus in on relevance. If you just want to filter by relevance, it might be easier to just tweak the client-side scripts to support filtering API sets. They'll already be grouped by API version.

It's possible for you to flat the list down to API Version + Billing Group in the GroupName, but you'll need a custom IActionDescriptionProvider to do it. I would still recommend using a custom attribute, but then it should be easy to combine the two parts into a single value the way you want.

It should be possible to use a custom Swagger document generator or operation filter in Swashbuckle to achieve a similar result.

I hope that gives you a few ideas.

@mattmazzola
Copy link
Author

mattmazzola commented Jul 15, 2019

Thanks for replying. It does give us some ideas although we're not quite sure how to take action on them. It sounds like we would need to customize the generation of Swagger files through modifying Swashbuckle and I'm not familiar with that process.

I will try to rewrite what you suggested to make sure I understand and ask questions about parts. There were two main parts I was confused about.

you don't really need to have split the Swagger documents up this way unless you're trying to reduce the size of the documents or focus in on relevance.

I will have to look into this more. It was my understanding we associated a single unfiltered swagger doc per APIM endpoint. (E.g. authoring endpoint was 1 swagger.json and session endpoint was another swagger.json) This was the driving factor for needing separate documents. (We have 2 per version) And with addition of new version we would have 4.

Are you saying it's possible for us to only generate 1 document per version (instead of sub split by author and session) and then somehow filter on APIM so it only sees a subset of the operations?

On the suggestion of customizing swashbuckle:

To get your desired result, you've to customize things on the Swashbuckle side; specifically, the user interface. It should be simple enough to group and build endpoint URLs by API version and billing name, but you'll need two dropdown lists to drive it.

That was the intention to end up with 4 swagger files. (v1 / authoring, v1 / session, v2 / authoring, v2 / session, etc) If the above question about filtering is possible then maybe this isn't needed.

If we do need to generate individual files then yes, I think we would need 2 dropdowns (1 per hierarchy). There would be a top level dropdown for version which exists now. Which would map to the ApiVersion / GroupName as those would be in alignment.

The second drop down would be for the billing group of authoring and session.

How would we go about modifying Swashbuckle to have these 2 dropdowns using our second custom attribute?
Is there an extension method we can over ride? This is more likely for us to try.
Or do we have to modify their source something more challenging? We wouldn't want to be on a special fork of library.

@commonsensesoftware
Copy link
Collaborator

To clarify, when you use the API Explorer extensions provided by API versioning, you would end up with a Swagger document per API version that contains all applicable APIs. For example:

  • swagger/v1/swagger.json
    • api/sessions
    • api/authoring
  • swagger/v2/swagger.json
    • api/sessions
    • api/authoring

I've had people ask me about having a Swagger document per resource (e.g. group) and then list all the different versions in the document. APIs that follow the REST constraints aren't able to fit into this model because Swagger does not allow duplicate URLs. Since you're versioning by URL segment (the least RESTful method), it's technically possible for this work, but it don't think it would go far in helping clients review and test out APIs since you'd see the same name multiple times or names like Sessions 1.0 and Sessions 2.0 (unless you don't mind that).

I don't profess to be an expert in Swagger (the doc or UI) nor any of the numerous frameworks that exist out there. I have some familiarity with Swashbuckle and its author. The type of advanced customization you seem to be describing isn't unheard of. If you were just trying to put up simple documentation endpoint for your internal services, I could see the resistance to investing in extensive customization. It seems you're trying to document a monetizable product, which may warrant the investment. The Swagger UI is meant to serve the 80%, but it sounds like you might be in the 20%. It's completely up to you whether you want to live within its intrinsic, out-of-the-box limitations or extend it, which may involve forking the UI. You might want to review the Swagger UI documentation for more information. Some folks within Microsoft built their own UI so that's always an option for you too.

@mattmazzola
Copy link
Author

I've had people ask me about having a Swagger document per resource (e.g. group) and then list all the different versions in the document.

In other words, having a hierarchy of group -> version as opposed to version -> group. We considered this but agree Version as top level is better for us. It's similar to your list above with the second level of hierarchy.

Version -> Group

/v1 (Version)
  /authoring (Group)  (swagger/v1/authoring/swagger.json)
    /endpointA (Controller Action)
    ...
  /session (swagger/v1/session/swagger.json)
   /endpointB
   ...

/v2
  /authoring (swagger/v2/authoring/swagger.json)
    /endpointA
    ...
  /session (swagger/v2/session/swagger.json)
    /endpointB
    ...

you'd see the same name multiple times or names like Sessions 1.0 and Sessions 2.0

I didn't understand what scenario or conditions would make this occur. I assumed thee would be v1 and v2 swagger docs and both have an session group / endpoint operation. I thought this was expected.

which may involve forking the UI

Yes, we will probably avoid that as maintaining the changes as the origin updates is a too much for our small team. Also, I think we're more concern about the generation of the files and not simply being able view them in the UI. Based on previous discussion in thread the services.AddSwaggerGen only generates 1 per group / version and would not understand to generate based on the separate custom header you proposed as BillingGroup. Since we would also need to fork this then it's definitely too much.

@commonsensesoftware
Copy link
Collaborator

"I didn't understand what scenario or conditions would make this occur"

This would happen in the group->version scenario because you'd have the same routes per group, but grouped by version.

Since changing the out-of-the-box experience is not an option for you, I really can only think of two recommendations.

Option 1

Use it as is to produce the version->group layout. There will be a single Swagger document per API version. You didn't say why this wouldn't work for you, but all things considered, it's the simplest and lowest effort to implement.

Option 2

Create and register a custom IActionDescriptionProvider that runs after API Versioning. In the OnExecuted method, you'll be at the end with everything collated by API version. At this point, you should reorder everything and bucketize based on API Version + Billing Group. This is place where your custom [BillingGroup] attribute can come into play. You can extract the value from this attribute and concatenate it with the API version. If your provider injects IOptions<ApiExplorerOptions>, then you'll have access to the configured formatting options - if you care; otherwise, you can format things inline. The ApiVersion implements IFormattable. The formatting codes and options are in the wiki.

The really tricky part is trying to filter the billing group. The IActionDescriptionProvider design doesn't really support this concept. I'm struggling to think of how you can make this work on the ASP.NET Core side. This is the kind of thing where a feature could be used based on the incoming request, but this API doesn't have a way to access the incoming request. That makes it very difficult to filter things. I think you'd have to do this filtering on the Swashbuckle side with a custom Swagger generator or something. I don't have an example of exactly how and where you do it, but it should be possible for the entire document or specific operations. You should be able to extend and replace the default implementation without a lot of effort. The only thing you're really doing is filtering things down.

With all of this in place, you'll end up with single Swagger dropdown that looks something like:

  • Authoring 1.0
  • Authoring 2.0
  • Sessions 1.0
  • Sessions 2.0

I don't know if that's what you really want, but those are the only two ways I can think of you can make this work without doing some significant customization.

@commonsensesoftware
Copy link
Collaborator

Did this help or otherwise answer your question?

@commonsensesoftware
Copy link
Collaborator

It appears that the suggestions provided helped you find a solution or you used another approach. If you weren't able to solve your issue, feel free to come back or reopen the issue. Thanks.

@mattmazzola
Copy link
Author

Sorry, I should have responding earlier. There were some changes with product that came above versioning which delayed investigation and also I kept waiting to find out more concrete answers from my co-worker about APIM and swagger, but I never did and thus didn't reply. I will just reply with what I know now.

We haven't solved the issued, but it's mostly because of inaction. We feel a bit stuck. It seems option 1 didn't solve the issue (at least how we interpreted it, details below) and option 2 was likely too much custom work which we're resistant to invest in since we don't know it will work. However, you can keep the issue closed for now until we get more clarity on swagger requirements and we're at a point to put more effort in.

Option 1

There seems to be confusion about the default implementation and what we want. We want the version -> group hierarchy (meaning each version has authoring and session groups within it as shown in sample URLs in my post above), however, in the default implementation there is no hierarchy.
In your explanation you associate Option 1 with "Use it as is" (default implementation), but using as is means the version is the group.
Out original intention was to use as is, but this results in authoring and session API's for the same version in the same swagger file.

swagger/v1/swagger.json

/v1/authoring/endpoint
/v1/session/endpoint

You didn't say why this wouldn't work for you

I meant to say it in the above post.

I will have to look into this more. It was my understanding we associated a single unfiltered swagger doc per APIM endpoint. (E.g. authoring endpoint was 1 swagger.json and session endpoint was another swagger.json) This was the driving factor for needing separate documents. (We have 2 per version) And with addition of new version we would have 4.

This was one of the pieces of information I was waiting on. If we can give APIM a swagger file and a filter on which operations from that file, this would work and remove need for separate files. I still need to confirm that this IS or IS NOT possible.

Option 2

This does produce what we want, but due to extra complexity we're not sure. If there was some sample we could compare and reference as we try that could work, but we haven't tried this. We were also worried about going non-standard and being compatible with other teams services so they could overlap the maintenance.

Hope that at least clears up the situation.

@commonsensesoftware
Copy link
Collaborator

No problem. Since I'm running this project alone, I keep the issues list well-pruned. After question-based issues go dark, I usually ping with a follow up and give people a week or so. After that I assume they either fixed their issue, found another solution, or abandoned things altogether.

If you only need a particular layout for APIM, then you might consider using the Swagger/OpenAPI library of choice and generating the *.json files yourself. The URL conventions are purely about serving the files directly from your own application. If your uploading the document to APIM, then you shouldn't have to worry about that. You can generate the documents however you like. A single document can contain one or more sets of APIs per version (which is the default behavior). There's nothing wrong with creating a single document per API, per version, but I believe you'd have to generate that yourself. A single document cannot contain multiple versions of an API unless you version by URL segment, which I never recommend.

By generating the documents yourself, you might have tool that outputs:

  • authoring.v1.json
  • authoring.v2.json
  • session.v1.json
  • session..v2.json

Each document contains a single API per version. I'm not super familiar with the details of APIM, but I presume each of these documents should be suitable for upload.

You could also customize a tool like Swashbuckle to serve up the same documents. The mapping ends up looking something Authoring 1.0 -> ~/authoring.v1.json. Grouping by name is not a requirement of a Swagger/OpenAPI document. The support provided in the API Explorer affords creating a group per logical name for an API. Unfortunately, it doesn't include multiple levels of grouping. When you introduce versioning, most cases need to be grouped by API version or the document would become invalid because you cannot have duplicate URLs. As I mentioned before, the only other way I can think of that would address that is combining the API name with the API version into a single level of grouping.

I hope that helps. Happy to continue providing whatever guidance you may need.

@tjmcnaboe
Copy link

I had a similar intent. This will not get you exactly what you want but might be close enough for your needs with a very minimal amount of effort.
In Startup.cs change options group format to this if it is not already. : options.GroupNameFormat = "'v'VVV";
The Api Version attribute forces you to use a int for the 1st and 2nd values however for the 3rd you can use alphanumeric so then you can do this:
[ApiVersion( "3.0.session" )]
[ApiVersion( "3.0.authoring" )]
[Route("api/v{version:apiVersion}/[controller]")]

which will produce a swagger document for each api per version and routes like this:
/api/v3-authoring/Orders/5
/api/v3-session/Orders/{id}

which is very close to what you prob want which is this api/session-v3

Hope that helps

@markgould
Copy link

I'm trying to do something similar and this looks like a great solution for us, thanks @tjmcnaboe!

@aherrick
Copy link

aherrick commented Apr 1, 2020

@tjmcnaboe nice solution!

@tjmcnaboe
Copy link

@tjmcnaboe nice solution!

Glad it was helpful. Thanks.

@ttkoma
Copy link

ttkoma commented Dec 11, 2020

@tjmcnaboe @mattmazzola
Full solution for issue

[ApiVersion( "3.0.session" )]
[ApiVersion( "3.0.authoring" )]
[Route("api/v{version:apiVersion}/[controller]")]

services.AddVersionedApiExplorer(opt =>
{
opt.GroupNameFormat = "S-'v'VV";
opt.SubstitutionFormat = "VV/S";
opt.SubstituteApiVersionInUrl = true;
});

Swagger doc definition:
"SESSION-v3.0 | AUTHORITY-v3.0"
Routes:
/api/v3.0/session/Orders/5
/api/v3.0/authoring/Orders/5

@mattmazzola
Copy link
Author

mattmazzola commented Dec 11, 2020

Cool, thanks ttkoma, had time to look at this again and believe I everything works as described except for the swagger file generation. The service runs and I can see the different API's exposed based on our alphanumeric status which is formatted based on the GroupNameFormat option. (I didn't know where was way to reference the status as 'S') can see the ApiVersions detected in the Swagger ui here:
image

Problem: Only generating first swagger file

As mentioned above, I'm still seeing problem where even though all Api are detected and formatted it's not generate the swagger.json for those API which we would need to submit to APIM and for the general Swagger UI to function correctly since it's relying on reading the .json response from the endpoint to generate the UI.

I've debugging both places I see it iterate through the Api version and both seem to be fine, but only the first authoring-v1.0 file is generated at http://localhost:37936/swagger/authoring-v1.0/swagger.json. None of these other files such as session-v1.0/swagger.json or prediction-v1.0.json.

The places I see are the loop in Startup.cs

 app.UseSwaggerUI(c =>
                {
                    // build a swagger endpoint for each discovered API version
                    foreach (var description in provider.ApiVersionDescriptions)
                    {
                        c.SwaggerEndpoint($"/swagger/{description.GroupName}/swagger.json", description.GroupName.ToUpperInvariant());
                    }
                });

And in ConfigureSwaggerOptions.cs

public void Configure(SwaggerGenOptions options)
        {
            var contact = new Contact
            {
                Name = "Team Name",
                Url = "https://github.com/org/project"
            };

            // add a swagger document for each discovered API version
            // note: you might choose to skip or document deprecated API versions differently
            foreach (var description in provider.ApiVersionDescriptions)
            {
                var apiInfo = new Info()
                {
                    Title = $"Product Name ${description.ApiVersion} API",
                    Version = description.ApiVersion.ToString(),
                    Contact = contact
                };

                options.SwaggerDoc(description.GroupName, apiInfo);
            }
        }

Given that I can debug see the options.SwaggerDoc(d) or c.SwaggerEndpoint(e) being run the correct amount of times, I would expect there to be the same amount of files generated or endpoints exposed, but it seems like a first wins scenarion and the later calls are dropped.

Any of you see this or know tips to work around it?

@mattmazzola
Copy link
Author

Ah, disregard, was some unrelated conflicts between classes and schemaIds for those classes preventing certain files from being generated. I should have read the error more closely. It seems only coincidence that it was the first option and threw me off.

I suppose this could be more common since as you include more and more classes in your solution you might be likely to have type name conflicts.

Option 1: Fullname

There is a simple solution which use full type name:
options.CustomSchemaIds(x => x.FullName);

https://stackoverflow.com/questions/46071513/swagger-error-conflicting-schemaids-duplicate-schemaids-detected-for-types-a-a

But that can get really long names, so there other solution to use name of attribute on the type

Option 2: Attribute for conflict [DisplayName("AlternateName")]

// Update Swagger generated names.
            options.CustomSchemaIds(type =>
            {
                // If the class has a "DisplayName" attribute, use that value.
                var obj = type.GetCustomAttributes(typeof(DisplayNameAttribute), false /* == don't inherit */).SingleOrDefault();
                if (obj != null)
                {
                    return ((DisplayNameAttribute)obj).DisplayName;
                }

                // Otherwise, fall back to default names.
                return type.Name;
            });

@borota
Copy link

borota commented Mar 17, 2021

@commonsensesoftware, following on your pointers, I created this IApiDescriptionProvider implementation. It combines [ApiExplorerSettings(GroupName = "GroupName")] with version grouping to achieve the desired result.
I am guessing you meant IApiDescriptionProvider not IActionDescriptionProvider above. Things seem to work nicely. I'd appreciate your feedback, if any concerns, etc.

   public class SubgroupDescriptionProvider : IApiDescriptionProvider {
       private readonly ApiExplorerOptions _options;
       public SubgroupDescriptionProvider(IOptions<ApiExplorerOptions> options) => _options = options.Value;
       public int Order => -1; // Execute after DefaultApiVersionDescriptionProvider.OnProvidersExecuted
       public void OnProvidersExecuting(ApiDescriptionProviderContext context) { }
       public void OnProvidersExecuted(ApiDescriptionProviderContext context) {
           var newResults = new List<ApiDescription>();
           foreach (var result in context.Results) {
               var apiVersion = result.GetApiVersion();
               var versionGroupName = apiVersion.ToString(_options.GroupNameFormat, CurrentCulture);
               if (result.GroupName == versionGroupName) {
                   continue; // This is a version grouping, nothing else to do
               }
               // Change [ApiExplorerSettings] group to show as subgroup of version grouping
               result.GroupName = $"{versionGroupName}{SubgroupSeparator}{result.GroupName}";
               // Add the missing version grouping as well
               var newResult = result.Clone();
               newResult.GroupName = versionGroupName;
               newResults.Add(newResult);
           }
           newResults.ForEach(newResult => context.Results.Add(newResult));
       }
   }
services.TryAddEnumerable(Transient<IApiDescriptionProvider, SubgroupDescriptionProvider >());

@borota
Copy link

borota commented Mar 17, 2021

So the above will create not only the version subgroups, but the version only groups as well.

@commonsensesoftware
Copy link
Collaborator

Seems reasonable to me. Glad you got it working.

@Code1110
Copy link

@borota, thanks for providing the example, this is exactly what I want to achieve. My problem is, that app.UseSwaggerUI and options.SwaggerDoc are called before the SubgroupDescriptionProvider, so the IApiVersionDescriptionProvider doesn't knows yet about the new GroupNames.

ConfigureServices:

services.AddApiVersioning(options => ...

services.AddVersionedApiExplorer(...)

services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();

services.AddSwaggerGen(...)

services.TryAddEnumerable(ServiceDescriptor.Transient<IApiDescriptionProvider, SubgroupDescriptionProvider>());

Configure:

app.UseSwagger();

app.UseSwaggerUI(
    options =>
    {
        foreach (var description in provider.ApiVersionDescriptions)
        {
            options.SwaggerEndpoint($"/swagger/{description.GroupName}/swagger.json", description.GroupName.ToUpperInvariant());
        }
    });

Any ideas what I'm doing wrong?

@commonsensesoftware
Copy link
Collaborator

@Code1110 , UseSwaggerUI will occur after DI resolution so that should be fine. options.SwaggerDoc, on the other hand, is not by default. Swashbuckle improved that quite some time ago. You need to use IConfigureOptions<SwaggerGenOptions> as shown in the example as part of ConfigureSwaggerOptions.cs. That will enable configuring options.SwaggerDoc after DI resolution. You register that with:

services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();

The complete set of moving parts is outlined in the Swagger Sample.

@Code1110
Copy link

OK, I just realized that I need to replace IApiVersionDescriptionProvider with IApiDescriptionGroupCollectionProvider for this to work. That way, it's possible to group by GroupName and Version - perfect.

@borota
Copy link

borota commented Mar 29, 2021

@Code1110 that's what I did as well.
Besides IApiDescriptionGroupCollectionProvider I also injected IApiVersionDescriptionProvider and used descriptionGroup.Items[0].GetApiVersion() to get the version for each group. I used the version to link to IApiVersionDescriptionProvider.
This way I was able to get all the information already put together by IApiVersionDescriptionProvider for each version, such as deprecation status, etc.

I could put all of it into a nuget package, if requested.

@karpediemnow
Copy link

karpediemnow commented May 24, 2021

OK, I just realized that I need to replace IApiVersionDescriptionProvider with IApiDescriptionGroupCollectionProvider for this to work. That way, it's possible to group by GroupName and Version - perfect.

Great! Work perfectly... :)

..but it's too slow and resource consuming...

Do you have the following issue?

dotnet/aspnetcore#17979

@commonsensesoftware
Copy link
Collaborator

@karpediemnow I'm not aware of any specific issues. There's no way around the IApiDescriptionGroupCollectionProvider as it's required to dispatch to any controllers. I've not had issues with the performance of this service, but if you have a very large number of controllers or you're frequently changing the candidate sources dynamically (ex: plug-ins), I can see how that might happen. The collection backing .Items is created once and is retained unless there are runtime changes (again - such as plug-ins). The specific issue you've referenced appears to be related to having an action parameter of System.Type. I don't have a solution for that, but I suspect there are workarounds for it.

If there is a performance issue, that should be external inside ASP.NET Core itself. I expect you'll find that with or without this extension. If there is way to mitigate this issue, I'm willing to consider it as a future enhancement.

@pfaustinopt
Copy link

pfaustinopt commented Sep 20, 2021

@tjmcnaboe @mattmazzola
Full solution for issue

[ApiVersion( "3.0.session" )]
[ApiVersion( "3.0.authoring" )]
[Route("api/v{version:apiVersion}/[controller]")]

services.AddVersionedApiExplorer(opt =>
{
opt.GroupNameFormat = "S-'v'VV";
opt.SubstitutionFormat = "VV/S";
opt.SubstituteApiVersionInUrl = true;
});

Swagger doc definition:
"SESSION-v3.0 | AUTHORITY-v3.0"
Routes:
/api/v3.0/session/Orders/5
/api/v3.0/authoring/Orders/5

I've tried to use this solution but I get a 404 Not Found when navigating to /api/v3.0/session/Orders/5.

This doesn't seem to happen if I remove opt.SubstitutionFormat = "VV/S"; and navigate to /api/v3-authoring/Orders/5.

What am I doing wrong?

@commonsensesoftware
Copy link
Collaborator

@pfaustinopt I can't really speak to whether that solution works as it doesn't have a full repro. Honestly, I would not expect it to work as the status should appear in the form of 3.0-session and 3.0-authoring. I would also argue that that is an abuse of how status is intended to be used, even if it does work.

The solution provided by @borota provides a working solution. Ultimately, the solution depends on how you want to have things appear in the OpenAPI UI. I've expanded upon that solution with some additional context and clarifying information in https://stackoverflow.com/questions/68788850/swagger-i-need-versioning-and-grouping. May that be of use to you.

@pfaustinopt
Copy link

pfaustinopt commented Sep 20, 2021

Thank you @commonsensesoftware , I'm going to take a look on that link.

To provide you with more context, I'm trying to split my swagger into different groups, each one with its own versions, like this:

  • Mobile
    • V1
  • Internal
    • V1
    • V2
    • V3
  • Public
    • V1
    • V2

@commonsensesoftware
Copy link
Collaborator

Totally reasonable. The long and short of it is that it's technically possible, but not easy to achieve without a lot of work - unfortunately. The API Explorer only has GroupName, which defines a single level of grouping. Unless the ASP.NET team changes that, there's no way around it. As such, most OpenAPI generators such as Swashbuckle or NSwag will have parity with that. The biggest change is in the UI because, unless you get creative with naming (as suggested), there's only a single dropdown (when you'd now need 2; by group name, then version). API Versioning collates by API version by default for your convenience and due to this limitation, but it doesn't have to.

@pfaustinopt
Copy link

pfaustinopt commented Sep 20, 2021

I don't mind having a single dropdown and I've already accomplished that by following the suggestions over this thread:
2021-09-20 20_28_47-SwaggerPoC (Running) - Microsoft Visual Studio (Administrator)

Now I'm just trying to transform the URL from v1-mobile to v1/mobile - looks more REST friendly IMO - but I can't find a way to do this (using opt.SubstitutionFormat = "VV/S" doesn't seem to work).

Here is the configuration of MobileController using the Status field from API version:

[ApiController]
[Route("api/v{version:apiVersion}/[controller].[Action]")]
[ApiVersion("1.mobile")]
[SwaggerControllerName("mobile")]

public class MobileController : ControllerBase
...

@commonsensesoftware
Copy link
Collaborator

Ut oh! Now you've done it...

<steps up to soapbox>

Versioning by URL segment is the least RESTful of all methods and, arguably, not RESTful at all; it violates the Uniform Interface constraint. v1/mobile/42 and v2/mobile/42 are not two different resources (or they shouldn't be); they are different representations. The URL path is the identifier (not 42 as some people think). v1 and v2 are not endpoints. An endpoint is a host - as in v1.api.com or v2.api.com.

"REST is a system of constraints. You either follow the constraints - all of the constraints, or you aren't doing REST."
- Roy Thomas Fielding

<steps down from soapbox>

To get the result you want, do not conflate API version status with how you want to group; they are different, unrelated concepts. If you've already got a custom IApiDescriptionProvider doing what you want, then there are 2 possible solutions.

Option 1

Use the built-in group name metadata - as in [ApiExplorerSettings(GroupName = "Mobile")] applied to your controller. Depending on the IApiDescriptionProvider.Order that your implementation runs, this should work and will be the simplest to do. Currently, the API Versioning implementation will overwrite ApiDescription.GroupName with the formatted API version (changing in the future). It may, however, still be possible to get the value before it's overwritten, get the value from the ApiExplorerModel, or inspect the ApiExplorerSettingsAttribute in the controller metadata.

Option 2

If that solution doesn't work the way you want, then you can always create your own attribute to provide this metadata. For example, [ApiGroup("Mobile")] or something like that. Your IApiDescriptionProvider implementation would then look for this attribute and use its value, if present. This is a bit more work, but is very straight forward.

Conclusion

Choosing either of those options will get you out of the scenario trying to compose crafty format transformations. Even if you could get the transformation to work correctly, it would probably not be routable. The API version values have to match up. v1-mobile will never match v1/mobile.

@pfaustinopt
Copy link

Could you provide me with a little more guidance to implement Option 1? I've created a github repository with the code that I'm currently using. I've implemented the IApiDescriptionProvider you provided in your stackoverflow example.

With those changes applied, this is what I get now in Swagger:
2021-09-20 21_30_59-Swagger UI

All my groups are together in one Swagger definition, which is not what I intend to.

I'm also curious, according to your suggestion of not splitting API versions by Status, what would happen if I set Public V1 as deprecated? Wouldn't it affect the remaining V1's from the other groups?

@commonsensesoftware
Copy link
Collaborator

Ah, yes - that part of the discussion was missed. The number and generation of OpenAPI documents are completely separate from API Versioning. This is controlled in SwaggerDocApiVersionConfiguration.cs via options.SwaggerGeneratorOptions.SwaggerDocs.Add. You are currently added a document per API version. Each item in the dropdown corresponds to a GroupName and each group typically corresponds to a single API version. Since you've flattened group and version together, you'll get more documents, but that should be ok. What's missing is that the group name given to an OpenAPI generator like Swashbuckle must match the ApiDescription.GroupName. From what I see, they don't appear to match.

Deprecated simply means that you are advertising that a particular API is going to go away - at some point. It doesn't convey any particular policy; for example, after 6 months. For now, it's something you publicly advertise or negotiate with your clients. I'm considering adding expanded support in the future which will jive with RFC 8594. Deprecated also does not mean unsupported. Once an API is completely unsupported, it simply no longer exists. I think you may be asking about how aggregation works. An API version reported by the IApiVersionDescriptionProvider is only considered deprecated all-up, if and only if, all of the APIs in that version are deprecated. If even one is not, then the version is not deprecated. Individual APIs will still correctly report and show that they are deprecated, even if there are other APIs in the same version that are supported.

@pfaustinopt
Copy link

pfaustinopt commented Sep 20, 2021

So you are saying that I need to add a Swagger doc for each ApiDescription.GroupName that I've, like this?

app.UseSwaggerUI(options =>
                {
                    ...

                    foreach (var description in provider.ApiVersionDescriptions.OrderBy(x => x.ApiVersion.Status).ThenByDescending(x => x.ApiVersion))
                    {
                        options.SwaggerEndpoint($"/swagger/Mobile_{description.GroupName}/swagger.json", $"Mobile {description.GroupName.ToUpperInvariant()}");
                        options.SwaggerEndpoint($"/swagger/Public_{description.GroupName}/swagger.json", $"Public {description.GroupName.ToUpperInvariant()}");
                        options.SwaggerEndpoint($"/swagger/Internal_{description.GroupName}/swagger.json", $"Internal {description.GroupName.ToUpperInvariant()}");
                    }
                });`
public void Configure(SwaggerGenOptions options)
        {
            foreach (var description in provider.ApiVersionDescriptions)
            {
                options.SwaggerGeneratorOptions.SwaggerDocs.Add($"Mobile_{description.GroupName}", CreateInfoForApiVersion(description));
                options.SwaggerGeneratorOptions.SwaggerDocs.Add($"Public_{description.GroupName}", CreateInfoForApiVersion(description));
                options.SwaggerGeneratorOptions.SwaggerDocs.Add($"Internal_{description.GroupName}", CreateInfoForApiVersion(description));
            }
        }

What is happening now is that I have a document for V2 Mobile and Internal with No operations defined in spec. Perhaps I need some additional logic to check whether there is at least one controller with ApiDescription.GroupName = Mobile that has APIVersionAttribute = V2?


Edited: I've modified the repository to support automatic GroupName + API version document generation.

@commonsensesoftware
Copy link
Collaborator

First, you probably want IApiDescriptionGroupCollectionProvider instead of IApiVersionDescriptionProvider for your case. Something like:

public class SwaggerDocApiVersionConfiguration : IConfigureOptions<SwaggerGenOptions>
{
    private readonly IApiDescriptionGroupCollectionProvider provider;

    public SwaggerDocApiVersionConfiguration(
        IApiDescriptionGroupCollectionProvider provider) => this.provider = provider;

    public void Configure(SwaggerGenOptions options)
    {
        var docs = options.SwaggerGeneratorOptions.SwaggerDocs;

        for (var i = 0; i < provider.Items.Count)
        {
            var group = provider.Items[i];
            
            // grouping expectation: name -> version, which means:
            // 1. OpenApiInfo.Version does not apply
            // 2. No built-in way to know if:
            //    a. All versions in API are deprecated
            //    b. A particular version is deprecated across all groups
            //  
            // note: this can be solve by some post-processing on the descriptions
            //
            // the GroupName now includes name+version. you'd either need separate
            // metadata to get just the name from or you 'could' extract the group
            // component based on your known format.
            docs.Add(group.GroupName, new() { Title = group.GroupName });
        }
    }
}

...so I've no way to tell my API consumers that V1 of public API is deprecated (or obsolete) and that they consider using a superior version? I can only say that V1 of all my groups (Public, Mobile, Internal) is deprecated?

No, that isn't true. The challenge is that multi-level (2+) grouping is not intrinsically supported so there's no way that API Versioning can just do it for you. You could, however, build some extension methods or some other collaborator with functions that can compute these values. Keeping the group name completely separate with a piece of metadata, such as an attribute, would potentially make that way easier. For example, API Versioning stores the associated API version in the ApiDescription and provides extension methods to retrieve it (e.g. ApiDescription.GetApiVersion()). If you followed a similar pattern for the group name, then you'd be able to implement this fairly easily. It might look something like:

// expanding upon above
public void Configure(SwaggerGenOptions options)
{
    var names = provider.Items
                        .Select(g => g.Items)
                        .SelectMany(i => i.GroupName)
                        .Distinct(StringComparer.OrdinalIgnoreCase);
    var deprecationMap = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);

    foreach (var name in names)
    {
       var deprecated = groupProvider.Items
                                     .Select(g => g.Items)
                                     .SelectMany(i => i)
                                     .Where(i => i.ApiGroupName() == name) // your extension; get group name only
                                     .All(i => i.IsDeprecated());
      deprecationMap.Add(name, deprecated);
    }

    var docs = options.SwaggerGeneratorOptions.SwaggerDocs;

    for (var i = 0; i < provider.Items.Count)
    {
        var group = provider.Items[i];
        var name = ExtractTitle(group.GroupName); // your function to 'extract' the name only component; could be a map
        var title = name;

        if (deprecationMap.TryGetValue(name, out var deprecated) && deprecated)
        {
            title += $"{Environment.NewLine}This API version has been deprecated";
        }

        docs.Add(group.GroupName, new() { Title = title });
    }
}

The organization of group name -> version -> name is totally logical, but it is quite difficult to implement. Organizing by version (as group) -> name is simple and painless albeit not how you prefer it to be arranged. Only you can decide if the proverbial juice is worth the squeeze.

The way your solution is organized makes no difference to API Versioning. You can use a single project or many. The choice is yours. Part of the reason that the Conventions API exists is so that you can apply versioning to external controllers that you didn't author and don't have source for. For example:

services.AddApiVersioning(options => options.Conventions.Controller<ThirdPartyController>().HasApiVersion(2, 1));

@pfaustinopt
Copy link

pfaustinopt commented Sep 20, 2021

Perfect, with IApiDescriptionGroupCollectionProvider I can find all the GroupName my controllers have. However, I'm facing this:
2021-09-21 00_45_47-Swagger UI

provider.Items is returning v1 and v2, even thought all my controllers have a GroupName set. Any idea why?

To find if a certain API version is deprecated, I'm following @borota advice:

Besides IApiDescriptionGroupCollectionProvider I also injected IApiVersionDescriptionProvider and used descriptionGroup.Items[0].GetApiVersion() to get the version for each group. I used the version to link to IApiVersionDescriptionProvider.
This way I was able to get all the information already put together by IApiVersionDescriptionProvider for each version, such as deprecation status, etc.

@pfaustinopt
Copy link

Perfect, with IApiDescriptionGroupCollectionProvider I can find all the GroupName my controllers have. However, I'm facing this:
2021-09-21 00_45_47-Swagger UI

provider.Items is returning v1 and v2, even thought all my controllers have a GroupName set. Any idea why?

Nevermind, it was due to this in SubgroupDescriptionProvider:

                // optional: add version grouping as well
                // note: this works because the api description will appear in
                // multiple, but different, documents
                var newResult = result.Clone();

                newResult.GroupName = versionGroupName;
                newResults.Add(newResult);

@danielohirsch
Copy link

@pfaustinopt or @commonsensesoftware - do either of you have working code with this in place? Having a hard time recreating this....

@commonsensesoftware
Copy link
Collaborator

@danielohirsch , this question has been asked many times over the years. The same limitations exist, but the capability to merge API version while retaining a group name is now supported out-of-the-box starting in 7.0. The wiki topic for the Format Group Name option describes how to use it. Let me know if you have any other questions about the feature.

@danielohirsch
Copy link

danielohirsch commented Feb 6, 2023 via email

@commonsensesoftware
Copy link
Collaborator

No, but why would you want anything else? 😛

Sometimes I forget what I backport or added and carried forward. It appears this feature has been supported since 6.2. 😄 If you need/want .NET 6, then I would recommend 6.4.x. 😉

@danielohirsch
Copy link

No, but why would you want anything else? 😛

Oh, it's hard to move that fast in enterprise libs... :) I'll go to 7 when I can, but for now, our stuff is in 6.

@danielohirsch
Copy link

Sometimes I forget what I backport or added and carried forward. It appears this feature has been supported since 6.2. 😄 If you need/want .NET 6, then I would recommend 6.4.x

Oh, good to know. I feel like an idiot now having gone round and round on this for as long as I did. I have an api aggregator that I am trying to gen out separate docs for based on child domain entry point... Hoping what you said will work for me.

@danielchiangGMF
Copy link

Hi @commonsensesoftware ,

Is there a way to merge all versions of endpoint in one swagger page?
For example:
I want only one page with v1 v2 v3 endpoints.
page1:
v1
v2
v3
Instead:
page1:
v1
page2:
v2
page3:
v3
image

@commonsensesoftware
Copy link
Collaborator

@danielchiangGMF,

Yes, it is possible, but not without some work. The Swashbuckle configuration is effectively the same as if you had no versioning (e.g. it uses the default grouping mechanisms). Omit the version-specific part and that should aspect should be covered.

API Versioning automatically uses the associated the API version as the group name. There is no way to opt out of that behavior because anyone not versioning by URL segment (which violates the Uniform Interface constraint) would end up in a broken state (e.g. the Swagger UI wouldn't work by default). The group name can be cleared (e.g. ""), which will yield the default behavior of everything together. The best way to achieve this is:

  1. Create a custom IApiDescriptionProvider that sets ApiDescription.GroupName = "" in OnProvidersExecuted
  2. Make sure it runs after API Versioning a la Order
  3. Register it with DI via TryAddEnumerable(ServiceDescriptor.Transient<MyProvider, IApiDescriptionProvider>())

The net effect should be that there is no longer any grouping and the UI is a flat list. I hope that helps.

@danielchiangGMF
Copy link

@commonsensesoftware
I was given a thought to use custom schema. Is it a right direction? Thank you.

@commonsensesoftware
Copy link
Collaborator

@danielchiangGMF Custom schema is not necessary.

Step 1 - Create a custom API description provider

internal sealed class AllGroupApiDescriptionProvider : IApiDescriptionProvider
{
    public int Order => int.MaxValue;

    public void OnProvidersExecuted( ApiDescriptionProviderContext context )
    {
        foreach ( var result in context.Results )
        {
            result.GroupName = "all";
        }
    }

    public void OnProvidersExecuting( ApiDescriptionProviderContext context ) { }
}

Step 2 - Register the provider in DI

builder.Services.TryAddEnumerable(
    ServiceDescriptor.Transient<IApiDescriptionProvider, AllGroupApiDescriptionProvider>() );

Step 3 - Update the Swashbuckle configuration

builder.Services.AddSwaggerGen(
    options =>
    {
        // NOTE: your options might not be the same here
        options.OperationFilter<SwaggerDefaultValues>();

        var fileName = typeof( Program ).Assembly.GetName().Name + ".xml";
        var filePath = Path.Combine( AppContext.BaseDirectory, fileName );

        options.IncludeXmlComments( filePath );
        options.SwaggerDoc(
            "all",
            new()
            {
                Title = "Example API",
                Contact = new() { Name = "Daniel", Email = "[email protected]" },
                License = new() { Name = "MIT", Url = new Uri( "https://opensource.org/licenses/MIT" ) },
                Description = "An example application with OpenAPI, Swashbuckle, and API versioning.",
            } );
    } );

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI( options => options.SwaggerEndpoint( "all/swagger.json", "All" ) );

Step 4 - Build and Run Your App

Profit! 🪙🪙 🪙

@danielchiangGMF
Copy link

danielchiangGMF commented Mar 4, 2024

Thank you for sharing the code. It seems like it wont work for minimal api @@

@commonsensesoftware
Copy link
Collaborator

@danielchiangGMF You're welcome. Why not? What makes you think that Minimal APIs are somehow special or different in this context? With the exception of ordering, I don't see why it wouldn't work.

@danielchiangGMF
Copy link

danielchiangGMF commented Mar 4, 2024

@commonsensesoftware

I followed the steps. Swagger is running but endpoints aren't being added.
image
I didn't change our minimal api endpoints structure.
image
Some additional details:
image
image

@commonsensesoftware
Copy link
Collaborator

Without a repro, it's hard to provide an exact solution. I've attached a modified version of the Minimal API with OpenAPI example, which will demonstrate how to get things working. There may be some variances to your particular case that you need to make however. Note, that model names have to be unique within the OpenAPI document, which may or may not be a problem for you.

Issue516-MinApi.zip

@danielchiangGMF
Copy link

danielchiangGMF commented Mar 7, 2024

Morning @commonsensesoftware
Your zip file works which is exactly what we want to achieve. We are refactoring our code now.
Question: Will IEndpointRouteBuilder work well with this all-in-one Api versioning? We tried to work around on zip file@@
Scenario: We want to make all the api in one swagger json so we can import to APIM
image
image
image

@danielchiangGMF
Copy link

@commonsensesoftware

Update: We are slowly resolving our issue now. Thank you. Will let you know if we encounter sth new.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests