From 9490f6cb323518ee05a3e5153540dd547beb79b0 Mon Sep 17 00:00:00 2001 From: lapych Date: Fri, 5 Jun 2026 08:53:45 +0500 Subject: [PATCH 1/7] [add] add net10 support --- .editorconfig | 162 ++++++++++++++++++ src/Simplify.Web.Swagger/ControllerAction.cs | 9 + .../ControllerActionsFactory.cs | 26 +++ .../EnumNamesSchemaFilter.cs | 32 +++- .../Simplify.Web.Swagger.csproj | 11 +- .../SimplifyWebDocumentFilter.cs | 33 +++- .../SimplifyWebSwaggerArgs.cs | 4 + src/TesterApp/Program.cs | 26 +-- src/TesterApp/TesterApp.csproj | 4 +- 9 files changed, 274 insertions(+), 33 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..35c7ffe --- /dev/null +++ b/.editorconfig @@ -0,0 +1,162 @@ +root = true + +[*] +end_of_line = crlf +indent_style = tab +insert_final_newline = false +resharper_insert_final_newline = false +trim_trailing_whitespace = true + +[*.cs] +indent_size = 4 +tab_width = 4 + +# New-line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = flush_left +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents_when_block = false + +# Spacing +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false + +# Wrapping +csharp_preserve_single_line_statements = false +csharp_preserve_single_line_blocks = true + +# var preferences — use explicit type for clarity (Air Astana standard) +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_indexers = true:suggestion +csharp_style_expression_bodied_accessors = true:suggestion +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent + +# Pattern matching +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_prefer_pattern_matching = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion + +# Null checks +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion + +# Modifier preferences +csharp_prefer_static_local_function = true:suggestion +csharp_preferred_modifier_order = public, private, protected, internal, file, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, required, volatile, async:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion +dotnet_style_readonly_field = true:suggestion + +# using directives +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +csharp_using_directive_placement = outside_namespace:warning + +# Namespace style +csharp_style_namespace_declarations = file_scoped:warning + +# Object initializers +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion + +# Tuple / anonymous types +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion + +# Miscellaneous +csharp_prefer_braces = when_multiline:silent +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# Naming rules — Air Astana: PascalCase public, _camelCase private fields +dotnet_naming_rule.private_fields_should_be_camel_case.severity = warning +dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields +dotnet_naming_rule.private_fields_should_be_camel_case.style = underscore_camel_case_style + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +dotnet_naming_style.underscore_camel_case_style.capitalization = camel_case +dotnet_naming_style.underscore_camel_case_style.required_prefix = _ + +dotnet_naming_rule.interfaces_should_start_with_i.severity = warning +dotnet_naming_rule.interfaces_should_start_with_i.symbols = interfaces +dotnet_naming_rule.interfaces_should_start_with_i.style = i_prefix_pascal_case_style + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_style.i_prefix_pascal_case_style.capitalization = pascal_case +dotnet_naming_style.i_prefix_pascal_case_style.required_prefix = I + +dotnet_naming_rule.public_members_pascal_case.severity = warning +dotnet_naming_rule.public_members_pascal_case.symbols = public_members +dotnet_naming_rule.public_members_pascal_case.style = pascal_case_style + +dotnet_naming_symbols.public_members.applicable_kinds = property, method, field, event +dotnet_naming_symbols.public_members.applicable_accessibilities = public, internal, protected, protected_internal +dotnet_naming_style.pascal_case_style.capitalization = pascal_case + +# Roslyn analyzer severities +dotnet_diagnostic.CA1822.severity = suggestion # Mark members as static +dotnet_diagnostic.CA1848.severity = none # Use LoggerMessage delegates (too verbose for this stack) +dotnet_diagnostic.CA2007.severity = none # ConfigureAwait (not needed in ASP.NET Core) +dotnet_diagnostic.CS8618.severity = warning # Non-nullable field uninitialized +dotnet_diagnostic.CS8600.severity = warning # Converting null literal +dotnet_diagnostic.CS8601.severity = warning # Possible null reference assignment +dotnet_diagnostic.CS8602.severity = warning # Dereference of possibly null reference +dotnet_diagnostic.CS8603.severity = warning # Possible null reference return +dotnet_diagnostic.IDE0005.severity = warning # Remove unnecessary using +dotnet_diagnostic.IDE0055.severity = warning # Fix formatting +dotnet_diagnostic.IDE0160.severity = none # Namespace style (handled by csharp_style_namespace_declarations) + +[*.{json,yaml,yml}] +indent_size = 2 + +[*.xml] +indent_size = 2 + +[*.{csproj,props,targets}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/src/Simplify.Web.Swagger/ControllerAction.cs b/src/Simplify.Web.Swagger/ControllerAction.cs index 8e1984b..1c59a52 100644 --- a/src/Simplify.Web.Swagger/ControllerAction.cs +++ b/src/Simplify.Web.Swagger/ControllerAction.cs @@ -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; @@ -35,7 +40,11 @@ public class ControllerAction /// /// The type. /// +#if NET10_0 + public NetHttpMethod Type { get; set; } = NetHttpMethod.Get; +#else public OperationType Type { get; set; } +#endif /// /// Gets the path. diff --git a/src/Simplify.Web.Swagger/ControllerActionsFactory.cs b/src/Simplify.Web.Swagger/ControllerActionsFactory.cs index d172eb2..7b1784d 100644 --- a/src/Simplify.Web.Swagger/ControllerActionsFactory.cs +++ b/src/Simplify.Web.Swagger/ControllerActionsFactory.cs @@ -2,12 +2,19 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +#if NET10_0 +using Microsoft.OpenApi; +#else using Microsoft.OpenApi.Models; +#endif 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 NetHttpMethod = System.Net.Http.HttpMethod; +#endif namespace Simplify.Web.Swagger; @@ -113,6 +120,19 @@ private static string FormatNameSource(string str) return str; } +#if NET10_0 + private static NetHttpMethod HttpMethodToOperationType(HttpMethod method) => + method switch + { + 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 + }; +#else private static OperationType HttpMethodToOperationType(HttpMethod method) => method switch { @@ -124,6 +144,7 @@ private static OperationType HttpMethodToOperationType(HttpMethod method) => HttpMethod.Options => OperationType.Options, _ => OperationType.Get }; +#endif private static OpenApiRequestBody CreateRequestBody(Type controllerType, DocumentFilterContext context) { @@ -158,9 +179,14 @@ private static OpenApiResponse CreateResponse(ProducesResponseAttribute produces }; foreach (var item in producesResponse.ContentTypes.Distinct()) + { +#if NET10_0 + response.Content ??= new Dictionary(); +#endif response.Content.Add(item, producesResponse.Type is null ? new OpenApiMediaType() : new OpenApiMediaType { Schema = context.SchemaGenerator.GenerateSchema(producesResponse.Type, context.SchemaRepository) }); + } return response; } diff --git a/src/Simplify.Web.Swagger/EnumNamesSchemaFilter.cs b/src/Simplify.Web.Swagger/EnumNamesSchemaFilter.cs index 7d49d5b..3bfd622 100644 --- a/src/Simplify.Web.Swagger/EnumNamesSchemaFilter.cs +++ b/src/Simplify.Web.Swagger/EnumNamesSchemaFilter.cs @@ -1,7 +1,12 @@ using System; -using System.Linq; +using System.Collections.Generic; +#if NET10_0 +using System.Text.Json.Nodes; +using Microsoft.OpenApi; +#else using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Models; +#endif using Swashbuckle.AspNetCore.SwaggerGen; namespace Simplify.Web.Swagger; @@ -12,18 +17,33 @@ namespace Simplify.Web.Swagger; public class EnumNamesSchemaFilter : ISchemaFilter { /// - public void Apply(OpenApiSchema schema, SchemaFilterContext context) +#if NET10_0 + public void Apply(IOpenApiSchema schema, SchemaFilterContext context) { if (!context.Type.IsEnum) return; - var names = Enum.GetNames(context.Type); - var values = Enum.GetValues(context.Type).Cast().ToArray(); + if (schema is not OpenApiSchema concreteSchema) + return; + + var varnames = new JsonArray(); + foreach (var name in Enum.GetNames(context.Type)) + varnames.Add(name); + + concreteSchema.Extensions ??= new Dictionary(); + concreteSchema.Extensions["names"] = new JsonNodeExtension(varnames); + } +#else + public void Apply(OpenApiSchema schema, SchemaFilterContext context) + { + if (!context.Type.IsEnum) + return; var varnames = new OpenApiArray(); - foreach (var name in names) + foreach (var name in Enum.GetNames(context.Type)) varnames.Add(new OpenApiString(name)); schema.Extensions["names"] = varnames; } -} \ No newline at end of file +#endif +} diff --git a/src/Simplify.Web.Swagger/Simplify.Web.Swagger.csproj b/src/Simplify.Web.Swagger/Simplify.Web.Swagger.csproj index b4e32be..aa644c6 100644 --- a/src/Simplify.Web.Swagger/Simplify.Web.Swagger.csproj +++ b/src/Simplify.Web.Swagger/Simplify.Web.Swagger.csproj @@ -1,6 +1,6 @@  - net9.0;net8.0 + net10.0;net9.0;net8.0 latest enable nullable @@ -24,12 +24,17 @@ See https://github.com/SimplifyNet/Simplify.Web.Swagger/tree/master/src/Simplify.Web.Swagger/CHANGELOG.md for details - + + + + + + + - diff --git a/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs b/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs index f976a5b..20b3028 100644 --- a/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs +++ b/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs @@ -1,6 +1,11 @@ using System.Collections.Generic; using System.Linq; +#if NET10_0 +using Microsoft.OpenApi; +#else +using System.Net.Http; using Microsoft.OpenApi.Models; +#endif using Simplify.Web.Controllers.Meta.Routing; using Swashbuckle.AspNetCore.SwaggerGen; @@ -40,6 +45,18 @@ public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) swaggerDoc?.Paths.Add(item.Key, item.Value); } +#if NET10_0 + private static IList CreateParameters(IControllerRoute path) => + path.Items + .Where(x => x is PathParameter) + .Cast() + .Select(x => (IOpenApiParameter)new OpenApiParameter + { + Name = x.Name, + In = ParameterLocation.Path, + AllowEmptyValue = false + }).ToList(); +#else private static IList CreateParameters(IControllerRoute path) => path.Items .Where(x => x is PathParameter) @@ -50,6 +67,7 @@ private static IList CreateParameters(IControllerRoute path) = In = ParameterLocation.Path, AllowEmptyValue = false }).ToList(); +#endif private OpenApiPathItem CreatePathItem(IEnumerable actions) { @@ -65,16 +83,23 @@ private OpenApiOperation CreateOperation(ControllerAction item) { var operation = new OpenApiOperation(); - operation.Tags.Add(new OpenApiTag - { - Name = item.Names.GroupName - }); +#if NET10_0 + operation.Tags ??= new HashSet(); + operation.Tags.Add(new OpenApiTagReference(item.Names.GroupName)); +#else + operation.Tags.Add(new OpenApiTag { Name = item.Names.GroupName }); +#endif if (item.Names.Summary != null) operation.Summary = item.Names.Summary; foreach (var response in item.Responses) + { +#if NET10_0 + operation.Responses ??= new OpenApiResponses(); +#endif operation.Responses.Add(response.Key.ToString(), response.Value); + } operation.Parameters = CreateParameters(item.ControllerRoute); operation.RequestBody = item.RequestBody; diff --git a/src/Simplify.Web.Swagger/SimplifyWebSwaggerArgs.cs b/src/Simplify.Web.Swagger/SimplifyWebSwaggerArgs.cs index 785d283..5e63020 100644 --- a/src/Simplify.Web.Swagger/SimplifyWebSwaggerArgs.cs +++ b/src/Simplify.Web.Swagger/SimplifyWebSwaggerArgs.cs @@ -1,5 +1,9 @@ using System.Collections.Generic; +#if NET10_0 +using Microsoft.OpenApi; +#else using Microsoft.OpenApi.Models; +#endif namespace Simplify.Web.Swagger; diff --git a/src/TesterApp/Program.cs b/src/TesterApp/Program.cs index 0b5e52d..4e5db3c 100644 --- a/src/TesterApp/Program.cs +++ b/src/TesterApp/Program.cs @@ -1,5 +1,5 @@ -using Microsoft.OpenApi.Any; -using Microsoft.OpenApi.Models; +using System.Text.Json.Nodes; +using Microsoft.OpenApi; using Simplify.DI; using Simplify.Web; using Simplify.Web.Swagger; @@ -26,20 +26,10 @@ Description = "Input your Bearer token in this format - Bearer {your token here} to access this API", }); - x.AddSecurityRequirement(new OpenApiSecurityRequirement + x.AddSecurityRequirement(_ => new OpenApiSecurityRequirement { { - new OpenApiSecurityScheme - { - Reference = new OpenApiReference - { - Type = ReferenceType.SecurityScheme, - Id = "Bearer", - }, - Scheme = "Bearer", - Name = "Bearer", - In = ParameterLocation.Header, - }, + new OpenApiSecuritySchemeReference("Bearer"), new List() } }); @@ -53,14 +43,14 @@ Description = "Language preference for the response.", Required = true, AllowEmptyValue = true, - Example = new OpenApiString("en-US"), + Example = "en-US", Schema = new OpenApiSchema { - Default = new OpenApiString("en-US"), + Default = "en-US", Enum = [ - new OpenApiString("en-US"), - new OpenApiString("ru-RU") + (JsonNode)"en-US", + (JsonNode)"ru-RU" ] } }; diff --git a/src/TesterApp/TesterApp.csproj b/src/TesterApp/TesterApp.csproj index 42781a3..ab75d6d 100644 --- a/src/TesterApp/TesterApp.csproj +++ b/src/TesterApp/TesterApp.csproj @@ -7,7 +7,7 @@ - - + + \ No newline at end of file From c40bd0008e9e8be343d1c3933f4bf7abdaf48514 Mon Sep 17 00:00:00 2001 From: lapych Date: Fri, 5 Jun 2026 10:33:53 +0500 Subject: [PATCH 2/7] [add] openapi scheme generation update --- .../EnumNamesSchemaFilter.cs | 24 +++- .../SimplifyWebDocumentFilter.cs | 131 ++++++++++++++---- .../SimplifyWebSwaggerArgs.cs | 7 + src/TesterApp/Program.cs | 52 +++---- src/TesterApp/TesterApp.csproj | 9 +- 5 files changed, 156 insertions(+), 67 deletions(-) diff --git a/src/Simplify.Web.Swagger/EnumNamesSchemaFilter.cs b/src/Simplify.Web.Swagger/EnumNamesSchemaFilter.cs index 3bfd622..3d16a4d 100644 --- a/src/Simplify.Web.Swagger/EnumNamesSchemaFilter.cs +++ b/src/Simplify.Web.Swagger/EnumNamesSchemaFilter.cs @@ -1,13 +1,14 @@ using System; -using System.Collections.Generic; +using System.Linq; +using Swashbuckle.AspNetCore.SwaggerGen; #if NET10_0 +using System.Collections.Generic; using System.Text.Json.Nodes; using Microsoft.OpenApi; #else using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Models; #endif -using Swashbuckle.AspNetCore.SwaggerGen; namespace Simplify.Web.Swagger; @@ -26,12 +27,16 @@ public void Apply(IOpenApiSchema schema, SchemaFilterContext context) if (schema is not OpenApiSchema concreteSchema) return; + var names = Enum.GetNames(context.Type); + var values = Enum.GetValues(context.Type).Cast().ToArray(); + var varnames = new JsonArray(); - foreach (var name in Enum.GetNames(context.Type)) + foreach (var name in names) varnames.Add(name); concreteSchema.Extensions ??= new Dictionary(); - concreteSchema.Extensions["names"] = new JsonNodeExtension(varnames); + concreteSchema.Extensions["x-varnames"] = new JsonNodeExtension(varnames); + concreteSchema.Description = BuildDescription(names, values); } #else public void Apply(OpenApiSchema schema, SchemaFilterContext context) @@ -39,11 +44,18 @@ public void Apply(OpenApiSchema schema, SchemaFilterContext context) if (!context.Type.IsEnum) return; + var names = Enum.GetNames(context.Type); + var values = Enum.GetValues(context.Type).Cast().ToArray(); + var varnames = new OpenApiArray(); - foreach (var name in Enum.GetNames(context.Type)) + foreach (var name in names) varnames.Add(new OpenApiString(name)); - schema.Extensions["names"] = varnames; + schema.Extensions["x-varnames"] = varnames; + schema.Description = BuildDescription(names, values); } #endif + + private static string BuildDescription(string[] names, object[] values) => + string.Join(", ", names.Select((name, i) => $"{Convert.ToInt64(values[i])} = {name}")); } diff --git a/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs b/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs index 20b3028..6f1b977 100644 --- a/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs +++ b/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs @@ -1,13 +1,13 @@ +using System; using System.Collections.Generic; using System.Linq; +using Simplify.Web.Controllers.Meta.Routing; +using Swashbuckle.AspNetCore.SwaggerGen; #if NET10_0 using Microsoft.OpenApi; #else -using System.Net.Http; using Microsoft.OpenApi.Models; #endif -using Simplify.Web.Controllers.Meta.Routing; -using Swashbuckle.AspNetCore.SwaggerGen; namespace Simplify.Web.Swagger; @@ -22,9 +22,7 @@ public class SimplifyWebDocumentFilter : IDocumentFilter /// /// Initializes an instance of . /// - public SimplifyWebDocumentFilter() - { - } + public SimplifyWebDocumentFilter() { } /// /// Initializes an instance of . @@ -39,47 +37,66 @@ public SimplifyWebDocumentFilter() /// The context public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) { - foreach (var item in ControllerActionsFactory.CreateControllerActionsFromControllersMetaData(context) - .GroupBy(x => x.Path) - .Select(x => new KeyValuePair(x.Key, CreatePathItem(x)))) - swaggerDoc?.Paths.Add(item.Key, item.Value); + var controllerActions = ControllerActionsFactory + .CreateControllerActionsFromControllersMetaData(context) + .ToList(); + + foreach ( + var item in controllerActions + .GroupBy(x => x.Path) + .Select(x => new KeyValuePair( + x.Key, + CreatePathItem(x, swaggerDoc) + )) + ) + swaggerDoc.Paths.Add(item.Key, item.Value); + + PopulateDocumentTags(swaggerDoc, controllerActions); } #if NET10_0 private static IList CreateParameters(IControllerRoute path) => - path.Items - .Where(x => x is PathParameter) + path + .Items.Where(x => x is PathParameter) .Cast() - .Select(x => (IOpenApiParameter)new OpenApiParameter - { - Name = x.Name, - In = ParameterLocation.Path, - AllowEmptyValue = false - }).ToList(); + .Select(x => + (IOpenApiParameter) + new OpenApiParameter + { + Name = x.Name, + In = ParameterLocation.Path, + AllowEmptyValue = false, + } + ) + .ToList(); #else private static IList CreateParameters(IControllerRoute path) => - path.Items - .Where(x => x is PathParameter) + path + .Items.Where(x => x is PathParameter) .Cast() .Select(x => new OpenApiParameter { Name = x.Name, In = ParameterLocation.Path, - AllowEmptyValue = false - }).ToList(); + AllowEmptyValue = false, + }) + .ToList(); #endif - private OpenApiPathItem CreatePathItem(IEnumerable actions) + private OpenApiPathItem CreatePathItem( + IEnumerable actions, + OpenApiDocument swaggerDoc + ) { var pathItem = new OpenApiPathItem(); foreach (var item in actions) - pathItem.AddOperation(item.Type, CreateOperation(item)); + pathItem.AddOperation(item.Type, CreateOperation(item, swaggerDoc)); return pathItem; } - private OpenApiOperation CreateOperation(ControllerAction item) + private OpenApiOperation CreateOperation(ControllerAction item, OpenApiDocument swaggerDoc) { var operation = new OpenApiOperation(); @@ -102,7 +119,41 @@ private OpenApiOperation CreateOperation(ControllerAction item) } operation.Parameters = CreateParameters(item.ControllerRoute); - operation.RequestBody = item.RequestBody; + + if (item.RequestBody.Content is { Count: > 0 }) + operation.RequestBody = item.RequestBody; + +#if NET10_0 + if ( + item.IsAuthorizationRequired && _args?.SecuritySchemeName is { } securitySchemeNameNet10 + ) + operation.Security = + [ + new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference(securitySchemeNameNet10, swaggerDoc)] = [], + }, + ]; +#else + if (item.IsAuthorizationRequired && _args?.SecuritySchemeName is { } securitySchemeName) + operation.Security = new List + { + new() + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = securitySchemeName, + }, + }, + new List() + }, + }, + }; +#endif if (_args == null) return operation; @@ -112,4 +163,30 @@ private OpenApiOperation CreateOperation(ControllerAction item) return operation; } -} \ No newline at end of file + + private static void PopulateDocumentTags( + OpenApiDocument swaggerDoc, + IEnumerable actions + ) + { +#if NET10_0 + var existingNames = + swaggerDoc.Tags?.Select(t => t.Name).ToHashSet(StringComparer.Ordinal) ?? []; +#else + var existingNames = swaggerDoc.Tags.Select(t => t.Name).ToHashSet(StringComparer.Ordinal); +#endif + + foreach ( + var name in actions + .Select(x => x.Names.GroupName) + .Distinct() + .Where(n => !existingNames.Contains(n)) + ) + { +#if NET10_0 + swaggerDoc.Tags ??= new HashSet(); +#endif + swaggerDoc.Tags.Add(new OpenApiTag { Name = name }); + } + } +} diff --git a/src/Simplify.Web.Swagger/SimplifyWebSwaggerArgs.cs b/src/Simplify.Web.Swagger/SimplifyWebSwaggerArgs.cs index 5e63020..69581bb 100644 --- a/src/Simplify.Web.Swagger/SimplifyWebSwaggerArgs.cs +++ b/src/Simplify.Web.Swagger/SimplifyWebSwaggerArgs.cs @@ -16,4 +16,11 @@ public class SimplifyWebSwaggerArgs /// Open Api Parameters /// public IList Parameters { get; } = []; + + /// + /// The security scheme name to apply to authorized operations (e.g. "Bearer"). + /// When set, operations on controllers with IsAuthorizationRequired will have + /// a security requirement added referencing this scheme. + /// + public string? SecuritySchemeName { get; set; } } \ No newline at end of file diff --git a/src/TesterApp/Program.cs b/src/TesterApp/Program.cs index 4e5db3c..075c8d1 100644 --- a/src/TesterApp/Program.cs +++ b/src/TesterApp/Program.cs @@ -8,33 +8,31 @@ var builder = WebApplication.CreateBuilder(args); // DI -DIContainer.Current - .RegisterAll() - .Verify(); +DIContainer.Current.RegisterAll().Verify(); // Swagger -builder.Services.AddEndpointsApiExplorer() +builder + .Services.AddEndpointsApiExplorer() .AddSwaggerGen(x => { - x.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme - { - Name = "Authorization", - In = ParameterLocation.Header, - Type = SecuritySchemeType.ApiKey, - Scheme = "Bearer", - BearerFormat = "JWT", - Description = "Input your Bearer token in this format - Bearer {your token here} to access this API", - }); - - x.AddSecurityRequirement(_ => new OpenApiSecurityRequirement - { + x.AddSecurityDefinition( + "Bearer", + new OpenApiSecurityScheme { - new OpenApiSecuritySchemeReference("Bearer"), - new List() + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + Scheme = "Bearer", + BearerFormat = "JWT", + Description = + "Input your Bearer token in this format - Bearer {your token here} to access this API", } - }); + ); - var args = new SimplifyWebSwaggerArgs(); + var swaggerArgs = new SimplifyWebSwaggerArgs + { + SecuritySchemeName = "Bearer" + }; var parameter = new OpenApiParameter { @@ -47,17 +45,13 @@ Schema = new OpenApiSchema { Default = "en-US", - Enum = - [ - (JsonNode)"en-US", - (JsonNode)"ru-RU" - ] - } + Enum = [(JsonNode)"en-US", (JsonNode)"ru-RU"], + }, }; - args.Parameters.Add(parameter); + swaggerArgs.Parameters.Add(parameter); - x.AddSimplifyWebSwagger(args); + x.AddSimplifyWebSwagger(swaggerArgs); }); // App @@ -69,4 +63,4 @@ app.UseSimplifyWeb(); -await app.RunAsync(); \ No newline at end of file +await app.RunAsync(); diff --git a/src/TesterApp/TesterApp.csproj b/src/TesterApp/TesterApp.csproj index ab75d6d..0056715 100644 --- a/src/TesterApp/TesterApp.csproj +++ b/src/TesterApp/TesterApp.csproj @@ -1,13 +1,12 @@ - + net10.0 enable - + - - + - \ No newline at end of file + From e32e04b077b578279dd53525b45135e609051ac4 Mon Sep 17 00:00:00 2001 From: lapych Date: Mon, 8 Jun 2026 08:48:12 +0500 Subject: [PATCH 3/7] [add] Accept-Language header support, Bearer security scheme, route parameter types * Added AcceptLanguageHeaderArgs class for per-operation Accept-Language header config * Added SecuritySchemeName to SimplifyWebSwaggerArgs for Bearer security requirements * Added RouteParameterTypes to ControllerAction for typed route params * Updated SimplifyWebDocumentFilter and ControllerActionsFactory accordingly * Updated README with configuration examples for security scheme and JSON casing --- README.md | 36 +++++ .../AcceptLanguageHeaderArgs.cs | 30 ++++ src/Simplify.Web.Swagger/ControllerAction.cs | 6 + .../ControllerActionsFactory.cs | 133 ++++++++++++------ .../EnumNamesSchemaFilter.cs | 2 +- .../SimplifyWebDocumentFilter.cs | 94 +++++++++---- .../SimplifyWebSwaggerArgs.cs | 7 +- .../SimplifyWebSwaggerExtensions.cs | 19 +++ src/TesterApp/Program.cs | 26 +--- src/TesterApp/TesterApp.csproj | 1 + 10 files changed, 261 insertions(+), 93 deletions(-) create mode 100644 src/Simplify.Web.Swagger/AcceptLanguageHeaderArgs.cs diff --git a/README.md b/README.md index a186bda..3b082b4 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,42 @@ public class GetController : Controller2 5. After application started go to or to see generated Swagger +## Configuration + +### Bearer security scheme + +When your controllers use authorization (`[Authorize]`), `Simplify.Web.Swagger` automatically adds the Bearer security requirement to the corresponding operations. The security scheme name defaults to `"Bearer"`: + +```csharp +x.AddSimplifyWebSwagger(new SimplifyWebSwaggerArgs +{ + SecuritySchemeName = "Bearer" // default, can be omitted +}); +``` + +To use a different scheme name, set `SecuritySchemeName` to match the name used in `AddSecurityDefinition`. To disable security requirements entirely, set it to `null`: + +```csharp +x.AddSimplifyWebSwagger(new SimplifyWebSwaggerArgs +{ + SecuritySchemeName = null // disables security requirements on all operations +}); +``` + +### 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(_ => + new JsonSerializerDataContractResolver( + new JsonSerializerOptions(JsonSerializerDefaults.Web) + )) + .AddEndpointsApiExplorer() + .AddSwaggerGen(x => x.AddSimplifyWebSwagger()); +``` + ## Example application Below is the example of Swagger generated by `Simplify.Web.Swagger`: diff --git a/src/Simplify.Web.Swagger/AcceptLanguageHeaderArgs.cs b/src/Simplify.Web.Swagger/AcceptLanguageHeaderArgs.cs new file mode 100644 index 0000000..70911c3 --- /dev/null +++ b/src/Simplify.Web.Swagger/AcceptLanguageHeaderArgs.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace Simplify.Web.Swagger; + +/// +/// Configuration for the Accept-Language header parameter added to all operations. +/// +public class AcceptLanguageHeaderArgs +{ + /// + /// Initializes a new instance with the specified supported languages. + /// The first entry is used as the default value. + /// + /// Supported language codes, e.g. "en-US", "ru-RU". + public AcceptLanguageHeaderArgs(params string[] languages) + { + Languages = languages; + Default = languages.Length > 0 ? languages[0] : string.Empty; + } + + /// + /// The list of supported language codes. + /// + public IReadOnlyList Languages { get; } + + /// + /// The default language code (defaults to the first entry in ). + /// + public string Default { get; set; } +} \ No newline at end of file diff --git a/src/Simplify.Web.Swagger/ControllerAction.cs b/src/Simplify.Web.Swagger/ControllerAction.cs index 1c59a52..f665274 100644 --- a/src/Simplify.Web.Swagger/ControllerAction.cs +++ b/src/Simplify.Web.Swagger/ControllerAction.cs @@ -34,6 +34,12 @@ public class ControllerAction /// public IDictionary Responses { get; set; } = new Dictionary(); + /// + /// Gets or sets the route parameter types, keyed by parameter name (case-insensitive). + /// + public IDictionary RouteParameterTypes { get; set; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + /// /// Gets or sets the type. /// diff --git a/src/Simplify.Web.Swagger/ControllerActionsFactory.cs b/src/Simplify.Web.Swagger/ControllerActionsFactory.cs index 7b1784d..0a6a372 100644 --- a/src/Simplify.Web.Swagger/ControllerActionsFactory.cs +++ b/src/Simplify.Web.Swagger/ControllerActionsFactory.cs @@ -1,17 +1,19 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Text.RegularExpressions; -#if NET10_0 -using Microsoft.OpenApi; -#else -using Microsoft.OpenApi.Models; -#endif 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; +#else +using Microsoft.OpenApi.Models; +#endif + #if NET10_0 using NetHttpMethod = System.Net.Http.HttpMethod; #endif @@ -23,18 +25,17 @@ namespace Simplify.Web.Swagger; /// public static class ControllerActionsFactory { - private static readonly IReadOnlyCollection> ResponseDescriptionMap = + private static readonly IReadOnlyCollection< + KeyValuePair + > ResponseDescriptionMap = [ new KeyValuePair("1\\d{2}", "Information"), - new KeyValuePair("201", "Created"), new KeyValuePair("202", "Accepted"), new KeyValuePair("204", "No Content"), new KeyValuePair("2\\d{2}", "Success"), - new KeyValuePair("304", "Not Modified"), new KeyValuePair("3\\d{2}", "Redirect"), - new KeyValuePair("400", "Bad Request"), new KeyValuePair("401", "Unauthorized"), new KeyValuePair("403", "Forbidden"), @@ -45,9 +46,8 @@ public static class ControllerActionsFactory new KeyValuePair("409", "Conflict"), new KeyValuePair("429", "Too Many Requests"), new KeyValuePair("4\\d{2}", "Client Error"), - new KeyValuePair("5\\d{2}", "Server Error"), - new KeyValuePair("default", "Error") + new KeyValuePair("default", "Error"), ]; /// @@ -56,26 +56,33 @@ public static class ControllerActionsFactory /// /// The remove prefixes. /// - public static IList RemovePrefixes { get; } = - [ - "Controllers.", - "Api.v1." - ]; + public static IList RemovePrefixes { get; } = ["Controllers.", "Api.v1."]; /// /// Creates the controller actions from controllers metadata. /// /// The context. - public static IEnumerable CreateControllerActionsFromControllersMetaData(DocumentFilterContext context) => - ControllersMetaStore.Current.RoutedControllers - .SelectMany(item => CreateControllerActions(item, context)); - - private static IEnumerable 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 CreateControllerActionsFromControllersMetaData( + DocumentFilterContext context + ) => + ControllersMetaStore.Current.RoutedControllers.SelectMany(item => + CreateControllerActions(item, context) + ); + + private static IEnumerable 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), @@ -83,11 +90,15 @@ private static ControllerAction CreateControllerAction(HttpMethod method, IContr 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) { @@ -130,7 +141,7 @@ private static NetHttpMethod HttpMethodToOperationType(HttpMethod method) => HttpMethod.Patch => NetHttpMethod.Patch, HttpMethod.Delete => NetHttpMethod.Delete, HttpMethod.Options => NetHttpMethod.Options, - _ => NetHttpMethod.Get + _ => NetHttpMethod.Get, }; #else private static OperationType HttpMethodToOperationType(HttpMethod method) => @@ -142,11 +153,14 @@ private static OperationType HttpMethodToOperationType(HttpMethod method) => HttpMethod.Patch => OperationType.Patch, HttpMethod.Delete => OperationType.Delete, HttpMethod.Options => OperationType.Options, - _ => OperationType.Get + _ => OperationType.Get, }; #endif - private static OpenApiRequestBody CreateRequestBody(Type controllerType, DocumentFilterContext context) + private static OpenApiRequestBody CreateRequestBody( + Type controllerType, + DocumentFilterContext context + ) { var request = new OpenApiRequestBody(); var attributes = controllerType.GetCustomAttributes(typeof(RequestBodyAttribute), false); @@ -158,24 +172,39 @@ private static OpenApiRequestBody CreateRequestBody(Type controllerType, Documen request.Content = new Dictionary { - [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 CreateResponses(Type controllerType, DocumentFilterContext context) => - controllerType.GetCustomAttributes(typeof(ProducesResponseAttribute), false) + private static IDictionary CreateResponses( + Type controllerType, + DocumentFilterContext context + ) => + controllerType + .GetCustomAttributes(typeof(ProducesResponseAttribute), false) .Cast() .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()) @@ -183,11 +212,33 @@ private static OpenApiResponse CreateResponse(ProducesResponseAttribute produces #if NET10_0 response.Content ??= new Dictionary(); #endif - response.Content.Add(item, producesResponse.Type is null - ? new OpenApiMediaType() - : new OpenApiMediaType { Schema = context.SchemaGenerator.GenerateSchema(producesResponse.Type, context.SchemaRepository) }); + response.Content.Add( + item, + producesResponse.Type is null + ? new OpenApiMediaType() + : new OpenApiMediaType + { + Schema = context.SchemaGenerator.GenerateSchema( + producesResponse.Type, + context.SchemaRepository + ), + } + ); } return response; } -} \ No newline at end of file + + private static IDictionary GetRouteParameterTypes(Type controllerType) + { + var method = controllerType.GetMethod("Invoke") ?? controllerType.GetMethod("InvokeAsync"); + + if (method is null) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + return method + .GetParameters() + .Where(p => p.Name != null) + .ToDictionary(p => p.Name!, p => p.ParameterType, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/Simplify.Web.Swagger/EnumNamesSchemaFilter.cs b/src/Simplify.Web.Swagger/EnumNamesSchemaFilter.cs index 3d16a4d..87f1802 100644 --- a/src/Simplify.Web.Swagger/EnumNamesSchemaFilter.cs +++ b/src/Simplify.Web.Swagger/EnumNamesSchemaFilter.cs @@ -58,4 +58,4 @@ public void Apply(OpenApiSchema schema, SchemaFilterContext context) private static string BuildDescription(string[] names, object[] values) => string.Join(", ", names.Select((name, i) => $"{Convert.ToInt64(values[i])} = {name}")); -} +} \ No newline at end of file diff --git a/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs b/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs index 6f1b977..66b1735 100644 --- a/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs +++ b/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs @@ -5,6 +5,7 @@ using Swashbuckle.AspNetCore.SwaggerGen; #if NET10_0 using Microsoft.OpenApi; +using JsonNode = System.Text.Json.Nodes.JsonNode; #else using Microsoft.OpenApi.Models; #endif @@ -46,7 +47,7 @@ var item in controllerActions .GroupBy(x => x.Path) .Select(x => new KeyValuePair( x.Key, - CreatePathItem(x, swaggerDoc) + CreatePathItem(x, swaggerDoc, context) )) ) swaggerDoc.Paths.Add(item.Key, item.Value); @@ -55,48 +56,50 @@ var item in controllerActions } #if NET10_0 - private static IList CreateParameters(IControllerRoute path) => - path - .Items.Where(x => x is PathParameter) - .Cast() - .Select(x => - (IOpenApiParameter) - new OpenApiParameter - { - Name = x.Name, - In = ParameterLocation.Path, - AllowEmptyValue = false, - } - ) + private static IList CreateParameters(ControllerAction item, DocumentFilterContext context) => + item.ControllerRoute.Items + .OfType() + .Select(x => (IOpenApiParameter)CreatePathParameter(x, item, context)) .ToList(); #else - private static IList CreateParameters(IControllerRoute path) => - path - .Items.Where(x => x is PathParameter) - .Cast() - .Select(x => new OpenApiParameter - { - Name = x.Name, - In = ParameterLocation.Path, - AllowEmptyValue = false, - }) + private static IList CreateParameters(ControllerAction item, DocumentFilterContext context) => + item.ControllerRoute.Items + .OfType() + .Select(x => CreatePathParameter(x, item, context)) .ToList(); #endif + private static OpenApiParameter CreatePathParameter(PathParameter pathParam, ControllerAction item, DocumentFilterContext context) + { + var param = new OpenApiParameter + { + Name = pathParam.Name, + In = ParameterLocation.Path, + Required = true, + AllowEmptyValue = false, + }; + + if (item.RouteParameterTypes.TryGetValue(pathParam.Name, out var type)) + param.Schema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository); + + return param; + } + private OpenApiPathItem CreatePathItem( IEnumerable actions, - OpenApiDocument swaggerDoc + OpenApiDocument swaggerDoc, + DocumentFilterContext context ) { var pathItem = new OpenApiPathItem(); foreach (var item in actions) - pathItem.AddOperation(item.Type, CreateOperation(item, swaggerDoc)); + pathItem.AddOperation(item.Type, CreateOperation(item, swaggerDoc, context)); return pathItem; } - private OpenApiOperation CreateOperation(ControllerAction item, OpenApiDocument swaggerDoc) + private OpenApiOperation CreateOperation(ControllerAction item, OpenApiDocument swaggerDoc, DocumentFilterContext context) { var operation = new OpenApiOperation(); @@ -118,7 +121,7 @@ private OpenApiOperation CreateOperation(ControllerAction item, OpenApiDocument operation.Responses.Add(response.Key.ToString(), response.Value); } - operation.Parameters = CreateParameters(item.ControllerRoute); + operation.Parameters = CreateParameters(item, context); if (item.RequestBody.Content is { Count: > 0 }) operation.RequestBody = item.RequestBody; @@ -161,9 +164,42 @@ [new OpenApiSecuritySchemeReference(securitySchemeNameNet10, swaggerDoc)] = [], foreach (var parameter in _args.Parameters) operation.Parameters.Add(parameter); + if (_args.AcceptLanguageHeader is { } acceptLang) + operation.Parameters.Add(CreateAcceptLanguageParameter(acceptLang)); + return operation; } + private static OpenApiParameter CreateAcceptLanguageParameter(AcceptLanguageHeaderArgs args) + { + var param = new OpenApiParameter + { + Name = "Accept-Language", + In = ParameterLocation.Header, + Description = "Language preference for the response.", + Required = true, + AllowEmptyValue = true, + }; + +#if NET10_0 + param.Example = args.Default; + param.Schema = new OpenApiSchema + { + Default = args.Default, + Enum = args.Languages.Select(l => (JsonNode)l).ToList() + }; +#else + param.Example = new Microsoft.OpenApi.Any.OpenApiString(args.Default); + param.Schema = new OpenApiSchema + { + Default = new Microsoft.OpenApi.Any.OpenApiString(args.Default), + Enum = args.Languages.Select(l => (Microsoft.OpenApi.Any.IOpenApiAny)new Microsoft.OpenApi.Any.OpenApiString(l)).ToList() + }; +#endif + + return param; + } + private static void PopulateDocumentTags( OpenApiDocument swaggerDoc, IEnumerable actions @@ -189,4 +225,4 @@ var name in actions swaggerDoc.Tags.Add(new OpenApiTag { Name = name }); } } -} +} \ No newline at end of file diff --git a/src/Simplify.Web.Swagger/SimplifyWebSwaggerArgs.cs b/src/Simplify.Web.Swagger/SimplifyWebSwaggerArgs.cs index 69581bb..9aa183e 100644 --- a/src/Simplify.Web.Swagger/SimplifyWebSwaggerArgs.cs +++ b/src/Simplify.Web.Swagger/SimplifyWebSwaggerArgs.cs @@ -17,10 +17,15 @@ public class SimplifyWebSwaggerArgs /// public IList Parameters { get; } = []; + /// + /// When set, adds an Accept-Language header parameter to every operation. + /// + public AcceptLanguageHeaderArgs? AcceptLanguageHeader { get; set; } + /// /// The security scheme name to apply to authorized operations (e.g. "Bearer"). /// When set, operations on controllers with IsAuthorizationRequired will have /// a security requirement added referencing this scheme. /// - public string? SecuritySchemeName { get; set; } + public string? SecuritySchemeName { get; set; } = "Bearer"; } \ No newline at end of file diff --git a/src/Simplify.Web.Swagger/SimplifyWebSwaggerExtensions.cs b/src/Simplify.Web.Swagger/SimplifyWebSwaggerExtensions.cs index 6e66d33..abfc9db 100644 --- a/src/Simplify.Web.Swagger/SimplifyWebSwaggerExtensions.cs +++ b/src/Simplify.Web.Swagger/SimplifyWebSwaggerExtensions.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using Swashbuckle.AspNetCore.SwaggerGen; +using System.Text.Json; namespace Simplify.Web.Swagger; @@ -8,6 +9,24 @@ namespace Simplify.Web.Swagger; /// public static class SimplifyWebSwaggerServiceCollectionExtensions { + /// + /// Registers Simplify.Web Swagger services on the service collection. + /// Defaults schema property naming to PascalCase to match Simplify.Web default serialization. + /// Must be called before AddSwaggerGen. + /// + /// The service collection. + public static IServiceCollection AddSimplifyWebSwaggerServices(this IServiceCollection services) + { + // Swashbuckle resolves JsonSerializerOptions from Mvc.JsonOptions or HttpJsonOptions, both of which + // default to CamelCase via JsonSerializerDefaults.Web. Simplify.Web serializes independently using + // PascalCase by default. Register a custom resolver with PropertyNamingPolicy = null (= PascalCase) + // before AddSwaggerGen so that Swashbuckle's TryAddSingleton is skipped. + services.AddSingleton(_ => + new JsonSerializerDataContractResolver(new JsonSerializerOptions { PropertyNamingPolicy = null })); + + return services; + } + /// /// Add Simplify.Web controllers to Swagger documentation generation /// diff --git a/src/TesterApp/Program.cs b/src/TesterApp/Program.cs index 075c8d1..432bed6 100644 --- a/src/TesterApp/Program.cs +++ b/src/TesterApp/Program.cs @@ -1,4 +1,3 @@ -using System.Text.Json.Nodes; using Microsoft.OpenApi; using Simplify.DI; using Simplify.Web; @@ -12,7 +11,8 @@ // Swagger builder - .Services.AddEndpointsApiExplorer() + .Services.AddSimplifyWebSwaggerServices() + .AddEndpointsApiExplorer() .AddSwaggerGen(x => { x.AddSecurityDefinition( @@ -31,26 +31,9 @@ var swaggerArgs = new SimplifyWebSwaggerArgs { - SecuritySchemeName = "Bearer" + AcceptLanguageHeader = new AcceptLanguageHeaderArgs("en-US", "ru-RU", "kk-KZ"), }; - var parameter = new OpenApiParameter - { - Name = "Accept-Language", - In = ParameterLocation.Header, - Description = "Language preference for the response.", - Required = true, - AllowEmptyValue = true, - Example = "en-US", - Schema = new OpenApiSchema - { - Default = "en-US", - Enum = [(JsonNode)"en-US", (JsonNode)"ru-RU"], - }, - }; - - swaggerArgs.Parameters.Add(parameter); - x.AddSimplifyWebSwagger(swaggerArgs); }); @@ -59,8 +42,9 @@ var app = builder.Build(); app.UseSwagger(); + app.UseSwaggerUI(); app.UseSimplifyWeb(); -await app.RunAsync(); +await app.RunAsync(); \ No newline at end of file diff --git a/src/TesterApp/TesterApp.csproj b/src/TesterApp/TesterApp.csproj index 0056715..55cb1f5 100644 --- a/src/TesterApp/TesterApp.csproj +++ b/src/TesterApp/TesterApp.csproj @@ -4,6 +4,7 @@ enable + From ff07587b2dcc04d9c3e9050cff83897349de6246 Mon Sep 17 00:00:00 2001 From: lapych Date: Mon, 8 Jun 2026 08:57:59 +0500 Subject: [PATCH 4/7] [edit] auto-detect security scheme names from AddSecurityDefinition * SecuritySchemeName in SimplifyWebSwaggerArgs now defaults to null (auto-detect) * When null, all security schemes registered via AddSecurityDefinition are applied automatically as separate OR requirements on authorized operations * Set SecuritySchemeName explicitly to restrict to a specific scheme * Updated README to document the new auto-detection behavior --- README.md | 12 ++-- .../SimplifyWebDocumentFilter.cs | 59 +++++++++++-------- .../SimplifyWebSwaggerArgs.cs | 6 +- 3 files changed, 44 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 3b082b4..b884114 100644 --- a/README.md +++ b/README.md @@ -60,21 +60,19 @@ public class GetController : Controller2 ### Bearer security scheme -When your controllers use authorization (`[Authorize]`), `Simplify.Web.Swagger` automatically adds the Bearer security requirement to the corresponding operations. The security scheme name defaults to `"Bearer"`: +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.AddSimplifyWebSwagger(new SimplifyWebSwaggerArgs -{ - SecuritySchemeName = "Bearer" // default, can be omitted -}); +x.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { ... }); +x.AddSimplifyWebSwagger(); // Bearer requirement is added automatically ``` -To use a different scheme name, set `SecuritySchemeName` to match the name used in `AddSecurityDefinition`. To disable security requirements entirely, set it to `null`: +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 = null // disables security requirements on all operations + SecuritySchemeName = "Bearer" }); ``` diff --git a/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs b/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs index 66b1735..a8c8873 100644 --- a/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs +++ b/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs @@ -126,37 +126,38 @@ private OpenApiOperation CreateOperation(ControllerAction item, OpenApiDocument if (item.RequestBody.Content is { Count: > 0 }) operation.RequestBody = item.RequestBody; + if (item.IsAuthorizationRequired) + { + var schemeNames = ResolveSecuritySchemeNames(swaggerDoc); + #if NET10_0 - if ( - item.IsAuthorizationRequired && _args?.SecuritySchemeName is { } securitySchemeNameNet10 - ) - operation.Security = - [ - new OpenApiSecurityRequirement - { - [new OpenApiSecuritySchemeReference(securitySchemeNameNet10, swaggerDoc)] = [], - }, - ]; + if (schemeNames.Count > 0) + operation.Security = schemeNames + .Select(name => new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference(name, swaggerDoc)] = [], + }) + .ToList(); #else - if (item.IsAuthorizationRequired && _args?.SecuritySchemeName is { } securitySchemeName) - operation.Security = new List - { - new() - { + if (schemeNames.Count > 0) + operation.Security = schemeNames + .Select(name => new OpenApiSecurityRequirement { - new OpenApiSecurityScheme { - Reference = new OpenApiReference + new OpenApiSecurityScheme { - Type = ReferenceType.SecurityScheme, - Id = securitySchemeName, + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = name, + }, }, + new List() }, - new List() - }, - }, - }; + }) + .ToList(); #endif + } if (_args == null) return operation; @@ -170,6 +171,18 @@ [new OpenApiSecuritySchemeReference(securitySchemeNameNet10, swaggerDoc)] = [], return operation; } + private IReadOnlyList ResolveSecuritySchemeNames(OpenApiDocument swaggerDoc) + { + if (_args?.SecuritySchemeName is { } explicitName) + return [explicitName]; + +#if NET10_0 + return swaggerDoc.Components?.SecuritySchemes?.Keys?.ToList() ?? []; +#else + return swaggerDoc.Components.SecuritySchemes.Keys.ToList(); +#endif + } + private static OpenApiParameter CreateAcceptLanguageParameter(AcceptLanguageHeaderArgs args) { var param = new OpenApiParameter diff --git a/src/Simplify.Web.Swagger/SimplifyWebSwaggerArgs.cs b/src/Simplify.Web.Swagger/SimplifyWebSwaggerArgs.cs index 9aa183e..54353d9 100644 --- a/src/Simplify.Web.Swagger/SimplifyWebSwaggerArgs.cs +++ b/src/Simplify.Web.Swagger/SimplifyWebSwaggerArgs.cs @@ -24,8 +24,8 @@ public class SimplifyWebSwaggerArgs /// /// The security scheme name to apply to authorized operations (e.g. "Bearer"). - /// When set, operations on controllers with IsAuthorizationRequired will have - /// a security requirement added referencing this scheme. + /// When null (default), all security schemes registered via AddSecurityDefinition + /// are applied automatically. Set to an explicit name to restrict to a single scheme. /// - public string? SecuritySchemeName { get; set; } = "Bearer"; + public string? SecuritySchemeName { get; set; } } \ No newline at end of file From 9188b2a014851c65d1b15befe180bbe42e93e47a Mon Sep 17 00:00:00 2001 From: lapych Date: Mon, 8 Jun 2026 09:17:19 +0500 Subject: [PATCH 5/7] [fix] Microsoft.OpenApi usings for net8/net9 vs net10 in TesterApp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix null-reference in ResolveSecuritySchemeNames for net8/net9 — Components and SecuritySchemes can be null when no AddSecurityDefinition is called --- .editorconfig | 162 ------- .../ControllerActionsFactory.cs | 424 +++++++++--------- .../SimplifyWebDocumentFilter.cs | 4 - src/TesterApp/Program.cs | 6 +- 4 files changed, 215 insertions(+), 381 deletions(-) delete mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 35c7ffe..0000000 --- a/.editorconfig +++ /dev/null @@ -1,162 +0,0 @@ -root = true - -[*] -end_of_line = crlf -indent_style = tab -insert_final_newline = false -resharper_insert_final_newline = false -trim_trailing_whitespace = true - -[*.cs] -indent_size = 4 -tab_width = 4 - -# New-line preferences -csharp_new_line_before_open_brace = all -csharp_new_line_before_else = true -csharp_new_line_before_catch = true -csharp_new_line_before_finally = true -csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_members_in_anonymous_types = true -csharp_new_line_between_query_expression_clauses = true - -# Indentation -csharp_indent_case_contents = true -csharp_indent_switch_labels = true -csharp_indent_labels = flush_left -csharp_indent_block_contents = true -csharp_indent_braces = false -csharp_indent_case_contents_when_block = false - -# Spacing -csharp_space_after_cast = false -csharp_space_after_keywords_in_control_flow_statements = true -csharp_space_between_method_declaration_parameter_list_parentheses = false -csharp_space_between_method_call_parameter_list_parentheses = false -csharp_space_between_parentheses = false -csharp_space_before_colon_in_inheritance_clause = true -csharp_space_after_colon_in_inheritance_clause = true -csharp_space_around_binary_operators = before_and_after -csharp_space_between_method_declaration_empty_parameter_list_parentheses = false -csharp_space_between_method_call_name_and_opening_parenthesis = false -csharp_space_between_method_call_empty_parameter_list_parentheses = false - -# Wrapping -csharp_preserve_single_line_statements = false -csharp_preserve_single_line_blocks = true - -# var preferences — use explicit type for clarity (Air Astana standard) -csharp_style_var_for_built_in_types = false:suggestion -csharp_style_var_when_type_is_apparent = true:suggestion -csharp_style_var_elsewhere = false:suggestion - -# Expression-bodied members -csharp_style_expression_bodied_methods = false:silent -csharp_style_expression_bodied_constructors = false:silent -csharp_style_expression_bodied_operators = false:silent -csharp_style_expression_bodied_properties = true:suggestion -csharp_style_expression_bodied_indexers = true:suggestion -csharp_style_expression_bodied_accessors = true:suggestion -csharp_style_expression_bodied_lambdas = true:suggestion -csharp_style_expression_bodied_local_functions = false:silent - -# Pattern matching -csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion -csharp_style_pattern_matching_over_as_with_null_check = true:suggestion -csharp_style_prefer_switch_expression = true:suggestion -csharp_style_prefer_pattern_matching = true:suggestion -csharp_style_prefer_not_pattern = true:suggestion - -# Null checks -csharp_style_throw_expression = true:suggestion -csharp_style_conditional_delegate_call = true:suggestion -dotnet_style_null_propagation = true:suggestion -dotnet_style_coalesce_expression = true:suggestion -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion - -# Modifier preferences -csharp_prefer_static_local_function = true:suggestion -csharp_preferred_modifier_order = public, private, protected, internal, file, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, required, volatile, async:suggestion -dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion -dotnet_style_readonly_field = true:suggestion - -# using directives -dotnet_sort_system_directives_first = true -dotnet_separate_import_directive_groups = false -csharp_using_directive_placement = outside_namespace:warning - -# Namespace style -csharp_style_namespace_declarations = file_scoped:warning - -# Object initializers -dotnet_style_object_initializer = true:suggestion -dotnet_style_collection_initializer = true:suggestion - -# Tuple / anonymous types -dotnet_style_prefer_auto_properties = true:suggestion -dotnet_style_explicit_tuple_names = true:suggestion -dotnet_style_prefer_inferred_tuple_names = true:suggestion -dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion - -# Miscellaneous -csharp_prefer_braces = when_multiline:silent -csharp_prefer_simple_default_expression = true:suggestion -csharp_style_deconstructed_variable_declaration = true:suggestion -csharp_style_inlined_variable_declaration = true:suggestion -csharp_style_prefer_local_over_anonymous_function = true:suggestion -csharp_style_prefer_index_operator = true:suggestion -csharp_style_prefer_range_operator = true:suggestion -csharp_style_unused_value_assignment_preference = discard_variable:suggestion -csharp_style_unused_value_expression_statement_preference = discard_variable:silent - -# Naming rules — Air Astana: PascalCase public, _camelCase private fields -dotnet_naming_rule.private_fields_should_be_camel_case.severity = warning -dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_fields -dotnet_naming_rule.private_fields_should_be_camel_case.style = underscore_camel_case_style - -dotnet_naming_symbols.private_fields.applicable_kinds = field -dotnet_naming_symbols.private_fields.applicable_accessibilities = private - -dotnet_naming_style.underscore_camel_case_style.capitalization = camel_case -dotnet_naming_style.underscore_camel_case_style.required_prefix = _ - -dotnet_naming_rule.interfaces_should_start_with_i.severity = warning -dotnet_naming_rule.interfaces_should_start_with_i.symbols = interfaces -dotnet_naming_rule.interfaces_should_start_with_i.style = i_prefix_pascal_case_style - -dotnet_naming_symbols.interfaces.applicable_kinds = interface -dotnet_naming_style.i_prefix_pascal_case_style.capitalization = pascal_case -dotnet_naming_style.i_prefix_pascal_case_style.required_prefix = I - -dotnet_naming_rule.public_members_pascal_case.severity = warning -dotnet_naming_rule.public_members_pascal_case.symbols = public_members -dotnet_naming_rule.public_members_pascal_case.style = pascal_case_style - -dotnet_naming_symbols.public_members.applicable_kinds = property, method, field, event -dotnet_naming_symbols.public_members.applicable_accessibilities = public, internal, protected, protected_internal -dotnet_naming_style.pascal_case_style.capitalization = pascal_case - -# Roslyn analyzer severities -dotnet_diagnostic.CA1822.severity = suggestion # Mark members as static -dotnet_diagnostic.CA1848.severity = none # Use LoggerMessage delegates (too verbose for this stack) -dotnet_diagnostic.CA2007.severity = none # ConfigureAwait (not needed in ASP.NET Core) -dotnet_diagnostic.CS8618.severity = warning # Non-nullable field uninitialized -dotnet_diagnostic.CS8600.severity = warning # Converting null literal -dotnet_diagnostic.CS8601.severity = warning # Possible null reference assignment -dotnet_diagnostic.CS8602.severity = warning # Dereference of possibly null reference -dotnet_diagnostic.CS8603.severity = warning # Possible null reference return -dotnet_diagnostic.IDE0005.severity = warning # Remove unnecessary using -dotnet_diagnostic.IDE0055.severity = warning # Fix formatting -dotnet_diagnostic.IDE0160.severity = none # Namespace style (handled by csharp_style_namespace_declarations) - -[*.{json,yaml,yml}] -indent_size = 2 - -[*.xml] -indent_size = 2 - -[*.{csproj,props,targets}] -indent_size = 2 - -[*.md] -trim_trailing_whitespace = false diff --git a/src/Simplify.Web.Swagger/ControllerActionsFactory.cs b/src/Simplify.Web.Swagger/ControllerActionsFactory.cs index 0a6a372..e7721ea 100644 --- a/src/Simplify.Web.Swagger/ControllerActionsFactory.cs +++ b/src/Simplify.Web.Swagger/ControllerActionsFactory.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; using System.Text.RegularExpressions; using Simplify.Web.Controllers.Meta; using Simplify.Web.Controllers.Meta.MetaStore; @@ -10,14 +9,11 @@ using Swashbuckle.AspNetCore.SwaggerGen; #if NET10_0 using Microsoft.OpenApi; +using NetHttpMethod = System.Net.Http.HttpMethod; #else using Microsoft.OpenApi.Models; #endif -#if NET10_0 -using NetHttpMethod = System.Net.Http.HttpMethod; -#endif - namespace Simplify.Web.Swagger; /// @@ -25,220 +21,220 @@ namespace Simplify.Web.Swagger; /// public static class ControllerActionsFactory { - private static readonly IReadOnlyCollection< - KeyValuePair - > ResponseDescriptionMap = - [ - new KeyValuePair("1\\d{2}", "Information"), - new KeyValuePair("201", "Created"), - new KeyValuePair("202", "Accepted"), - new KeyValuePair("204", "No Content"), - new KeyValuePair("2\\d{2}", "Success"), - new KeyValuePair("304", "Not Modified"), - new KeyValuePair("3\\d{2}", "Redirect"), - new KeyValuePair("400", "Bad Request"), - new KeyValuePair("401", "Unauthorized"), - new KeyValuePair("403", "Forbidden"), - new KeyValuePair("404", "Not Found"), - new KeyValuePair("405", "Method Not Allowed"), - new KeyValuePair("406", "Not Acceptable"), - new KeyValuePair("408", "Request Timeout"), - new KeyValuePair("409", "Conflict"), - new KeyValuePair("429", "Too Many Requests"), - new KeyValuePair("4\\d{2}", "Client Error"), - new KeyValuePair("5\\d{2}", "Server Error"), - new KeyValuePair("default", "Error"), - ]; - - /// - /// Gets the remove prefixes. - /// - /// - /// The remove prefixes. - /// - public static IList RemovePrefixes { get; } = ["Controllers.", "Api.v1."]; - - /// - /// Creates the controller actions from controllers metadata. - /// - /// The context. - public static IEnumerable CreateControllerActionsFromControllersMetaData( - DocumentFilterContext context - ) => - ControllersMetaStore.Current.RoutedControllers.SelectMany(item => - CreateControllerActions(item, context) - ); - - private static IEnumerable 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 }, - RouteParameterTypes = GetRouteParameterTypes(item.ControllerType), - }; - - private static ControllerActionNames CreateNames(Type controllerType) => - CreateNames( - controllerType.FullName - ?? throw new InvalidOperationException("controllerType.FullName is null") - ); - - private static ControllerActionNames CreateNames(string name) - { - var src = FormatNameSource(name); - - var index = src.LastIndexOf("/"); - - return index == -1 - ? new ControllerActionNames(src, src) - : new ControllerActionNames(src, src.Substring(0, index), src.Substring(index + 1)); - } - - private static string FormatNameSource(string str) - { - foreach (var prefix in RemovePrefixes) - { - var prefixIndex = str.IndexOf(prefix); - - if (prefixIndex == -1) - continue; - - str = str.Substring(prefixIndex + prefix.Length); - } - - str = str.Replace(".", "/"); - - if (str.EndsWith("Controller")) - str = str.Substring(0, str.LastIndexOf("Controller")); - - return str; - } + private static readonly IReadOnlyCollection< + KeyValuePair + > ResponseDescriptionMap = + [ + new KeyValuePair("1\\d{2}", "Information"), + new KeyValuePair("201", "Created"), + new KeyValuePair("202", "Accepted"), + new KeyValuePair("204", "No Content"), + new KeyValuePair("2\\d{2}", "Success"), + new KeyValuePair("304", "Not Modified"), + new KeyValuePair("3\\d{2}", "Redirect"), + new KeyValuePair("400", "Bad Request"), + new KeyValuePair("401", "Unauthorized"), + new KeyValuePair("403", "Forbidden"), + new KeyValuePair("404", "Not Found"), + new KeyValuePair("405", "Method Not Allowed"), + new KeyValuePair("406", "Not Acceptable"), + new KeyValuePair("408", "Request Timeout"), + new KeyValuePair("409", "Conflict"), + new KeyValuePair("429", "Too Many Requests"), + new KeyValuePair("4\\d{2}", "Client Error"), + new KeyValuePair("5\\d{2}", "Server Error"), + new KeyValuePair("default", "Error"), + ]; + + /// + /// Gets the remove prefixes. + /// + /// + /// The remove prefixes. + /// + public static IList RemovePrefixes { get; } = ["Controllers.", "Api.v1."]; + + /// + /// Creates the controller actions from controllers metadata. + /// + /// The context. + public static IEnumerable CreateControllerActionsFromControllersMetaData( + DocumentFilterContext context + ) => + ControllersMetaStore.Current.RoutedControllers.SelectMany(item => + CreateControllerActions(item, context) + ); + + private static IEnumerable 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 }, + RouteParameterTypes = GetRouteParameterTypes(item.ControllerType), + }; + + private static ControllerActionNames CreateNames(Type controllerType) => + CreateNames( + controllerType.FullName + ?? throw new InvalidOperationException("controllerType.FullName is null") + ); + + private static ControllerActionNames CreateNames(string name) + { + var src = FormatNameSource(name); + + var index = src.LastIndexOf("/"); + + return index == -1 + ? new ControllerActionNames(src, src) + : new ControllerActionNames(src, src.Substring(0, index), src.Substring(index + 1)); + } + + private static string FormatNameSource(string str) + { + foreach (var prefix in RemovePrefixes) + { + var prefixIndex = str.IndexOf(prefix); + + if (prefixIndex == -1) + continue; + + str = str.Substring(prefixIndex + prefix.Length); + } + + str = str.Replace(".", "/"); + + if (str.EndsWith("Controller")) + str = str.Substring(0, str.LastIndexOf("Controller")); + + return str; + } #if NET10_0 - private static NetHttpMethod HttpMethodToOperationType(HttpMethod method) => - method switch - { - 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 NetHttpMethod HttpMethodToOperationType(HttpMethod method) => + method switch + { + 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, + }; #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, - }; + 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); - - if (attributes.Length <= 0) - return request; - - var item = (RequestBodyAttribute)attributes[0]; - - request.Content = new Dictionary - { - [item.ContentType] = new() - { - Schema = context.SchemaGenerator.GenerateSchema( - item.Model, - context.SchemaRepository - ), - }, - }; - - return request; - } - - private static IDictionary CreateResponses( - Type controllerType, - DocumentFilterContext context - ) => - controllerType - .GetCustomAttributes(typeof(ProducesResponseAttribute), false) - .Cast() - .ToDictionary(item => item.StatusCode, item => CreateResponse(item, 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, - }; - - foreach (var item in producesResponse.ContentTypes.Distinct()) - { + private static OpenApiRequestBody CreateRequestBody( + Type controllerType, + DocumentFilterContext context + ) + { + var request = new OpenApiRequestBody(); + var attributes = controllerType.GetCustomAttributes(typeof(RequestBodyAttribute), false); + + if (attributes.Length <= 0) + return request; + + var item = (RequestBodyAttribute)attributes[0]; + + request.Content = new Dictionary + { + [item.ContentType] = new() + { + Schema = context.SchemaGenerator.GenerateSchema( + item.Model, + context.SchemaRepository + ), + }, + }; + + return request; + } + + private static IDictionary CreateResponses( + Type controllerType, + DocumentFilterContext context + ) => + controllerType + .GetCustomAttributes(typeof(ProducesResponseAttribute), false) + .Cast() + .ToDictionary(item => item.StatusCode, item => CreateResponse(item, 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, + }; + + foreach (var item in producesResponse.ContentTypes.Distinct()) + { #if NET10_0 - response.Content ??= new Dictionary(); + response.Content ??= new Dictionary(); #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 GetRouteParameterTypes(Type controllerType) - { - var method = controllerType.GetMethod("Invoke") ?? controllerType.GetMethod("InvokeAsync"); - - if (method is null) - return new Dictionary(StringComparer.OrdinalIgnoreCase); - - return method - .GetParameters() - .Where(p => p.Name != null) - .ToDictionary(p => p.Name!, p => p.ParameterType, StringComparer.OrdinalIgnoreCase); - } + 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 GetRouteParameterTypes(Type controllerType) + { + var method = controllerType.GetMethod("Invoke") ?? controllerType.GetMethod("InvokeAsync"); + + if (method is null) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + return method + .GetParameters() + .Where(p => p.Name != null) + .ToDictionary(p => p.Name!, p => p.ParameterType, StringComparer.OrdinalIgnoreCase); + } } diff --git a/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs b/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs index a8c8873..fe21a07 100644 --- a/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs +++ b/src/Simplify.Web.Swagger/SimplifyWebDocumentFilter.cs @@ -176,11 +176,7 @@ private IReadOnlyList ResolveSecuritySchemeNames(OpenApiDocument swagger if (_args?.SecuritySchemeName is { } explicitName) return [explicitName]; -#if NET10_0 return swaggerDoc.Components?.SecuritySchemes?.Keys?.ToList() ?? []; -#else - return swaggerDoc.Components.SecuritySchemes.Keys.ToList(); -#endif } private static OpenApiParameter CreateAcceptLanguageParameter(AcceptLanguageHeaderArgs args) diff --git a/src/TesterApp/Program.cs b/src/TesterApp/Program.cs index 432bed6..b8f1bd9 100644 --- a/src/TesterApp/Program.cs +++ b/src/TesterApp/Program.cs @@ -1,8 +1,12 @@ -using Microsoft.OpenApi; using Simplify.DI; using Simplify.Web; using Simplify.Web.Swagger; using TesterApp.Setup; +#if NET10_0 +using Microsoft.OpenApi; +#else +using Microsoft.OpenApi.Models; +#endif var builder = WebApplication.CreateBuilder(args); From 38cda77001a2528682be10d35fbd06ee94209ab5 Mon Sep 17 00:00:00 2001 From: lapych Date: Mon, 8 Jun 2026 09:31:30 +0500 Subject: [PATCH 6/7] [edit] changelog for 1.3.0 --- src/Simplify.Web.Swagger/CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Simplify.Web.Swagger/CHANGELOG.md b/src/Simplify.Web.Swagger/CHANGELOG.md index dba7cee..1ca41be 100644 --- a/src/Simplify.Web.Swagger/CHANGELOG.md +++ b/src/Simplify.Web.Swagger/CHANGELOG.md @@ -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 From b6dbb5a6aeba7885fdf9d68b11543e005c561b94 Mon Sep 17 00:00:00 2001 From: lapych Date: Mon, 8 Jun 2026 09:45:04 +0500 Subject: [PATCH 7/7] [edit] README code blocks indentation to tabs --- README.md | 14 +- .../ControllerActionsFactory.cs | 398 +++++++++--------- 2 files changed, 206 insertions(+), 206 deletions(-) diff --git a/README.md b/README.md index b884114..e16f1a4 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ To restrict to a specific scheme name (e.g. when you have multiple definitions b ```csharp x.AddSimplifyWebSwagger(new SimplifyWebSwaggerArgs { - SecuritySchemeName = "Bearer" + SecuritySchemeName = "Bearer" }); ``` @@ -82,12 +82,12 @@ By default, Swagger schema property names follow the `System.Text.Json` default ```csharp builder.Services - .AddSingleton(_ => - new JsonSerializerDataContractResolver( - new JsonSerializerOptions(JsonSerializerDefaults.Web) - )) - .AddEndpointsApiExplorer() - .AddSwaggerGen(x => x.AddSimplifyWebSwagger()); + .AddSingleton(_ => + new JsonSerializerDataContractResolver( + new JsonSerializerOptions(JsonSerializerDefaults.Web) + )) + .AddEndpointsApiExplorer() + .AddSwaggerGen(x => x.AddSimplifyWebSwagger()); ``` ## Example application diff --git a/src/Simplify.Web.Swagger/ControllerActionsFactory.cs b/src/Simplify.Web.Swagger/ControllerActionsFactory.cs index e7721ea..e64a497 100644 --- a/src/Simplify.Web.Swagger/ControllerActionsFactory.cs +++ b/src/Simplify.Web.Swagger/ControllerActionsFactory.cs @@ -21,124 +21,124 @@ namespace Simplify.Web.Swagger; /// public static class ControllerActionsFactory { - private static readonly IReadOnlyCollection< - KeyValuePair - > ResponseDescriptionMap = - [ - new KeyValuePair("1\\d{2}", "Information"), - new KeyValuePair("201", "Created"), - new KeyValuePair("202", "Accepted"), - new KeyValuePair("204", "No Content"), - new KeyValuePair("2\\d{2}", "Success"), - new KeyValuePair("304", "Not Modified"), - new KeyValuePair("3\\d{2}", "Redirect"), - new KeyValuePair("400", "Bad Request"), - new KeyValuePair("401", "Unauthorized"), - new KeyValuePair("403", "Forbidden"), - new KeyValuePair("404", "Not Found"), - new KeyValuePair("405", "Method Not Allowed"), - new KeyValuePair("406", "Not Acceptable"), - new KeyValuePair("408", "Request Timeout"), - new KeyValuePair("409", "Conflict"), - new KeyValuePair("429", "Too Many Requests"), - new KeyValuePair("4\\d{2}", "Client Error"), - new KeyValuePair("5\\d{2}", "Server Error"), - new KeyValuePair("default", "Error"), - ]; - - /// - /// Gets the remove prefixes. - /// - /// - /// The remove prefixes. - /// - public static IList RemovePrefixes { get; } = ["Controllers.", "Api.v1."]; - - /// - /// Creates the controller actions from controllers metadata. - /// - /// The context. - public static IEnumerable CreateControllerActionsFromControllersMetaData( - DocumentFilterContext context - ) => - ControllersMetaStore.Current.RoutedControllers.SelectMany(item => - CreateControllerActions(item, context) - ); - - private static IEnumerable 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 }, - RouteParameterTypes = GetRouteParameterTypes(item.ControllerType), - }; - - private static ControllerActionNames CreateNames(Type controllerType) => - CreateNames( - controllerType.FullName - ?? throw new InvalidOperationException("controllerType.FullName is null") - ); - - private static ControllerActionNames CreateNames(string name) - { - var src = FormatNameSource(name); - - var index = src.LastIndexOf("/"); - - return index == -1 - ? new ControllerActionNames(src, src) - : new ControllerActionNames(src, src.Substring(0, index), src.Substring(index + 1)); - } - - private static string FormatNameSource(string str) - { - foreach (var prefix in RemovePrefixes) - { - var prefixIndex = str.IndexOf(prefix); - - if (prefixIndex == -1) - continue; - - str = str.Substring(prefixIndex + prefix.Length); - } - - str = str.Replace(".", "/"); - - if (str.EndsWith("Controller")) - str = str.Substring(0, str.LastIndexOf("Controller")); - - return str; - } + private static readonly IReadOnlyCollection< + KeyValuePair + > ResponseDescriptionMap = + [ + new KeyValuePair("1\\d{2}", "Information"), + new KeyValuePair("201", "Created"), + new KeyValuePair("202", "Accepted"), + new KeyValuePair("204", "No Content"), + new KeyValuePair("2\\d{2}", "Success"), + new KeyValuePair("304", "Not Modified"), + new KeyValuePair("3\\d{2}", "Redirect"), + new KeyValuePair("400", "Bad Request"), + new KeyValuePair("401", "Unauthorized"), + new KeyValuePair("403", "Forbidden"), + new KeyValuePair("404", "Not Found"), + new KeyValuePair("405", "Method Not Allowed"), + new KeyValuePair("406", "Not Acceptable"), + new KeyValuePair("408", "Request Timeout"), + new KeyValuePair("409", "Conflict"), + new KeyValuePair("429", "Too Many Requests"), + new KeyValuePair("4\\d{2}", "Client Error"), + new KeyValuePair("5\\d{2}", "Server Error"), + new KeyValuePair("default", "Error"), + ]; + + /// + /// Gets the remove prefixes. + /// + /// + /// The remove prefixes. + /// + public static IList RemovePrefixes { get; } = ["Controllers.", "Api.v1."]; + + /// + /// Creates the controller actions from controllers metadata. + /// + /// The context. + public static IEnumerable CreateControllerActionsFromControllersMetaData( + DocumentFilterContext context + ) => + ControllersMetaStore.Current.RoutedControllers.SelectMany(item => + CreateControllerActions(item, context) + ); + + private static IEnumerable 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 }, + RouteParameterTypes = GetRouteParameterTypes(item.ControllerType), + }; + + private static ControllerActionNames CreateNames(Type controllerType) => + CreateNames( + controllerType.FullName + ?? throw new InvalidOperationException("controllerType.FullName is null") + ); + + private static ControllerActionNames CreateNames(string name) + { + var src = FormatNameSource(name); + + var index = src.LastIndexOf("/"); + + return index == -1 + ? new ControllerActionNames(src, src) + : new ControllerActionNames(src, src.Substring(0, index), src.Substring(index + 1)); + } + + private static string FormatNameSource(string str) + { + foreach (var prefix in RemovePrefixes) + { + var prefixIndex = str.IndexOf(prefix); + + if (prefixIndex == -1) + continue; + + str = str.Substring(prefixIndex + prefix.Length); + } + + str = str.Replace(".", "/"); + + if (str.EndsWith("Controller")) + str = str.Substring(0, str.LastIndexOf("Controller")); + + return str; + } #if NET10_0 - private static NetHttpMethod HttpMethodToOperationType(HttpMethod method) => - method switch - { - 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 NetHttpMethod HttpMethodToOperationType(HttpMethod method) => + method switch + { + 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, + }; #else private static OperationType HttpMethodToOperationType(HttpMethod method) => method switch @@ -153,88 +153,88 @@ private static OperationType HttpMethodToOperationType(HttpMethod method) => }; #endif - private static OpenApiRequestBody CreateRequestBody( - Type controllerType, - DocumentFilterContext context - ) - { - var request = new OpenApiRequestBody(); - var attributes = controllerType.GetCustomAttributes(typeof(RequestBodyAttribute), false); - - if (attributes.Length <= 0) - return request; - - var item = (RequestBodyAttribute)attributes[0]; - - request.Content = new Dictionary - { - [item.ContentType] = new() - { - Schema = context.SchemaGenerator.GenerateSchema( - item.Model, - context.SchemaRepository - ), - }, - }; - - return request; - } - - private static IDictionary CreateResponses( - Type controllerType, - DocumentFilterContext context - ) => - controllerType - .GetCustomAttributes(typeof(ProducesResponseAttribute), false) - .Cast() - .ToDictionary(item => item.StatusCode, item => CreateResponse(item, 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, - }; - - foreach (var item in producesResponse.ContentTypes.Distinct()) - { + private static OpenApiRequestBody CreateRequestBody( + Type controllerType, + DocumentFilterContext context + ) + { + var request = new OpenApiRequestBody(); + var attributes = controllerType.GetCustomAttributes(typeof(RequestBodyAttribute), false); + + if (attributes.Length <= 0) + return request; + + var item = (RequestBodyAttribute)attributes[0]; + + request.Content = new Dictionary + { + [item.ContentType] = new() + { + Schema = context.SchemaGenerator.GenerateSchema( + item.Model, + context.SchemaRepository + ), + }, + }; + + return request; + } + + private static IDictionary CreateResponses( + Type controllerType, + DocumentFilterContext context + ) => + controllerType + .GetCustomAttributes(typeof(ProducesResponseAttribute), false) + .Cast() + .ToDictionary(item => item.StatusCode, item => CreateResponse(item, 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, + }; + + foreach (var item in producesResponse.ContentTypes.Distinct()) + { #if NET10_0 - response.Content ??= new Dictionary(); + response.Content ??= new Dictionary(); #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 GetRouteParameterTypes(Type controllerType) - { - var method = controllerType.GetMethod("Invoke") ?? controllerType.GetMethod("InvokeAsync"); - - if (method is null) - return new Dictionary(StringComparer.OrdinalIgnoreCase); - - return method - .GetParameters() - .Where(p => p.Name != null) - .ToDictionary(p => p.Name!, p => p.ParameterType, StringComparer.OrdinalIgnoreCase); - } -} + 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 GetRouteParameterTypes(Type controllerType) + { + var method = controllerType.GetMethod("Invoke") ?? controllerType.GetMethod("InvokeAsync"); + + if (method is null) + return new Dictionary(StringComparer.OrdinalIgnoreCase); + + return method + .GetParameters() + .Where(p => p.Name != null) + .ToDictionary(p => p.Name!, p => p.ParameterType, StringComparer.OrdinalIgnoreCase); + } +} \ No newline at end of file