Skip to content

Add support for OpenAPI document filters #54650

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
captainsafia opened this issue Mar 20, 2024 · 29 comments
Closed

Add support for OpenAPI document filters #54650

captainsafia opened this issue Mar 20, 2024 · 29 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

captainsafia commented Mar 20, 2024

Background and Motivation

As part of the ongoing OpenAPI work, users have mentioned they're eager to see us support APIs that allow them to customize the OpenApiDocument that is generated by the framework. These APIs would allow users to resolve the following scenarios:

  • Customize schemas that are generated with example payloads
  • Enhance the version info produced in the generated document
  • Modify generated schemas with additional metadata

⚠️ The proposal below is superseded by #54650 (comment) after API review.

Proposed API

Note: All APIs are net new.

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

// Marker interface
public interface IOpenApiFilter { }

// For modifying info, servers, authentication schemes
public interface IOpenApiDocumentFilter : IOpenApiFilter
{
  public Task Apply(OpenApiDocument document, OpenApiDocumentFilterContext context, CancellationToken cancellationToken);
}

// For modifying parameters, requestBody, response
public inteface IOpenApiOperationFilter : IOpenApiFilter
{
  public Task Apply(OpenApiOperation operation, OpenApiOperationFilterContext context, CancellationToken cancellationToken);
}

// For modifying generated schemas
public interface IOpenApiSchemaFilter : IOpenApiFilter
{
  public Task Apply(JsonSchemaBuilder schemaBuilder, OpenApiSchemaFilterContext context, CancellationToken cancellationToken);
}
// Assembly: Microsoft.AspNetCore.OpenApi;
namespace Microsoft.AspNetCore.OpenApi;

public class OpenApiDocumentFilterContext
{
  public string DocumentName { get; }
  public IReadOnlyList<ApiDescriptionGroups> DescriptionGroups { get; }
}

public class OpenApiOperationFilterContext
{
  public string DocumentName { get; }
  public ApiDescription Description { get; }
}

public class OpenApiSchemaFilterContext
{
  public string DocumentName { get; }
  public Type Type { get; }
  public ApiParameterDescription ParameterDescription { get; }  
}
// Assembly: Microsoft.AspNetCore.OpenApi
namespace Microsoft.Extensions.Options;

public static class OpenApiOptionsExtensions
{
  public static void UseFilter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TFilterType>(this OpenApiOptions options)
        where TFilterType : IOpenApiFilter
}

Usage Examples

The following usage examples demonstrate leveraging this API to address a variety of scenarios requested by users.

var builder = WebApplication.CreateBuilder();

builder.Services.AddOpenApi(options =>
{
	options.UseFilter<AddBearerSecurityScheme>();
	options.UseFilter<AddBearerSecurityRequirement>();
	options.UseFilter<AddShapeExample>();
	options.UseFilter<AddXMsExtensions>();
});

var app = builder.Build();
// Demonstrate accessing DI via constructor injection
public class AddBearerSecurityScheme(IAuthenticationSchemeProvider authenticationSchemeProvider) : IOpenApiDocumentFilter
{
    public async Task Apply(OpenApiDocument openApiDocument, OpenApiDocumentFilterContext context)
    {
        var authenticationSchemes = (authenticationSchemeProvider is not null)
                ? await authenticationSchemeProvider.GetAllSchemesAsync()
                : Enumerable.Empty<AuthenticationScheme>();
        var requirements = authenticationSchemes
                .Where(authScheme => authScheme.Name == "Bearer")
                .ToDictionary(
                    (authScheme) => authScheme.Name,
                    (authScheme) => new OpenApiSecurityScheme
                    {
                        Type = SecuritySchemeType.Http,
                        Scheme = "bearer", // "bearer" refers to the header name here
                        In = ParameterLocation.Header,
                        BearerFormat = "Json Web Token"
                    });
        openApiDocument.Components.SecuritySchemes = requirements;
    }
}
public class AddBearerSecurityRequirement : IOpenApiOperationFilter
{
    public Task Apply(OpenApiOperation openApiOperation, OpenApiOperationFilterContext context)
    {
        var authorizations = context.Description.ActionDescriptor.EndpointMetadata.OfType<AuthorizeAttribute>();
        if (authorizations.Any())
        {
            openApiOperation.Security = [
                new OpenApiSecurityRequirement
                {
                    {
                        new OpenApiSecurityScheme
                        {
                            Reference = new OpenApiReference
                            {
                                Type = ReferenceType.SecurityScheme,
                                Id = "Bearer"
                            }
                        },
                        Array.Empty<string>()
                    }
                }
            ];
        };
        return Task.CompletedTask;
    }
}
public class AddShapeExample : ISchemaFilter
{
    public Task Apply(JsonSchemaBuilder schema, OpenApiSchemaFilterContext context)
    {
        if (context.Type == typeof(Shape))
        {
            schema.Example(JsonNode.Parse("{\"color\":\"red\",\"sides\":3}"));
        }
        return Task.CompletedTask;
    }
}
public class AddXMsExtensions : IOpenApiOperationFilter
{
    public Task Apply(OpenApiOperation openApiOperation, OpenApiOperationFilterContext context)
    {
        openApiOperation.Extensions["x-ms-trigger"] = new OpenApiAny("single");
        openApiOperation.Extensions["x-ms-visibility"] = new OpenApiAny("internal");
        openApiOperation.Extensions["x-ms-dynamic-values"] = new OpenApiAny(JsonNode.Parse("""
        {
            "operationId": "GetSupportedModels",
            "value-path": "name",
            "value-title": "properties/displayName",
            "value-collection": "value",
            "parameters": {
                "requestId": {
                    "parameter": "id"
                }
            }
        }
"""));
        return Task.CompletedTask;
    }
}

Alternative Designs

  • Based on the comments in the original issue, I limited the filters scope to three areas: the document, individual operations, and the generated schema. There is a world where we could provide a configurability option for each property within the OpenAPI document. For example, instead of IOpenApiDocumentFilter, we could have IInfoFilter, ITagsFilter, and IServersFilter. Instead of IOpenApiOperationFilter, we could have IParametersFilter, IRequestBodyFilter, and so on.

Open Questions

  • Should we provide alternative APIs for registering filters onto the document? Similar in shape to what we do for minimal API's endpoint filters?
// Assembly: Microsoft.AspNetCore.OpenApi;

namespace Microsoft.AspNetCore.OpenApi;

public static class OpenApiOptionsExtensions
{
	public static OpenApiOptions UseDocumentFilter(this OpenApiOptions options, Action<OpenApiDocument, OpenApiDocumentFilterContext> filter);
	public static OpenApiOptions UseOperationFilter(this OpenApiOptions options, Action<OpenApiOperation, OpenApiOperationFilterContext> filter);
	public static OpenApiOptions UseSchemaFilter(this OpenApiOptions options, Action<JsonSchemaBuilder, OpenApiSchemaFilterContext> filter);
    public static OpenApiOptions UseFilter<TFilterType>(this OpenApiOptions options, TFilterType filter)
		where TFilterType : IOpenApiDocumentFilter, IOpenApiOperationFilter, IOpenApiSchemaFilter;
}
@captainsafia captainsafia added area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates api-suggestion Early API idea and discussion, it is NOT ready for implementation feature-openapi area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc labels Mar 20, 2024
@captainsafia captainsafia added this to the 9.0-preview4 milestone Mar 20, 2024
@pinkfloydx33
Copy link

Should we provide access to the OpenApiComponentService which provides access to registries in the IOpenApiSchemaFilter implementation?

One common thing when customizing operations is the need to reference schemas already in use. For example adding different response types for error payloads that cannot be represented in normal metadata (or where doing so would be otherwise cumbersome or verbose). Being able to access the schema registry from operation/document filters (assuming that's what you mean) would be similar to what is allowed by Swashbuckle filters

@RicoSuter
Copy link

Please don't call it “filter”, because it is not filtering out stuff (eg Where()) but transforming a model. Better call it processor, transformer, handler etc.

@captainsafia
Copy link
Member Author

For example adding different response types for error payloads that cannot be represented in normal metadata (or where doing so would be otherwise cumbersome or verbose)

In this case, I assume you're only accessing the reference IDs used in the schema registry for a given type (e.g. /#/components/schemas/SomeErrorType) and not the actual registered schemas themselves?

There's two patterns for providing this consumption. Either letting people leverage constructor injection to resolve them:

public class SomeFilter(IOpenApiComponentService components) : IDocumentFilter { }

Or passed in via the Apply:

public interface IOpenApiOperationFilter
{
  Task Apply(OpenApiOperation operation, ApiDescription description, IOpenApiComponentService service);
}

I prefer the former since it lets us keep a simpler signature on the actual filter itself and users can include it as needed instead of always being there.

Please don't call it “filter”, because it is not filtering out stuff (eg Where()) but transforming a model. Better call it processor, transformer, handler etc.

I prefer the name "filter" because it has a well-known meaning in ASP.NET Core (e.g. action filters, endpoint filters) so we don't have to teach users a new term. The implementation will also mimic aspects of other filters in the platform. For example, filters will be applied in LIFO order like they are in other places.

@RicoSuter
Copy link

RicoSuter commented Mar 20, 2024

For reference here are the filter/processor interfaces in NSwag:

General notes:

  • Consider using a context object so that you can add additional properties without breaking changes
  • Consider using Task based filters as people might want use async code, eg to add additional schemas from files (I/O), etc.
    • In NSwag they were once async but this made the whole generator async which was an overkill and the only reason for I/O was essentially XML Docs reading
  • Filters/processors should have full access to the schema store and be able to generate and store new schemas, retrieve schemas etc.
    • I do not know the design of IOpenApiComponentService but it should be possible to retrieve schemas, generate new ones based on a type and register custom ones

Question:

  • ISchemaFilter would be called for every operation parameter (ApiParameterDescription not null) as well as each DTO type?

@desjoerd
Copy link

desjoerd commented Mar 20, 2024

Filters/processors should have full access to the schema store and be able to generate and store new schemas, retrieve schemas etc.

This is something which we use with Swashbuckle.

I would suggest to scope the schema store or components service for the document which is being generated. For example when having a v1 and v2, we would like to keep the schema name "Order" but with a different shape. That's something which is not possible in Swashbuckle and requires every schema to be named differently globally even when it's only being used in one document.

@RicoSuter
Copy link

RicoSuter commented Mar 20, 2024

Filters/processors should have full access to the schema store and be able to generate and store new schemas, retrieve schemas etc.

This is something which we use with Swashbuckle.

I would suggest to scope the schema store or components service for the document which is being generated. For example when having a v1 and v2, we would like to keep the schema name "Order" but with a different shape. That's something which is not possible in Swashbuckle and requires every schema to be named differently globally even when it's only being used in one document.

In NSwag you can register multiple documents (v1, v2) with two options:

  • scope them to v1/v2 group in api explorer and it will only generate operations and schemas for the given version (classes can have same schema name as they are never used at the same time)
  • each document has own filters/processors and you can exclude operations from a filter based on version and then only needed schemas are generated

something like this should also be possible

@captainsafia
Copy link
Member Author

Consider using a context object so that you can add additional properties without breaking changes

@RicoSuter On this topic, I'm curious to hear. Have you had to make many changes to the context types over the years or have they been fairly consistent?

From my experiences building the context types for endpoint filters, I prefer to keep "thin" context types and guide people to resolving things via constructor activation/DI lookup as often as possible. I'm thinking about this specifically in the context of being able to access things like OpenApiOptions and OpenApiComponentService.

Consider using Task based filters as people might want use async code, eg to add additional schemas from files (I/O), etc.

Yep, similar to other filters in the ASP.NET Core framework, I anticipate that these filters will be Task-based as well.

ISchemaFilter would be called for every operation parameter (ApiParameterDescription not null) as well as each DTO type?

Yes, you'll be able to apply ISchemaFilter for schemas generated for parameters, request bodies, and responses.

I do not know the design of IOpenApiComponentService but it should be possible to retrieve schemas, generate new ones based on a type and register custom ones

At the moment, the IOpenApiComponentService is internal only and exposes the following API:

interface IOpenApiComponentService
{
  // Gets ID for type that can be used to do lookups in OpenAPI components.
  string GetReferenceId(Type type);
  JsonSchemaBuilder GetTypeByReferenceId(string referenceId);
  // Implicitly stores generated schemas
  JsonSchemaBuilder GenerateSchemaByType(Type type);
}

each document has own filters/processors and you can exclude operations from a filter based on version and then only needed schemas are generated

How does the exclusion work? Do you pass information about the OpenApi document version in the context objects or do you expect users to query the ApiDescription to disambiguate between different versions?

@pinkfloydx33
Copy link

In this case, I assume you're only accessing the reference IDs used in the schema registry for a given type (e.g. /#/components/schemas/SomeErrorType) and not the actual registered schemas themselves?

Correct. Resolving the schema reference ids using source type or name. Don't need the actual schema metadata for any cases I can think of

@martincostello
Copy link
Member

Nit: typo in the proposed API:

- public interface IOpenApIOpenApiDocumentFilter : IFilter
+ public interface IOpenApiDocumentFilter : IFilter

@RicoSuter
Copy link

RicoSuter commented Mar 21, 2024

Consider using a context object so that you can add additional properties without breaking changes

@RicoSuter On this topic, I'm curious to hear. Have you had to make many changes to the context types over the years or have they been fairly consistent?

From my experiences building the context types for endpoint filters, I prefer to keep "thin" context types and guide people to resolving things via constructor activation/DI lookup as often as possible. I'm thinking about this specifically in the context of being able to access things like OpenApiOptions and OpenApiComponentService.

I think one problem of your current design and also using DI for IOpenApiComponentService is your assumption that there is only one OpenAPI document per application. However I often want to have multiple documents (e.g. one per API version, a public/private one, etc.). Thinking like that, it must be possible to register multiple document builders, with individual filters and component service, etc... If you make filters, component service or another per-document service DI based, then you lose a lot of flexibility which you cannot change later.

Consider using Task based filters as people might want use async code, eg to add additional schemas from files (I/O), etc.

Yep, similar to other filters in the ASP.NET Core framework, I anticipate that these filters will be Task-based as well.

ISchemaFilter would be called for every operation parameter (ApiParameterDescription not null) as well as each DTO type?

Yes, you'll be able to apply ISchemaFilter for schemas generated for parameters, request bodies, and responses.

My assumption is that schema filter runs on the actual DTO schemas (just before they are stored in component store) and as an additional option also on parameters. But it's much more important to run them on the shared DTO schemas (which are referenced). Parameters I can also patch in a regular operation filter.

I do not know the design of IOpenApiComponentService but it should be possible to retrieve schemas, generate new ones based on a type and register custom ones

At the moment, the IOpenApiComponentService is internal only and exposes the following API:

interface IOpenApiComponentService
{
  // Gets ID for type that can be used to do lookups in OpenAPI components.
  string GetReferenceId(Type type);
  JsonSchemaBuilder GetTypeByReferenceId(string referenceId);
  // Implicitly stores generated schemas
  JsonSchemaBuilder GenerateSchemaByType(Type type);
}

each document has own filters/processors and you can exclude operations from a filter based on version and then only needed schemas are generated

How does the exclusion work? Do you pass information about the OpenApi document version in the context objects or do you expect users to query the ApiDescription to disambiguate between different versions?

In the operation filter you need to be able to access the document generation options, the document, the OpenAPI operator as well as all metadata like ApiDescription from which you can also retrieve eg .NET attributes. In the case of versions you could manually parse these attributes but of course for this case you would already register the document builder with only API explorer groups you want to include so that you do not even need to exclude operations in a processor. This exclusions in operation processors of course only makes sense for custom attributes: For example you could have a [Private] and [Public] attribute on operations and you generate a private and public document and then filter based on these attributes.

@eajhnsn1
Copy link

I'm in favor of multiple context types, by filter type.

To @RicoSuter's comment, we generate multiple documents in our project. An internal and external version of multiple subsets of the entire api surface. In our OperationFilter we're accessing the DocumentName, ApiDescription, SchemaGenerator and SchemaRepository.

@captainsafia
Copy link
Member Author

captainsafia commented Mar 21, 2024

I think one problem of your current design and also using DI for IOpenApiComponentService is your assumption that there is only one OpenAPI document per application.

IMO, using DI to model the document doesn't preclude us from managing multiple documents in the same application, particularly if we take advantage of keyed services.

I've opened a separate proposal to track how multi-document registration would work with a model that uses keyed DI to distinguish between the two. See #54676.

The other benefit of leveraging keyed DI is that it more cleanly solves @desjoerd of needing to maintain different schema stores for each document type. Since IOpenApiComponentService will be registered as a service keyed by document name, you get the separation for free.

I'm in favor of multiple context types, by filter type.

I've updated the proposal to include a context type for each filter.

@ghost
Copy link

ghost commented Mar 24, 2024

@captainsafia it would be amazing if we had some predefined methods to make adding filters simpler then we all set to go.

builder.Services.AddOpenApi(options =>
{
	options.AddBearerScheme();
	options.AddHeader("X-Api-Key");
        options.AddHeader("X-Tenant-Id");
        options.AddHeader("X-Api-Version", "1.0"); //default value if provided
        options.AddQuery("X-Api-Version", "1.0"); //default value if provided
});

@captainsafia
Copy link
Member Author

@RandomBuffer This is something you'd be able to accomplish with the function-based registration API that's outlined under "Open Questions". The implementation would look something like this:

public static OpenApiOptions AddHeader(this OpenApiOptions options, string headerName, string defaultValue)
{
	return options.AddOperationFilter((operation, context) =>
	{
		operation.Parameters.Add(new OpenApiParameter
		{
			Name = headerName,
			In = ParameterLocation.Header,
			Schema = new OpenApiSchema
			{
				Type = "string",
				Default = new OpenApiAnyString(defaultValue)
			}
		})
	})
}

@halter73 halter73 added api-suggestion Early API idea and discussion, it is NOT ready for implementation api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews and removed api-suggestion Early API idea and discussion, it is NOT ready for implementation labels Mar 27, 2024
@amcasey
Copy link
Member

amcasey commented Mar 27, 2024

  • We have at least one request to not call them filters
    • Filter follows EndpointFilter and ActionFilter
    • Suggestion: processor (as in source generators)
    • "Filter" suggests that you could drop things
      • Can accomplish this with a document filter
      • Should removal be first-class?
        • We don't know of a good use-case for this
    • Settled: "Transformer"
  • Open API types are mutable (and hierarchical - documents have operations)
  • OpenApiOperationFilterContext.Description is mutable
    • We don't actually want them to be mutated, but we don't know how to prevent it
  • This runs after WithOpenApi (as the last step)
  • Operation transformers run before document transformers
    • Regardless of the order in which they're added
    • This is different from middleware, but matches the direction we're thinking of going with middleware
    • Operations/schemas are run in the order in which they are registered
    • Historical note: pre-aspnetcore the system defined the order and it caused problems - moving to user-defined order was intentional
    • This might be a good question for user studies
  • The marker interface is to support UseFilter (now UseTransformer)
    • We could instead have a separate method for each supported transformer interface
    • (Probably - C# might balk)
  • Alternative: pass delegates
    • Should probably be task-returning
  • Proposal: Only have document transformers - they are sufficient
    • No marker interface needed
    • Have the option of exposing a helper for constructing a document transformer from an operation transformer
    • Ordering is now straightforward and user-defined
    • Slightly clumsy to access ApiDescription objects, but doable
      • We could add helpers making this easy
    • Might be harder for schemas because it might be hard for users to find all the places they can appear
      • Schemas can be processed before or after they are cached
        • Original proposal processed them before
        • This model would more readily process them after
  • We could preview an MVP API and flesh it out if people are writing a lot of boilerplate
  • Nit: context types should be sealed
  • With an interface-based design, there's a straightforward way to access services - do we need something similar for lambdas?
    • Seems valuable to pass it to the delegate
    • Emphasizes need for task-returning delegates
    • Service provider and cancellation token should probably be parameters (vs context members)
  • nswag and swashbuckle both use DI, rather than delegates
  • It sounds like we need more user feedback to decide how to balance these two approaches
  • Hypothetical usage: attribute API members and review them while transforming document
  • Types are easier to export from libraries
  • Going delegate-first doesn't preclude exposing interfaces later
  • Less stuff in DI might help perf

TODOs:
- change name
- document-only
- delegate-based registration (task-returning, service provider)
- seal contexts

API promising but needs work

@amcasey amcasey added api-needs-work API needs work before it is approved, it is NOT ready for implementation and removed api-suggestion Early API idea and discussion, it is NOT ready for implementation api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews labels Mar 27, 2024
@RicoSuter
Copy link

Should removal be first-class?
We don't know of a good use-case for this

in nswag you can exclude operations from operation processor. This is very valuable when generating multiple docs and you need to exclude ops based on special rules. Removing ops in docs filter is super tricky because you’d also need to cleanup unused (transitive) schemas.

@captainsafia
Copy link
Member Author

Should removal be first-class?
We don't know of a good use-case for this

in nswag you can exclude operations from operation processor. This is very valuable when generating multiple docs and you need to exclude ops based on special rules. Removing ops in docs filter is super tricky because you’d also need to cleanup unused (transitive) schemas.

@RicoSuter I believe the scenario you described here would be covered by the ShouldInclude option outlined in this API.

@captainsafia
Copy link
Member Author

captainsafia commented Mar 27, 2024

In response to today's API review, I'm making the following changes to the proposed API:

  • Rename Filters to Transformers everywhere
  • Seal all context types
  • Add ServiceProvider as a property to context objects
  • Expose delegate-based overloads for registering transforms
  • Ensure that transform delegates return a Task

New Proposed API

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

public interface IOpenApiDocumentTransformer
{
  public Task Transform(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken);
}
// Assembly: Microsoft.AspNetCore.OpenApi;
namespace Microsoft.AspNetCore.OpenApi;

public sealed class OpenApiDocumentTransformerContext
{
  public required string DocumentName { get; init; }
  public required IReadOnlyList<ApiDescriptionGroups> DescriptionGroups { get; init; }
  public required IServiceProvider ApplicationServices { get; init; } = EmptyServiceProvider.Instance;
}

public sealed class OpenApiOperationTransformerContext
{
  public required string DocumentName { get; init; }
  public required ApiDescription Description { get; init; }
  public required IServiceProvider ApplicationServices { get; init; } = EmptyServiceProvider.Instance;
}

public sealed class OpenApiSchemaTransformerContext
{
  public required string DocumentName { get; init; }
  public required Type Type { get; init; }
  public ApiParameterDescription? ParameterDescription { get; init; }
  public required IServiceProvider ApplicationServices { get; init; } = EmptyServiceProvider.Instance;
}
// Assembly: Microsoft.AspNetCore.OpenApi
namespace Microsoft.Extensions.Options;

public static class OpenApiOptionsExtensions
{
  public static OpenApiOptions UseTransformer<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TTransformerType>(this OpenApiOptions options)
        where TTransformerType : IOpenApiDocumentTransformer
  public static OpenApiOptions UseTransformer(this OpenApiOptions options, IOpenApiDocumentTransformer transformer)
  public static OpenApiOptions UseDocumentTransformer(this OpenApiOptions options, Func<OpenApiDocument, OpenApiDocumentTransformerContext, CancellationToken, Task> transformer);
  public static OpenApiOptions UseOperationTransformer(this OpenApiOptions options, Func<OpenApiOperation, OpenApiOperationTransformerContext, CancellationToken, Task> transformer);
  public static OpenApiOptions UseSchemaTransformer(this OpenApiOptions options, Func<OpenApiSchema, OpenApiSchemaTransformerContext, CancellationToken, Task> transformer);

}

New Usage Examples

var builder = WebApplication.CreateBuilder();

builder.Services.AddOpenApi(options =>
{
	options.UseTransform<AddBearerSecurityScheme>();
	options.UseOperationTransform((operation, context, cancellationToken) =>
   {
			var authorizations = context.Description.ActionDescriptor.EndpointMetadata.OfType<AuthorizeAttribute>();
      if (authorizations.Any())
      {
          openApiOperation.Security = [
              new OpenApiSecurityRequirement
              {
                  {
                      new OpenApiSecurityScheme
                      {
                          Reference = new OpenApiReference
                          {
                              Type = ReferenceType.SecurityScheme,
                              Id = "Bearer"
                          }
                      },
                      Array.Empty<string>()
                  }
              }
          ];
      };
      return Task.CompletedTask;
   });
  options.UseSchemaFilter((schema, context, cancellationToken) =>
  {
    if (context.Type == typeof(Shape))
    {
        schema.Example(JsonNode.Parse("{\"color\":\"red\",\"sides\":3}"));
    }
    return Task.CompletedTask;
	});
	options.AddHeader();
});

var app = builder.Build();

// Demonstrate accessing DI via constructor injection
public class AddBearerSecurityScheme(IAuthenticationSchemeProvider authenticationSchemeProvider) : IOpenApiDocumentFilter
{
    public async Task Transform(OpenApiDocument openApiDocument, OpenApiDocumentFilterContext context, CancellationToken cancellationToken)
    {
        var authenticationSchemes = (authenticationSchemeProvider is not null)
                ? await authenticationSchemeProvider.GetAllSchemesAsync()
                : Enumerable.Empty<AuthenticationScheme>();
        var requirements = authenticationSchemes
                .Where(authScheme => authScheme.Name == "Bearer")
                .ToDictionary(
                    (authScheme) => authScheme.Name,
                    (authScheme) => new OpenApiSecurityScheme
                    {
                        Type = SecuritySchemeType.Http,
                        Scheme = "bearer", // "bearer" refers to the header name here
                        In = ParameterLocation.Header,
                        BearerFormat = "Json Web Token"
                    });
        openApiDocument.Components.SecuritySchemes = requirements;
    }
}

// Demonstrates using extension methods to support transformer registration
public static OpenApiOptions AddHeader(this OpenApiOptions options, string headerName, string defaultValue)
{
	return options.UseOperationTransform((operation, context, cancellationToken) =>
	{
		operation.Parameters.Add(new OpenApiParameter
		{
			Name = headerName,
			In = ParameterLocation.Header,
			Schema = new OpenApiSchema
			{
				Type = "string",
				Default = new OpenApiAnyString(defaultValue)
			}
		});
    return Task.CompletedTask;
	})
}

@captainsafia captainsafia added api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews and removed api-needs-work API needs work before it is approved, it is NOT ready for implementation labels Mar 27, 2024
@RicoSuter
Copy link

RicoSuter commented Mar 27, 2024

Questions:

  • Is JsonSchemaBuilder from the external library JsonSchema.NET?
    • Shouldnt this be JsonSchema (it's also OpenApiOperation and not OpenApiOperationBuilder)?
    • (As soon as STJ provides a JsonSchema class itself you will have a big breaking change here)
  • In a transformer, how can I access the schema repository to retrieve or generate new schemas
    • Eg. I write a custom operation transformer which sets the OpenAPI response schema to a custom schema I dynamically generate from an existing type which is not yet in the schema store
      • If this custom type references other types which already have a schema in the store then it should use $refs to them

@captainsafia
Copy link
Member Author

captainsafia commented Mar 27, 2024

Is JsonSchemaBuilder from the external library JsonSchema.NET?

Yes.

Shouldnt this be JsonSchema (it's also OpenApiOperation and not OpenApiOperationBuilder)?

JsonSchema.NET uses the builder pattern for constructing schemas. The JsonSchema exchange type is not mutable so we expose JsonSchemaBuilder so users can modify the schema before we finalize it and set it in the document.

(As soon as STJ provides a JsonSchema class itself you will have a big breaking change here)

Yep, we're aware of this problem and are figuring out how to mitigate the issue. One option was to expose a shim type (e.g. JsonNode) in the in the user-facing APIs but there's no way we can get around the fact that the JsonSchema exchange type from JsonSchema.NET is embedded deeply in the OpenAPI document model.

EDIT: To make the API proposal truer to the current state of things, I've updated the schema filter signature to target OpenApiSchema. This is not the final API shape and will likely change before we ship .NET 9. When this happens, I'll file an API proposal with the planned modification.

In a transformer, how can I access the schema repository to retrieve or generate new schemas

You would access the OpenApiComponentService from the DI container via context.ApplicationServices.GetKeyedService<OpenApiComponentService>(context.DocumentName).

@amcasey
Copy link
Member

amcasey commented Mar 28, 2024

  • Don't need default values for required properties
  • We're not sure whether Document/Operation/Schema should be in the context
    • Basically pulled out because of how often it's accessed
  • Output-only schemas don't have parameter descriptions
  • Could there be an abtract base class for contexts?
    • Sure, but no strong argument either way
    • e.g. might want an API response type on the base class
      • This particular example doesn't seem compelling (esp for schemas)
      • Can be added later
  • Note that OpenApiOptions might be tweaked before released (based on partner team changes)
  • Would it be useful to have the Document when implementing a schema transformer?
    • We can think of use cases: operation transformers might use schemas as context
      • But they can just access that information directly via DI
      • context.ApplicationServices.GetKeyedService<OpenApiComponentService>(context.DocumentName)
  • Introspecting through refs is hard and affects our decision about passing additional OpenApi objects to individual lambdas
  • Transform should be TransformAsync
  • UseTransformer (non-generic) and UseDocumentTransformer should agree
  • Application order is call order
// Assembly: Microsoft.AspNetCore.OpenApi;
namespace Microsoft.AspNetCore.OpenApi;

+public interface IOpenApiDocumentTransformer
+{
+  public Task TransformAsync(OpenApiDocument document, OpenApiDocumentTransformerContext context, CancellationToken cancellationToken);
+}
// Assembly: Microsoft.AspNetCore.OpenApi;
namespace Microsoft.AspNetCore.OpenApi;

+public sealed class OpenApiDocumentTransformerContext
+{
+  public required string DocumentName { get; init; }
+  public required IReadOnlyList<ApiDescriptionGroups> DescriptionGroups { get; init; }
+  public required IServiceProvider ApplicationServices { get; init; }
+}

+public sealed class OpenApiOperationTransformerContext
+{
+  public required string DocumentName { get; init; }
+  public required ApiDescription Description { get; init; }
+  public required IServiceProvider ApplicationServices { get; init; }
+}
+
+public sealed class OpenApiSchemaTransformerContext
+{
+  public required string DocumentName { get; init; }
+  public required Type Type { get; init; }
+  public ApiParameterDescription? ParameterDescription { get; init; }
+  public required IServiceProvider ApplicationServices { get; init; }
+}
// Assembly: Microsoft.AspNetCore.OpenApi
namespace Microsoft.AspNetCore.OpenApi;

public class OpenApiOptions
{
+  public OpenApiOptions UseTransformer<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TTransformerType>()
+        where TTransformerType : IOpenApiDocumentTransformer
+  public OpenApiOptions UseTransformer(IOpenApiDocumentTransformer transformer)
+  public OpenApiOptions UseTransformer(Func<OpenApiDocument, OpenApiDocumentTransformerContext, CancellationToken, Task> transformer);
+  public OpenApiOptions UseOperationTransformer(Func<OpenApiOperation, OpenApiOperationTransformerContext, CancellationToken, Task> transformer);
+  public OpenApiOptions UseSchemaTransformer(Func<OpenApiSchema, OpenApiSchemaTransformerContext, CancellationToken, Task> transformer);
}

API approved! (Subject to minor changes to OpenApiSchema)

@amcasey amcasey 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 28, 2024
@amcasey
Copy link
Member

amcasey commented Mar 28, 2024

@captainsafia Note that we called an audible and made the extension methods into instance methods.

@dnperfors
Copy link
Contributor

I am very excited to see native support for OpenAPI comming to dotnet, good work. 👍

Concerning OpenAPI document filters I have a practical question.
In our application, we have basically two paths of approaching the application: Via an application gateway, which handles API-keys for us. and directly for internal usage (mostly for testing during development).

In our external /v1/openapi.json all operations need to have the header API-Key.
In our internal /v1-internal/openapi.json all operations need to have some other headers, but not API-Key.

Both internal and external api's have the same operations (and the internal one sometimes more).

With Swashbuckle we have to remove all not needed headers for every operation and add the headers we need whenever we switch document.
With NSwag this doesn't seem to be necessary, since for every document there seems to be a copy of the OpenAPI specification, so we can just add the header when it is not there yet.

As I understand it with .NET 9 a similar result can be achieved with these OpenAPI document filters, but how will they work on multiple documents? The Swashbuckle way (working on the same copy of object tree) or more like NSwag (working on a copy per document)

@captainsafia
Copy link
Member Author

@dnperfors Assuming I understand your scenario, you have an application configured with two distinct OpenAPI documents. With the model we're supporting it would look like this:

var builder = WebApplication.CreateBuilder();

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

var app = builder.Build();

app.MapOpenApi();

app.Run();

Transformers for each document are managed independently. So your configuration would end up looking something like this:

builder.Services.AddOpenApi("v1", options => {
	options.AddHeader("Api-Key", "foo");
});
builder.Services.AddOpenApi("v1-internal", options => {
	options.AddHeader("Some-Other-One", "bar");
});

public static OpenApiOptions AddHeader(this OpenApiOptions options, string headerName, string defaultValue)
{
	return options.UseOperationTransform((operation, context, cancellationToken) =>
	{
		operation.Parameters.Add(new OpenApiParameter
		{
			Name = headerName,
			In = ParameterLocation.Header,
			Schema = new OpenApiSchema
			{
				Type = "string",
				Default = new OpenApiAnyString(defaultValue)
			}
		});
    return Task.CompletedTask;
	})
}

One question: are the headers that you consume included in the methods associated with your actions (e.g. IActionResult Get([FromHeader] string apiKey)) or not?

@dnperfors
Copy link
Contributor

That is exactly what I was hoping for.
Most of these headers are used inside middleware or in an api gateway. The API-key for example will not reach the application itself.

@jesperkristensen
Copy link

One issue I had with ISchemaFilter in Swashbuckle in the past was that I could not find a good way to tell if a given schema was used in a request or a response, because the output I wanted depended on if the type should be serialized or deserialized. I know it is possible in OpenAPI to have schemas that are used in both places, but I don't do that in my APIs.

@captainsafia
Copy link
Member Author

@jesperkristensen I'm curious about the scenario you've described here. Are you using distinct .NET types to represent the requests/responses in your case (e.g. TodoRequest and TodoResponse)? What are the serialization differences that would materialize in the schema for this case?

For example, I can see a scenario where your support more content types in the request (application/xml, application/json) than the response, but that is not information that materializes in the schema.

@jesperkristensen
Copy link

For a property that allows null, by default ASP.NET Core allows a client to send a request where the property is missing or a request where the property is set to null. But for the same property in a response, the JSON serialization will always produce one or the other. I want my OpenAPI to be explicit about which of the representations my API uses in my response, so e.g. a TypeScript client would not be forced to handle both cases even when one of them can never happen, while still allowing the client to choose which option suits their needs best when making requests. Maybe not the best example, since I hope the OpenAPI implementation will do this correctly so I won't have to create a custom filter.

@captainsafia
Copy link
Member Author

@jesperkristensen Ah yes, I believe you're referring to the way that the implementation creates schemas by reference for certain types. For example, the schema that you would use for the a type in a discriminated mapping is different from the schema you'd use for the type on its own (one expects a $type property and the other doesn't). AFAIK, Your null case relates to the way that JsonIgnoreCondition is handled in STJ and the way MVC's model binding handles nullability. I'll try to add some test cases for this scenario to our test bed.

Tangentially, closing this issue for tracking purposes since these APIs have been implemented. There will be follow-up to the schema transformers layer in reaction to the STJ work but I intend on tracking that separately. Further conversation can happen in the main issue at #54598.

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

10 participants