Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,40 @@ public class GetController : Controller2

5. After application started go to <http://localhost:5000/swagger/index.html> or <http://localhost:5000/swagger/v1/swagger.json> to see generated Swagger

## Configuration

### Bearer security scheme

When your controllers use authorization (`[Authorize]`), `Simplify.Web.Swagger` automatically adds security requirements to the corresponding operations using **all security schemes registered via `AddSecurityDefinition`** — no extra configuration needed:

```csharp
x.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { ... });
x.AddSimplifyWebSwagger(); // Bearer requirement is added automatically
```

To restrict to a specific scheme name (e.g. when you have multiple definitions but want only one applied), set `SecuritySchemeName` explicitly:

```csharp
x.AddSimplifyWebSwagger(new SimplifyWebSwaggerArgs
{
SecuritySchemeName = "Bearer"
});
```

### JSON property casing

By default, Swagger schema property names follow the `System.Text.Json` default casing (PascalCase). To use camelCase (matching `JsonSerializerDefaults.Web`), register a custom `ISerializerDataContractResolver`:

```csharp
builder.Services
.AddSingleton<ISerializerDataContractResolver>(_ =>
new JsonSerializerDataContractResolver(
new JsonSerializerOptions(JsonSerializerDefaults.Web)
))
.AddEndpointsApiExplorer()
.AddSwaggerGen(x => x.AddSimplifyWebSwagger());
```

## Example application

Below is the example of Swagger generated by `Simplify.Web.Swagger`:
Expand Down
30 changes: 30 additions & 0 deletions src/Simplify.Web.Swagger/AcceptLanguageHeaderArgs.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Collections.Generic;

namespace Simplify.Web.Swagger;

/// <summary>
/// Configuration for the Accept-Language header parameter added to all operations.
/// </summary>
public class AcceptLanguageHeaderArgs
{
/// <summary>
/// Initializes a new instance with the specified supported languages.
/// The first entry is used as the default value.
/// </summary>
/// <param name="languages">Supported language codes, e.g. "en-US", "ru-RU".</param>
public AcceptLanguageHeaderArgs(params string[] languages)
{
Languages = languages;
Default = languages.Length > 0 ? languages[0] : string.Empty;
}

/// <summary>
/// The list of supported language codes.
/// </summary>
public IReadOnlyList<string> Languages { get; }

/// <summary>
/// The default language code (defaults to the first entry in <see cref="Languages"/>).
/// </summary>
public string Default { get; set; }
}
14 changes: 14 additions & 0 deletions src/Simplify.Web.Swagger/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,24 @@

## [1.3.0] - Unreleased

### Added

- .NET 10.0 explicit support
- `AcceptLanguageHeaderArgs` — configures an `Accept-Language` header parameter added to all Swagger operations
- `SimplifyWebSwaggerArgs.AcceptLanguageHeader` — enables Accept-Language header with supported languages list
- `SimplifyWebSwaggerArgs.SecuritySchemeName` — explicit override for security scheme name; when `null` (default), all schemes registered via `AddSecurityDefinition` are applied automatically to authorized operations
- Route parameter types auto-detection from controller `Invoke`/`InvokeAsync` method signatures (typed path parameters in Swagger schema)
- `AddSimplifyWebSwaggerServices` extension method — registers PascalCase `ISerializerDataContractResolver` before `AddSwaggerGen`

### Changed

- Security requirements on authorized operations are now auto-detected from registered `AddSecurityDefinition` schemes; no manual `SecuritySchemeName` configuration needed

### Dependencies

- Simplify.Web bump to 5.2
- Asp.Versioning.Mvc bump to 8.1.1
- Swashbuckle.AspNetCore.SwaggerGen bump to 10.2.1 (net10.0)

## [1.2.0] - 2025-10-10

Expand Down
15 changes: 15 additions & 0 deletions src/Simplify.Web.Swagger/ControllerAction.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
using System;
using System.Collections.Generic;
#if NET10_0
using Microsoft.OpenApi;
using NetHttpMethod = System.Net.Http.HttpMethod;
#else
using Microsoft.OpenApi.Models;
#endif
using Simplify.Web.Controllers.Meta.Routing;

namespace Simplify.Web.Swagger;
Expand Down Expand Up @@ -29,13 +34,23 @@ public class ControllerAction
/// </value>
public IDictionary<int, OpenApiResponse> Responses { get; set; } = new Dictionary<int, OpenApiResponse>();

/// <summary>
/// Gets or sets the route parameter types, keyed by parameter name (case-insensitive).
/// </summary>
public IDictionary<string, Type> RouteParameterTypes { get; set; } =
new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);

/// <summary>
/// Gets or sets the type.
/// </summary>
/// <value>
/// The type.
/// </value>
#if NET10_0
public NetHttpMethod Type { get; set; } = NetHttpMethod.Get;
#else
public OperationType Type { get; set; }
#endif

/// <summary>
/// Gets the path.
Expand Down
159 changes: 116 additions & 43 deletions src/Simplify.Web.Swagger/ControllerActionsFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.OpenApi.Models;
using Simplify.Web.Controllers.Meta;
using Simplify.Web.Controllers.Meta.MetaStore;
using Simplify.Web.Controllers.Meta.Routing;
using Simplify.Web.Http;
using Swashbuckle.AspNetCore.SwaggerGen;
#if NET10_0
using Microsoft.OpenApi;
using NetHttpMethod = System.Net.Http.HttpMethod;
#else
using Microsoft.OpenApi.Models;
#endif

namespace Simplify.Web.Swagger;

Expand All @@ -16,18 +21,17 @@ namespace Simplify.Web.Swagger;
/// </summary>
public static class ControllerActionsFactory
{
private static readonly IReadOnlyCollection<KeyValuePair<string, string>> ResponseDescriptionMap =
private static readonly IReadOnlyCollection<
KeyValuePair<string, string>
> ResponseDescriptionMap =
[
new KeyValuePair<string, string>("1\\d{2}", "Information"),

new KeyValuePair<string, string>("201", "Created"),
new KeyValuePair<string, string>("202", "Accepted"),
new KeyValuePair<string, string>("204", "No Content"),
new KeyValuePair<string, string>("2\\d{2}", "Success"),

new KeyValuePair<string, string>("304", "Not Modified"),
new KeyValuePair<string, string>("3\\d{2}", "Redirect"),

new KeyValuePair<string, string>("400", "Bad Request"),
new KeyValuePair<string, string>("401", "Unauthorized"),
new KeyValuePair<string, string>("403", "Forbidden"),
Expand All @@ -38,9 +42,8 @@ public static class ControllerActionsFactory
new KeyValuePair<string, string>("409", "Conflict"),
new KeyValuePair<string, string>("429", "Too Many Requests"),
new KeyValuePair<string, string>("4\\d{2}", "Client Error"),

new KeyValuePair<string, string>("5\\d{2}", "Server Error"),
new KeyValuePair<string, string>("default", "Error")
new KeyValuePair<string, string>("default", "Error"),
];

/// <summary>
Expand All @@ -49,38 +52,49 @@ public static class ControllerActionsFactory
/// <value>
/// The remove prefixes.
/// </value>
public static IList<string> RemovePrefixes { get; } =
[
"Controllers.",
"Api.v1."
];
public static IList<string> RemovePrefixes { get; } = ["Controllers.", "Api.v1."];

/// <summary>
/// Creates the controller actions from controllers metadata.
/// </summary>
/// <param name="context">The context.</param>
public static IEnumerable<ControllerAction> CreateControllerActionsFromControllersMetaData(DocumentFilterContext context) =>
ControllersMetaStore.Current.RoutedControllers
.SelectMany(item => CreateControllerActions(item, context));

private static IEnumerable<ControllerAction> CreateControllerActions(IControllerMetadata item, DocumentFilterContext context) =>
item.ExecParameters!
.Routes
.Select(x => CreateControllerAction(x.Key, x.Value, item, context));

private static ControllerAction CreateControllerAction(HttpMethod method, IControllerRoute route, IControllerMetadata item, DocumentFilterContext context) =>
public static IEnumerable<ControllerAction> CreateControllerActionsFromControllersMetaData(
DocumentFilterContext context
) =>
ControllersMetaStore.Current.RoutedControllers.SelectMany(item =>
CreateControllerActions(item, context)
);

private static IEnumerable<ControllerAction> CreateControllerActions(
IControllerMetadata item,
DocumentFilterContext context
) =>
item.ExecParameters!.Routes.Select(x =>
CreateControllerAction(x.Key, x.Value, item, context)
);

private static ControllerAction CreateControllerAction(
HttpMethod method,
IControllerRoute route,
IControllerMetadata item,
DocumentFilterContext context
) =>
new()
{
Type = HttpMethodToOperationType(method),
ControllerRoute = route,
Names = CreateNames(item.ControllerType),
Responses = CreateResponses(item.ControllerType, context),
RequestBody = CreateRequestBody(item.ControllerType, context),
IsAuthorizationRequired = item.Security is { IsAuthorizationRequired: true }
IsAuthorizationRequired = item.Security is { IsAuthorizationRequired: true },
RouteParameterTypes = GetRouteParameterTypes(item.ControllerType),
};

private static ControllerActionNames CreateNames(Type controllerType) =>
CreateNames(controllerType.FullName ?? throw new InvalidOperationException("controllerType.FullName is null"));
CreateNames(
controllerType.FullName
?? throw new InvalidOperationException("controllerType.FullName is null")
);

private static ControllerActionNames CreateNames(string name)
{
Expand Down Expand Up @@ -113,19 +127,36 @@ private static string FormatNameSource(string str)
return str;
}

private static OperationType HttpMethodToOperationType(HttpMethod method) =>
#if NET10_0
private static NetHttpMethod HttpMethodToOperationType(HttpMethod method) =>
method switch
{
HttpMethod.Get => OperationType.Get,
HttpMethod.Post => OperationType.Post,
HttpMethod.Put => OperationType.Put,
HttpMethod.Patch => OperationType.Patch,
HttpMethod.Delete => OperationType.Delete,
HttpMethod.Options => OperationType.Options,
_ => OperationType.Get
HttpMethod.Get => NetHttpMethod.Get,
HttpMethod.Post => NetHttpMethod.Post,
HttpMethod.Put => NetHttpMethod.Put,
HttpMethod.Patch => NetHttpMethod.Patch,
HttpMethod.Delete => NetHttpMethod.Delete,
HttpMethod.Options => NetHttpMethod.Options,
_ => NetHttpMethod.Get,
};

private static OpenApiRequestBody CreateRequestBody(Type controllerType, DocumentFilterContext context)
#else
private static OperationType HttpMethodToOperationType(HttpMethod method) =>
method switch
{
HttpMethod.Get => OperationType.Get,
HttpMethod.Post => OperationType.Post,
HttpMethod.Put => OperationType.Put,
HttpMethod.Patch => OperationType.Patch,
HttpMethod.Delete => OperationType.Delete,
HttpMethod.Options => OperationType.Options,
_ => OperationType.Get,
};
#endif

private static OpenApiRequestBody CreateRequestBody(
Type controllerType,
DocumentFilterContext context
)
{
var request = new OpenApiRequestBody();
var attributes = controllerType.GetCustomAttributes(typeof(RequestBodyAttribute), false);
Expand All @@ -137,31 +168,73 @@ private static OpenApiRequestBody CreateRequestBody(Type controllerType, Documen

request.Content = new Dictionary<string, OpenApiMediaType>
{
[item.ContentType] = new() { Schema = context.SchemaGenerator.GenerateSchema(item.Model, context.SchemaRepository) }
[item.ContentType] = new()
{
Schema = context.SchemaGenerator.GenerateSchema(
item.Model,
context.SchemaRepository
),
},
};

return request;
}

private static IDictionary<int, OpenApiResponse> CreateResponses(Type controllerType, DocumentFilterContext context) =>
controllerType.GetCustomAttributes(typeof(ProducesResponseAttribute), false)
private static IDictionary<int, OpenApiResponse> CreateResponses(
Type controllerType,
DocumentFilterContext context
) =>
controllerType
.GetCustomAttributes(typeof(ProducesResponseAttribute), false)
.Cast<ProducesResponseAttribute>()
.ToDictionary(item => item.StatusCode, item => CreateResponse(item, context));

private static OpenApiResponse CreateResponse(ProducesResponseAttribute producesResponse, DocumentFilterContext context)
private static OpenApiResponse CreateResponse(
ProducesResponseAttribute producesResponse,
DocumentFilterContext context
)
{
var response = new OpenApiResponse
{
Description = ResponseDescriptionMap
.FirstOrDefault((entry) => Regex.IsMatch(producesResponse.StatusCode.ToString(), entry.Key))
.Value
.FirstOrDefault(
(entry) => Regex.IsMatch(producesResponse.StatusCode.ToString(), entry.Key)
)
.Value,
};

foreach (var item in producesResponse.ContentTypes.Distinct())
response.Content.Add(item, producesResponse.Type is null
? new OpenApiMediaType()
: new OpenApiMediaType { Schema = context.SchemaGenerator.GenerateSchema(producesResponse.Type, context.SchemaRepository) });
{
#if NET10_0
response.Content ??= new Dictionary<string, OpenApiMediaType>();
#endif
response.Content.Add(
item,
producesResponse.Type is null
? new OpenApiMediaType()
: new OpenApiMediaType
{
Schema = context.SchemaGenerator.GenerateSchema(
producesResponse.Type,
context.SchemaRepository
),
}
);
}

return response;
}

private static IDictionary<string, Type> GetRouteParameterTypes(Type controllerType)
{
var method = controllerType.GetMethod("Invoke") ?? controllerType.GetMethod("InvokeAsync");

if (method is null)
return new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);

return method
.GetParameters()
.Where(p => p.Name != null)
.ToDictionary(p => p.Name!, p => p.ParameterType, StringComparer.OrdinalIgnoreCase);
}
}
Loading
Loading