Skip to content
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

Support multiple OpenAPI documents per application #54676

Closed
captainsafia opened this issue Mar 21, 2024 · 5 comments
Closed

Support multiple OpenAPI documents per application #54676

captainsafia opened this issue Mar 21, 2024 · 5 comments
Labels
api-approved API was approved in API review, it can be implemented area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates feature-openapi
Milestone

Comments

@captainsafia
Copy link
Member

Background and Motivation

Users need the ability to group endpoints in the same application into different documents by various properties. This API proposal outlines how to support multiple documents in the same application using keyed services behind the scenes.

Proposed API

// Assembly: Microsoft.AspNetCore.OpenApi
namespace Microsoft.Extensions.DependencyInjection;

public static class OpenApiServiceCollectionExtensions
{
+  public static IServiceCollection AddOpenApi(this IServiceCollection services, string name);
+  public static IServiceCollection AddOpenApi(this IServiceCollection services, string name, Action<OpenApiOptions> configureOptions);
}
// Assembly: Microsoft.AspNetCore.OpenApi
namespace Microsoft.AspNetCore.OpenApi;

public class OpenApiOptions
{
+	public Func<ApiDescription, string, bool> ShouldInclude { get; set; } = (description, documentName) => description.GroupName == null || description.GroupName == documentName;
}

Usage Examples

Create different OpenAPI documents versioned by API description group name.

var builder = WebApplication.CreateBuilder();

builder.Services.AddControllers();
builder.Services.AddOpenApi("v1");
builder.Services.AddOpenApi("v2");

var app = builder.Build();

// /openapi/v1/openapi.json
// /openapi/v2/openapi.json
app.MapOpenApi();

app.MapControllers();

[ApiController]
[ApiExplorerSettings(GroupName = "v1")]
[Route("[controller]")]
public class PeopleController : ControllerBase
{
    [HttpGet("{id:guid}")]
    public IActionResult Get(Guid id)
        => Ok(new { FirstName = "Donald", LastName = "Duck" });
}

[ApiController]
[ApiExplorerSettings(GroupName = "v2")]
[Route("[controller]")]
public class NewPeopleController : ControllerBase
{
    [HttpGet("{id:guid}")]
    public IActionResult Get(Guid id)
        => Ok(new { FirstName = "Donald", LastName = "Duck" });

    [HttpPost("{id:guid}")]
    public IActionResult Post(Guid id)
        => Ok(new { FirstName = "Donald", LastName = "Duck" });
}

Create different OpenAPI documents for internal and public-facing APIs.

var builder = WebApplication.CreateBuilder();

builder.Services.AddControllers();
builder.Services.AddOpenApi("internal", options =>
{
	options.ShouldInclude = (description, documentName) => description.RelativePath.StartsWith("/internal");
});
builder.Services.AddOpenApi("public", options =>
{
  options.ShouldInclude = (description, documentName) => description.RelativePath.StartsWith("/public");
});

var app = builder.Build();

// /openapi/internal/openapi.json
// /openapi/public/openapi.json
app.MapOpenApi();

app.MapGet("/internal/foo", () => { });
app.MapGet("/public/foo", () => { });

Alternative Designs

  • It's possible to model each document within a single service and provide APIs for that. Using keyed DI allows us to split up documents and their dependencies (schema registies, options, etc.) more clearly.
@captainsafia captainsafia added area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews feature-openapi area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc labels Mar 21, 2024
@captainsafia captainsafia added this to the 9.0-preview4 milestone Mar 22, 2024
@bbarry
Copy link
Contributor

bbarry commented Mar 25, 2024

would this function to include one document in another as well?

var builder = WebApplication.CreateBuilder();

builder.Services.AddControllers();
builder.Services.AddOpenApi("unstable", options =>
{
	options.ShouldInclude = (description, documentName) => true;
});
builder.Services.AddOpenApi("stable", options =>
{
  options.ShouldInclude = (description, documentName) => description.IsDefined<StableAttribute>();
});

var app = builder.Build();

// /openapi/unstable/openapi.json
// /openapi/stable/openapi.json
app.MapOpenApi();

app.MapGet("/at/risk/for/breaking/changes", () => { });
app.MapGet("/safe/to/depend/on", [Stable] () => { });

@captainsafia
Copy link
Member Author

@bbarry Yep, based on the setup you configured in your sample code you'd get the following documents outputted:

  • Document name: stable
    • "/safe/to/depend/on"
  • Document name: unstable
    • "/safe/to/depend/on"
    • "/at/risk/for/breaking/changes"

@halter73
Copy link
Member

API Review Notes:

  • Does Swashbuckle and NSwag use [ApiExplorerSettings(GroupName = "v1")] to separate documents?
    • Yes, OpenApiOptions.ShouldInclude's default implementation matches the behavior of both.
  • How do we ensure we include just the schemas we need for each openapi document?
    • Keyed services. The OpenApiCompnentService which generates schemas and caches them and the OpenApiDocumentService which does the final document generation both of which are currently internal.
    • Options use named options.
  • Can we support wildcards or something similar for dynamic names?
  • e.g. AddOpenApi("$LOCALE")?
  • Maybe as an addition in the future, but for now you could write a loop that calls AddOpenApi
  • AddOpenApi() is equivalent to AddOpenApi("v1").
    • Do we want to make it AddOpenApi("openapi.v1")? Not if we keep /openapi route prefix.
  • MapOpenApi() is equivalent to MapOpenApi("/openapi/{documentName}/openapi.json").
  • Can we rename the name parameter in AddOpenApi to documentName for clarity?
    • Or groupName? No, it doesn't correspond 1:1 with ApiExplorer groups.
    • Or documentId? documentKey?
    • Is a segment of the route really a "name" at all? Swashbuckle and NSwag call it a "document name"
  • Could we change the default pattern to "/openapi/{documentName}.json"?
    • "/openapi/{documentName}.openapi.json"?
    • Let's go with "/openapi/{documentName}.json".
  • What happens if you don't have a documentName route parameter with a custom call to MapOpenApi("myopenapi.json")?
    • We'll only route the default "v1" document.
  • This is the first time we're exposing ApiExplorer types in public Microsoft.AspNetCore.OpenApi API, and we think that's fine.
  • How do we feel about Func<ApiDescription, string, bool> vs a strongly typed delegate where we could name the parameters?
    • It's very unusual to use strongly typed delegates in ASP.NET Core. The exist. For example, RequestDelegate, ConnectionDelegate and EndpointFilterDelegate, but they're rare.
    • We'll leave it as a Func.
  • Should ShouldInclude be called ShouldIncludeInDocument?
    • We think it's clear enough without "InDocument" because the options are document-specific, and the document name is no longer a parameter.
  • Should Func<ApiDescription, string, bool> be a Func<string, ApiDescription, bool>?
    • Func<ApiDescription, bool> seems better yet which is possible by making DocumentName a get-only property on the options.
  • Could we make the document name collection a property on ApiDescription, and have an Action<ApiDescription> mutate it?
    • We don't want to modify ApiExplorer APIs just for OpenApi

API Approved!

// Assembly: Microsoft.AspNetCore.OpenApi
namespace Microsoft.Extensions.DependencyInjection;

public static class OpenApiServiceCollectionExtensions
{
+  public static IServiceCollection AddOpenApi(this IServiceCollection services, string documentName);
+  public static IServiceCollection AddOpenApi(this IServiceCollection services, string documentName, Action<OpenApiOptions> configureOptions);
}
// Assembly: Microsoft.AspNetCore.OpenApi
namespace Microsoft.AspNetCore.OpenApi;

public class OpenApiOptions
{
+  public string DocumentName { get; }
+  public Func<ApiDescription, bool> ShouldInclude { get; set; } = (description) => description.GroupName == null || description.GroupName == DocumentName;
}

@halter73 halter73 added api-approved API was approved in API review, it can be implemented and removed api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews labels Mar 25, 2024
@RyanThomas73
Copy link

RyanThomas73 commented Mar 29, 2024

@captainsafia @halter73 I haven't fully investigated what's in the redesigned open api library yet but this issue seemed like a good place to point out an important consideration that I've dealt with in the past when using aspnetcore mvc versioning combined with swashbuckle for open api spec generation.

When generating separate OAS .json files for different api versions in the same project, it's an important use case that the generation be capable of putting a common api version prefix portion of the endpoint route into the OAS base url in lieu of having the api version prefix be inclued in each operation path section in the OAS.

Demonstration example:

[ApiController]
[ApiVersion("1.0", Deprecated = true)]
[ApiVersion("2.0")]
[Authorize]
[Route("api/example-versioned-rest-api/v{version:apiVersion}/weather-forecasts")]
public class WeatherForecastController : ControllerBase
{
    [HttpGet]
    [MapsToApiVersion("1.0")]
    public async Task<ActionResult<WeatherForecastsV1Response>> GetWeatherForecastsAsync(
        CancellationToken cancellationToken)
    {
        // code for deprecated v1 response
    }
    
    [HttpGet]
    [MapsToApiVersion("2.0")]
    public async Task<ActionResult<WeatherForecastsV2Response>> GetWeatherForecastsAsync(
        CancellationToken cancellationToken)
    {
        // code for v2 response
    }
}

Desired Output:

{
  "openapi": "3.0.1"
  "info": { "title": "...", "version": "1.0" },
  "servers": [
    { "url" : "/api/example-versioned-rest-api/v1" }
  ]
  "paths" {
    "/weather-forecasts": { ... }
  }
}

{
  "openapi": "3.0.1"
  "info": { "title": "...", "version": "2.0" },
  "servers": [
    { "url" : "/api/example-versioned-rest-api/v2" }
  ]
  "paths" {
    "/weather-forecasts": { ... }
  }
}

This is something that swashbuckle did not support natively and I had to write custom document filter logic to adjust for.

Why this is an important use case:
There are a variety of tools and consumption patterns for the OAS .json file that will not work correctly if the api versioning prefix is included in each operation path. Here are a couple of concrete examples of such tools that I've encountered:

  1. Pactflow bi-directional contract testing which compares the pact generated client .json file against the OAS document for compatibility. When the base url in the client code being tested has the api version prefix and the OAS document does not the contract validation fails to match.

  2. Microsoft Azure API Management import of OAS docs for versioned apis. When importing the OAS docs, APIM expects you to define an api version set with each version in the set having an APIM api version prefix url which will correspond to the base/server url of the OAS document being imported.

    If the api versioning prefix is output in the individual paths instead then APIM import will end up generating apim paths that have a duplicated version value in the path. e.g.

    https://url.for.apim/api/example-versioned-rest-api/v1/v1/weather-forecasts
    https://url.for.apim/api/example-versioned-rest-api/v2/v2/weather-forecasts
    

@captainsafia
Copy link
Member Author

@RyanThomas73 It looks like you are using Asp.Versioning to handle multi-version support for your APIs. Part of the challenge with this is getting all three components (Asp.Versioning + ASP.NET Core + OpenAPI document generation) to play nice with each other.

IMO, the expected output you've identified above should be trivial to support with a document transformer that modifies the servers object in the document to include the base URL. Although this will need to happen in the context of Asp.Versioning since it manages the state on the prefix URLs.

I'm going to close this particular issue for tracking purposes since this API has been implemented but please chime in with your scenario on the main issue over at #54598 and we can continue the discussion there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api-approved API was approved in API review, it can be implemented area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates feature-openapi
Projects
None yet
Development

No branches or pull requests

4 participants