RicoSuter / NSwag

The Swagger/OpenAPI toolchain for .NET, ASP.NET Core and TypeScript.
http://NSwag.org
MIT License
6.76k stars 1.29k forks source link

ASP.NET Core 2.2 WebAPI produces ProblemDetails for 400 responses instead of ValidationProblemDetails #2327

Closed LiangZugeng closed 5 years ago

LiangZugeng commented 5 years ago

According to the Microsoft official doc, if the compatible version is set to 2.2 in Startup.cs and ApiController attribute is set on controllers, the default response type for HTTP 400 responses is ValidationProblemDetails.

However in my WebAPI project, NSwag produced ProblemDetails response type for 400 responses instead of producing ValidationProblemDetails.

I would like to be able to use ValidationProblemDetails, any idea what caused the issue and how it can be fixed? Thanks.

.NET Core SDK: v2.2.300 Windows 10 Professional: v1809 (Build 17763.615)

WebAPI.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>netcoreapp2.2</TargetFramework>
    <AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
    <DebugType>portable</DebugType>
    <PreserveCompilationContext>true</PreserveCompilationContext>
    <AssemblyName>EaseSource.AIRMS.WebAPI</AssemblyName>
    <OutputType>Exe</OutputType>
    <RootNamespace>EaseSource.AIRMS.WebAPI</RootNamespace>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="AutoMapper" Version="8.1.1" />
    <PackageReference Include="Microsoft.AspNetCore.App" />
    <PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.2.0" PrivateAssets="All" />
    <PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.9.3" />
    <PackageReference Include="NSwag.AspNetCore" Version="13.0.4" />
    <PackageReference Include="NSwag.MSBuild" Version="13.0.4">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="StyleCop.Analyzers" Version="1.1.118">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Entity\Entity.csproj" />
    <ProjectReference Include="..\Utility\Utility.csproj" />
  </ItemGroup>

  <Target Name="NSwag" AfterTargets="Build">
    <Copy SourceFiles="@(ReferencePath)" DestinationFolder="$(OutDir)References" />
    <Exec Command="$(NSwagExe_Core22) run AIRMSWebAPI.nswag /variables:Configuration=$(Configuration),OutDir=$(OutDir)" />
    <RemoveDir Directories="$(OutDir)References" />
  </Target>
</Project>

swagger.json

{
  "x-generator": "NSwag v13.0.4.0 (NJsonSchema v10.0.21.0 (Newtonsoft.Json v11.0.0.0))",
  "openapi": "3.0.0",
  "info": {
    "title": "WebAPI",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "http://localhost:5000"
    }
  ],
  "paths": {
    "/api/Security/Login": {
      "post": {
        "tags": [
          "Security"
        ],
        "summary": "Login by password",
        "operationId": "Security_Login",
        "requestBody": {
          "x-name": "loginVM",
          "description": "Login View Model",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LoginModel"
              }
            }
          },
          "required": true,
          "x-position": 1
        },
        "responses": {
          "200": {
            "description": "Returns LoginInfoDTO when succeeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/LoginResultDTO"
                }
              }
            }
          },
          "400": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "401": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "ProblemDetails": {
        "type": "object",
        "additionalProperties": {
          "nullable": true
        },
        "properties": {
          "type": {
            "type": "string",
            "nullable": true
          },
          "title": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "detail": {
            "type": "string",
            "nullable": true
          },
          "instance": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "Operation": {
        "allOf": [
          {
            "$ref": "#/components/schemas/OperationBase"
          },
          {
            "type": "object",
            "additionalProperties": false,
            "properties": {
              "value": {
                "nullable": true
              }
            }
          }
        ]
      },
      "OperationBase": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "path": {
            "type": "string",
            "nullable": true
          },
          "op": {
            "type": "string",
            "nullable": true
          },
          "from": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "LoginResultDTO": {
        "type": "object",
        "description": "Login Result DTO",
        "additionalProperties": false,
        "properties": {
          "userInfo": {
            "description": "User Info",
            "nullable": true,
            "oneOf": [
              {
                "$ref": "#/components/schemas/UserInfoDTO"
              }
            ]
          },
          "jwtToken": {
            "type": "string",
            "description": "JWT Token",
            "nullable": true
          }
        }
      },
      "UserInfoDTO": {
        "type": "object",
        "description": "User Info DTO",
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "string",
            "description": "User Id",
            "format": "guid"
          },
          "userName": {
            "type": "string",
            "description": "User Name",
            "nullable": true
          },
          "fullName": {
            "type": "string",
            "description": "User Full Name",
            "nullable": true
          },
          "roles": {
            "type": "array",
            "description": "User Roles",
            "nullable": true,
            "items": {
              "type": "string"
            }
          }
        }
      },
      "LoginModel": {
        "type": "object",
        "description": "Login View Model",
        "additionalProperties": false,
        "required": [
          "userName",
          "password"
        ],
        "properties": {
          "userName": {
            "type": "string",
            "description": "User Name",
            "maxLength": 50,
            "minLength": 1
          },
          "password": {
            "type": "string",
            "description": "Password",
            "maxLength": 50,
            "minLength": 1
          }
        }
      }
    },
    "securitySchemes": {
      "JWT": {
        "type": "apiKey",
        "description": "Type into the textbox: Bearer {your JWT token}.",
        "name": "Authorization",
        "in": "header"
      }
    }
  },
  "security": [
    {
      "JWT": []
    }
  ]
}

ConfigureServices and Configure methods in Startup.cs

    public void ConfigureServices(IServiceCollection services)
    {
      // Add framework services.
      services.AddMvc()
      .AddJsonOptions(options =>
      {
        options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore;
        options.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
        options.SerializerSettings.DateTimeZoneHandling = DateTimeZoneHandling.Local;
      })
      .SetCompatibilityVersion(CompatibilityVersion.Version_2_2);

      services.AddDbContext<AIRMSDbContext>(options =>
        options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

      services.AddIdentity<AIRMSUser, AIRMSRole>(o =>
      {
        o.Password.RequireLowercase = false;
        o.Password.RequireUppercase = false;
        o.Password.RequireNonAlphanumeric = false;
        o.Password.RequiredLength = 4;
        o.Password.RequireDigit = false;
        o.User.RequireUniqueEmail = false;
      })
      .AddEntityFrameworkStores<AIRMSDbContext>()
      .AddDefaultTokenProviders();

      // Add Cross Origin Resource Sharing
#if DEBUG
      services.AddCors(o => o.AddPolicy(
        "CorsPolicy",
        builder =>
        builder.SetIsOriginAllowed((host) => true)
        .WithMethods("POST", "GET")
        .AllowAnyHeader()
        .AllowCredentials()));
#else
      services.AddCors(o => o.AddPolicy(
        "CorsPolicy",
        builder => builder.WithOrigins("http://localhost", "https://localhost")
        .WithMethods("POST", "GET")
        .AllowAnyHeader()
        .AllowCredentials()));
#endif

      services.AddOptions();
      services.Configure<JWTSettings>(Configuration.GetSection("JWTSettings"));
      var token = Configuration.GetSection("JWTSettings").Get<JWTSettings>();
      var secret = Encoding.ASCII.GetBytes(token.Secret);

      services.AddAuthentication(x =>
      {
        x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
      })
      .AddJwtBearer(x =>
      {
        x.RequireHttpsMetadata = false;
        x.SaveToken = true;
        x.TokenValidationParameters = new TokenValidationParameters
        {
          ValidateIssuerSigningKey = true,
          IssuerSigningKey = new SymmetricSecurityKey(secret),
          ValidIssuer = token.Issuer,
          ValidAudience = token.Audience,
          ValidateIssuer = false,
          ValidateAudience = false
        };
      });

      var config = new AutoMapper.MapperConfiguration(cfg =>
      {
        cfg.AddProfile(new AutoMapperProfileConfiguration());
      });
      var mapper = config.CreateMapper();
      services.AddSingleton(mapper);

      services.AddOpenApiDocument(doc =>
      {
        doc.PostProcess = d =>
        {
          d.Info.Title = "WebAPI";
          d.Schemes = new[] { OpenApiSchema.Http, OpenApiSchema.Https };
        };

        doc.AddSecurity("JWT", Enumerable.Empty<string>(), new OpenApiSecurityScheme
        {
          Type = OpenApiSecuritySchemeType.ApiKey,
          Name = "Authorization",
          In = OpenApiSecurityApiKeyLocation.Header,
          Description = "Type into the textbox: Bearer {your JWT token}."
        });

        doc.OperationProcessors.Add(new AspNetCoreOperationSecurityScopeProcessor("JWT"));
      });

      services.AddScoped<IUserInfoProvider, HttpContextUserInfoProvider>();
      services.AddSingleton<ITimeProvider, DefaultTimeProvider>();
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
      if (env.IsDevelopment())
      {
        app.UseDeveloperExceptionPage();
      }

      app.UseCors("CorsPolicy");
      app.UseAuthentication();
      app.UseStaticFiles();
      app.UseMvc();
      app.UseOpenApi();
      app.UseSwaggerUi3();
    }

Login Actions in Security controller

    [AllowAnonymous]
    [HttpPost]
    [Route("Login")]
    [ProducesResponseType(200)]
    [ProducesResponseType(400)]
    [ProducesResponseType(401)]
    public async Task<ActionResult<LoginResultDTO>> Login([FromBody] LoginModel loginVM)
    {
      if (loginVM == null)
      {
        return BadRequest();
      }

      var userName = loginVM.UserName;
      var password = loginVM.Password;
      var user = await userMgr.FindByNameAsync(userName);
      if (user == null)
      {
        ModelState.AddModelError("userNamePasswordNotMatch", "Username and password does not match");
        return GetValidationBadRequest();
      }

      bool isPwdValid = await userMgr.CheckPasswordAsync(user, password);
      if (!isPwdValid)
      {
        ModelState.AddModelError("userNamePasswordNotMatch", "Username and password does not match");
        return GetValidationBadRequest();
      }

      var loginResult = await SignInUserAsync(userMgr, user);

      return loginResult;
    }
LiangZugeng commented 5 years ago

I did some research and found a solution (explicitly specify the response type for 400 status code on every action), however this solution requires the response type added to every action, is there a global configuration to fix this?

Solution:

    [AllowAnonymous]
    [HttpPost]
    [Route("Login")]
    [ProducesResponseType(200)]
    // this is the fix, add "typeof(ValidationProblemDetails)"
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
    [ProducesResponseType(401)]
    public async Task<ActionResult<LoginResultDTO>> Login([FromBody] LoginModel loginVM)
RicoSuter commented 5 years ago

@pranavkm @dougbu is this a known issue or expected behavior? Is there a way to change these conventions globally?

pranavkm commented 5 years ago

MVC assumes ProblemDetails as the default response type for error status codes unless specified. For API controllers, returning any 4xx status code, including a 400, will return a ProblemDetails instance which is a more general purpose type of ValidationProblemDetails. Consider:

[ApiController]
[Route("[controller]/[action]")]
public class WeatherController
{
    public ActionResult<WeatherResult> UpdateWeather([Required, StringLength(6, 6)] string zipCode /* This should produce a ValidationProblemDetails if validation fails */ )
    {
        if (!IsNotLocatedInNorthDakota(zipCode))
        {
            return BadRequest(); // This produces a vanilla ProblemDetails
        }
    }
}

Is there a way to change these conventions globally?

I'd recommend looking at conventions: https://docs.microsoft.com/en-us/aspnet/core/web-api/advanced/conventions?view=aspnetcore-2.2

LiangZugeng commented 5 years ago

@pranavkm I tried the default conventions of ASP.NET Core 2.2, they also produced ProblemDetails type, did you mean that I should write our own custom conventions for ValidationProblemDetails?

Using the default convention for Create action

    [HttpPost]
    [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.c))]
    public async Task<ActionResult<AuditOfficeDTO>> Create([FromBody] AuditOfficeCreationModel aoCreationVM)
pranavkm commented 5 years ago

did you mean that I should write our own custom conventions for ValidationProblemDetails?

Yup. https://docs.microsoft.com/en-us/aspnet/core/web-api/advanced/conventions?view=aspnetcore-2.2#create-web-api-conventions has some guidance for this.

RicoSuter commented 5 years ago

Maybe we should update the nswag wiki with some guidance regarding this?

LiangZugeng commented 5 years ago

@pranavkm already done writing the custom conventions. Thanks for the help.

The following is the API convention class in case you need some sample code for the nswag wiki.

namespace WebAPI.Infrastructure
{
  using System;
  using Microsoft.AspNetCore.Http;
  using Microsoft.AspNetCore.Mvc;
  using Microsoft.AspNetCore.Mvc.ApiExplorer;

  /// <summary>
  /// Custom API Conventions
  /// </summary>
  public static class CustomAPIConventions
  {
    /// <summary>
    /// Find Action Convention
    /// </summary>
    /// <param name="parameters">The parameters of Find action</param>
    [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)]
    [ProducesDefaultResponseType]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
#pragma warning disable CA1801 // Remove unused parameter
    public static void Find(params object[] parameters)
#pragma warning restore CA1801 // Remove unused parameter
    {
    }

    /// <summary>
    /// GetById Action Convention
    /// </summary>
    /// <param name="id">Object Id</param>
    [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)]
    [ProducesDefaultResponseType]
    [ProducesResponseType(200)]
#pragma warning disable CA1801 // Remove unused parameter
    public static void GetById([ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)] Guid id)
#pragma warning restore CA1801 // Remove unused parameter
    {
    }

    /// <summary>
    /// Create Action Convention
    /// </summary>
    /// <param name="model">The creation view model Create action</param>
    [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)]
    [ProducesDefaultResponseType]
    [ProducesResponseType(StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
#pragma warning disable CA1801 // Remove unused parameter
    public static void Create([ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)][ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] object model)
#pragma warning restore CA1801 // Remove unused parameter
    {
    }

    /// <summary>
    /// Update Action Convention
    /// </summary>
    /// <param name="id">Object Id</param>
    /// <param name="patchDoc">JsonPatchDocument object containing all update and patch information</param>
    [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Prefix)]
    [ProducesDefaultResponseType]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
#pragma warning disable CA1801 // Remove unused parameter
    public static void Update([ApiConventionNameMatch(ApiConventionNameMatchBehavior.Suffix)][ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] Guid id, [ApiConventionNameMatch(ApiConventionNameMatchBehavior.Any)][ApiConventionTypeMatch(ApiConventionTypeMatchBehavior.Any)] object patchDoc)
#pragma warning restore CA1801 // Remove unused parameter
    {
    }
  }
}