diff --git a/Blueprint.Api/Infrastructure/Exceptions/Middleware/ExceptionMiddleware.cs b/Blueprint.Api/Infrastructure/Exceptions/Middleware/ExceptionMiddleware.cs new file mode 100644 index 0000000..ffbda8f --- /dev/null +++ b/Blueprint.Api/Infrastructure/Exceptions/Middleware/ExceptionMiddleware.cs @@ -0,0 +1,132 @@ +// Copyright 2022 Carnegie Mellon University. All Rights Reserved. +// Released under a MIT (SEI)-style license, please see LICENSE.md in the project root for license information or contact permission@sei.cmu.edu for full terms. + +using System; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Blueprint.Api.Infrastructure.Exceptions; + +namespace Blueprint.Api.Infrastructure.Exceptions.Middleware +{ + public class ExceptionMiddleware + { + private readonly IWebHostEnvironment _env; + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly ProblemDetailsFactory _problemDetailsFactory; + + public ExceptionMiddleware( + RequestDelegate next, + ILogger logger, + IWebHostEnvironment env, + ProblemDetailsFactory problemDetailsFactory) + { + _logger = logger; + _next = next; + _env = env; + _problemDetailsFactory = problemDetailsFactory; + } + + public async Task InvokeAsync(HttpContext httpContext) + { + try + { + await _next(httpContext); + } + catch (Exception ex) + { + // Transform PostgreSQL errors into clear messages + if (ex is DbUpdateException dbEx && dbEx.InnerException is PostgresException pgEx) + { + ex = TransformPostgresException(pgEx); + } + + _logger.LogError($"Unhandled Exception: {ex}"); + + await HandleExceptionAsync(httpContext, ex); + } + } + + private async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + int statusCode = GetStatusCodeFromException(exception); + + var error = new ProblemDetails(); + error.Status = statusCode; + + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/problem+json"; + + if (statusCode == (int)HttpStatusCode.InternalServerError) + { + if (_env.IsDevelopment()) + { + error.Title = exception.Message; + error.Detail = exception.ToString(); + } + else + { + error.Title = "A server error occurred."; + error.Detail = exception.Message; + } + } + else + { + error.Title = exception.Message; + } + + await context.Response.WriteAsync(JsonSerializer.Serialize(error)); + } + + /// + /// map all custom exceptions to proper http status code + /// + /// + private int GetStatusCodeFromException(Exception exception) + { + HttpStatusCode statusCode = HttpStatusCode.InternalServerError; + + if (exception is IApiException) + { + statusCode = (exception as IApiException).GetStatusCode(); + } + + return (int)statusCode; + } + + /// + /// Transform PostgreSQL exceptions into user-friendly messages. + /// Logs detailed error information while returning generic messages to prevent + /// exposing database internals to users. + /// + private Exception TransformPostgresException(PostgresException pgEx) + { + // Log detailed error for developers/ops + _logger.LogError($"PostgreSQL {pgEx.SqlState}: Table={pgEx.TableName}, Constraint={pgEx.ConstraintName}, Message={pgEx.MessageText}"); + + // Always return generic user-friendly messages + return pgEx.SqlState switch + { + "23505" => // unique_violation + new InvalidOperationException("A record with this identifier already exists."), + + "23503" => // foreign_key_violation + new InvalidOperationException("Referenced entity does not exist. Please verify all referenced entities exist."), + + "23514" => // check_violation + new InvalidOperationException("Data validation failed."), + + _ => new InvalidOperationException("A database error occurred.") + }; + } + } +} diff --git a/Blueprint.Api/Infrastructure/Filters/JsonExceptionFilter.cs b/Blueprint.Api/Infrastructure/Filters/JsonExceptionFilter.cs deleted file mode 100755 index 07bed75..0000000 --- a/Blueprint.Api/Infrastructure/Filters/JsonExceptionFilter.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright 2022 Carnegie Mellon University. All Rights Reserved. -// Released under a MIT (SEI)-style license, please see LICENSE.md in the project root for license information or contact permission@sei.cmu.edu for full terms. - -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.Hosting; -using Blueprint.Api.Infrastructure.Exceptions; -using Blueprint.Api.ViewModels; -using System; -using System.Net; - -namespace Blueprint.Api.Infrastructure.Filters -{ - public class JsonExceptionFilter : IExceptionFilter - { - private readonly IWebHostEnvironment _env; - - public JsonExceptionFilter(IWebHostEnvironment env) - { - _env = env; - } - - public void OnException(ExceptionContext context) - { - var error = new ApiError(); - error.Status = GetStatusCodeFromException(context.Exception); - - if(error.Status == (int)HttpStatusCode.InternalServerError) - { - if (_env.IsDevelopment()) - { - error.Title = context.Exception.Message; - error.Detail = context.Exception.StackTrace; - } - else - { - error.Title = "A server error occurred."; - error.Detail = context.Exception.Message; - } - } - else - { - error.Title = context.Exception.Message; - } - - context.Result = new JsonResult(error) - { - StatusCode = error.Status - }; - } - - /// - /// map all custom exceptions to proper http status code - /// - /// - private static int GetStatusCodeFromException(Exception exception) - { - HttpStatusCode statusCode = HttpStatusCode.InternalServerError; - - if (exception is IApiException) - { - statusCode = (exception as IApiException).GetStatusCode(); - } - - return (int)statusCode; - } - } -} - diff --git a/Blueprint.Api/Services/CardService.cs b/Blueprint.Api/Services/CardService.cs index 13d11ec..38111c1 100644 --- a/Blueprint.Api/Services/CardService.cs +++ b/Blueprint.Api/Services/CardService.cs @@ -10,6 +10,8 @@ using System.Threading.Tasks; using AutoMapper; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Npgsql; using Blueprint.Api.Data; using Blueprint.Api.Data.Models; using Blueprint.Api.Infrastructure.Authorization; @@ -34,15 +36,18 @@ public class CardService : ICardService private readonly BlueprintContext _context; private readonly ClaimsPrincipal _user; private readonly IMapper _mapper; + private readonly ILogger _logger; public CardService( BlueprintContext context, IPrincipal user, - IMapper mapper) + IMapper mapper, + ILogger logger) { _context = context; _user = user as ClaimsPrincipal; _mapper = mapper; + _logger = logger; } public async Task> GetTemplatesAsync(CancellationToken ct) @@ -95,14 +100,27 @@ public CardService( if (!hasGalleryCardPermission) throw new ForbiddenException(); } + + // Validate required fields + if (string.IsNullOrWhiteSpace(card.Name)) + throw new ArgumentException("Card Name is required and cannot be empty."); + + // Validate MselId if provided + if (card.MselId.HasValue && card.MselId.Value != Guid.Empty) + { + var mselExists = await _context.Msels.AnyAsync(m => m.Id == card.MselId.Value, ct); + if (!mselExists) + throw new EntityNotFoundException($"Invalid MselId '{card.MselId}'. The MSEL does not exist."); + } + card.Id = card.Id != Guid.Empty ? card.Id : Guid.NewGuid(); card.CreatedBy = _user.GetId(); var cardEntity = _mapper.Map(card); _context.Cards.Add(cardEntity); await _context.SaveChangesAsync(ct); - card = await GetAsync(cardEntity.Id, true, ct); + card = await GetAsync(cardEntity.Id, true, ct); return card; } diff --git a/Blueprint.Api/Services/MselService.cs b/Blueprint.Api/Services/MselService.cs index 5c879f7..64a090c 100755 --- a/Blueprint.Api/Services/MselService.cs +++ b/Blueprint.Api/Services/MselService.cs @@ -16,6 +16,7 @@ using AutoMapper; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Npgsql; using Blueprint.Api.Data; using Blueprint.Api.Data.Enumerations; using Blueprint.Api.Data.Models; @@ -243,14 +244,18 @@ public MselService( public async Task CreateAsync(ViewModels.Msel msel, CancellationToken ct) { + // Validate required fields + if (string.IsNullOrWhiteSpace(msel.Name)) + throw new ArgumentException("MSEL Name is required and cannot be empty."); + msel.Id = msel.Id != Guid.Empty ? msel.Id : Guid.NewGuid(); msel.CreatedBy = _user.GetId(); var mselEntity = _mapper.Map(msel); _context.Msels.Add(mselEntity); await _context.SaveChangesAsync(ct); - msel = await GetAsync(mselEntity.Id, true, ct); + msel = await GetAsync(mselEntity.Id, true, ct); return msel; } diff --git a/Blueprint.Api/Services/TeamService.cs b/Blueprint.Api/Services/TeamService.cs index 785d17b..620990c 100755 --- a/Blueprint.Api/Services/TeamService.cs +++ b/Blueprint.Api/Services/TeamService.cs @@ -12,6 +12,7 @@ using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Npgsql; using Blueprint.Api.Data; using Blueprint.Api.Data.Models; using Blueprint.Api.Infrastructure.Extensions; @@ -107,6 +108,18 @@ public TeamService(BlueprintContext context, IPrincipal team, ILogger m.Id == team.MselId, ct); + if (!mselExists) + throw new EntityNotFoundException($"Invalid MselId '{team.MselId}'. The MSEL does not exist."); + team.Id = team.Id != Guid.Empty ? team.Id : Guid.NewGuid(); team.CreatedBy = _user.GetId(); var teamEntity = _mapper.Map(team); diff --git a/Blueprint.Api/Startup.cs b/Blueprint.Api/Startup.cs index d539ea9..f9b5b84 100755 --- a/Blueprint.Api/Startup.cs +++ b/Blueprint.Api/Startup.cs @@ -126,7 +126,6 @@ public void ConfigureServices(IServiceCollection services) services.AddMvc(options => { options.Filters.Add(typeof(ValidateModelStateFilter)); - options.Filters.Add(typeof(JsonExceptionFilter)); // Require all scopes in authOptions var policyBuilder = new AuthorizationPolicyBuilder().RequireAuthenticatedUser(); @@ -266,10 +265,7 @@ public void ConfigureServices(IServiceCollection services) // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } + app.UseMiddleware(); app.UsePathBase(_pathbase); app.UseRouting(); app.UseCors("default");