-
Notifications
You must be signed in to change notification settings - Fork 10.3k
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
Comments
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 |
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. |
In this case, I assume you're only accessing the reference IDs used in the schema registry for a given type (e.g. 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 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.
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. |
For reference here are the filter/processor interfaces in NSwag:
General notes:
Question:
|
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:
something like this should also be possible |
@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
Yep, similar to other filters in the ASP.NET Core framework, I anticipate that these filters will be Task-based as well.
Yes, you'll be able to apply ISchemaFilter for schemas generated for parameters, request bodies, and responses.
At the moment, the
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 |
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 |
Nit: typo in the proposed API: - public interface IOpenApIOpenApiDocumentFilter : IFilter
+ public interface IOpenApiDocumentFilter : IFilter |
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.
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.
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. |
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. |
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
I've updated the proposal to include a context type for each filter. |
@captainsafia it would be amazing if we had some predefined methods to make adding filters simpler then we all set to go.
|
@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)
}
})
})
} |
TODOs: API promising but needs work |
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 |
In response to today's API review, I'm making the following changes to the proposed API:
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 Examplesvar 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;
})
} |
Questions:
|
Yes.
JsonSchema.NET uses the builder pattern for constructing schemas. The
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 EDIT: To make the API proposal truer to the current state of things, I've updated the schema filter signature to target
You would access the |
// 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 |
@captainsafia Note that we called an audible and made the extension methods into instance methods. |
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 external 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. 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) |
@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. |
That is exactly what I was hoping for. |
One issue I had with |
@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. For example, I can see a scenario where your support more content types in the request ( |
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. |
@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 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. |
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:Proposed API
Note: All APIs are net new.
Usage Examples
The following usage examples demonstrate leveraging this API to address a variety of scenarios requested by users.
Alternative Designs
IOpenApiDocumentFilter
, we could haveIInfoFilter
,ITagsFilter
, andIServersFilter
. Instead ofIOpenApiOperationFilter
, we could haveIParametersFilter
,IRequestBodyFilter
, and so on.Open Questions
The text was updated successfully, but these errors were encountered: