WebApi + ClientApp, GraphQL, Reflection

This commit is contained in:
Boris Milašinović
2026-05-06 20:55:05 +02:00
parent 8f7c704a90
commit 4fb3de19f6
196 changed files with 10395 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Events.WebAPI.Util.Extensions;
public static class AddJsonPatchSupportExtension
{
public static void AddJsonPatchSupport(this MvcOptions options)
{
options.InputFormatters.Insert(0, GetJsonPatchInputFormatter());
}
// Add Newtonsoft only to the JSON Patch formatter so the rest of the API keeps System.Text.Json.
private static NewtonsoftJsonPatchInputFormatter GetJsonPatchInputFormatter()
{
var builder = new ServiceCollection()
.AddLogging()
.AddControllers()
.AddNewtonsoftJson()
.Services
.BuildServiceProvider();
return builder
.GetRequiredService<IOptions<MvcOptions>>()
.Value
.InputFormatters
.OfType<NewtonsoftJsonPatchInputFormatter>()
.First();
}
}

View File

@@ -0,0 +1,20 @@
namespace Events.WebAPI.Util.Extensions;
public static class DictionaryExtensions
{
public static TValue GetOrCreate<TKey, TValue>(this Dictionary<TKey, TValue> dict, TKey key) where TValue : new() where TKey : notnull
{
if (!dict.ContainsKey(key))
{
var item = new TValue();
dict[key] = item;
return item;
}
else
{
return dict[key];
}
}
}

View File

@@ -0,0 +1,28 @@
using System.Text;
namespace Events.WebAPI.Util.Extensions
{
/// <summary>
/// Class with useful extensions for exceptions handling
/// </summary>
public static class ExceptionExtensions
{
/// <summary>
/// return complete hierarchy of an exception. It checks whether the exception has inner exception,
/// and if it has, then it appends inner exception message.
/// Then it looks for inner exception of the inner exceptions, and so on.
/// </summary>
/// <param name="exc">Exception which message hiearchy should be obtained</param>
/// <returns>String containing all exception hierarchy messages</returns>
public static string CompleteExceptionMessage(this Exception? exc)
{
StringBuilder sb = new();
while (exc != null)
{
sb.AppendLine(exc.Message);
exc = exc.InnerException;
}
return sb.ToString();
}
}
}

View File

@@ -0,0 +1,68 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Events.WebAPI.Util.Extensions;
using Events.WebAPI.Contract.Command;
namespace Events.WebAPI.Util.Middleware;
public class BadRequestOnRuleValidationException : ExceptionFilterAttribute
{
private readonly ILogger<BadRequestOnRuleValidationException> logger;
public BadRequestOnRuleValidationException(ILogger<BadRequestOnRuleValidationException> logger)
{
this.logger = logger;
}
public override void OnException(ExceptionContext context)
{
if (context.Exception is ValidationException)
{
string exceptionMessage = context.Exception.CompleteExceptionMessage();
logger.LogDebug("Validation error: {0}", exceptionMessage);
ValidationException exc = (ValidationException)context.Exception;
Dictionary<string, List<string>> validationErrors = new Dictionary<string, List<string>>();
Dictionary<string, List<string>> validationErrorCodes = new Dictionary<string, List<string>>();
foreach(var failure in exc.Errors)
{
//remove prefix Dto. (part of Update and AddCommand)
string propertyName = failure.PropertyName.Replace(nameof(AddCommand<object, object>.Dto) + ".", "");
if (propertyName == nameof(AddCommand<object, object>.Dto))
{
propertyName = string.Empty;
}
validationErrors.GetOrCreate(propertyName).Add(failure.ErrorMessage);
if (!string.IsNullOrWhiteSpace(failure.ErrorCode))
{
validationErrorCodes.GetOrCreate(propertyName).Add(failure.ErrorCode);
}
}
var problemDetails = new ValidationProblemDetails(validationErrors.ToDictionary(d => d.Key, d => d.Value.ToArray()))
{
Detail = context.Exception.Message,
Title = "Validation exception",
Instance = context.HttpContext.TraceIdentifier
};
if (validationErrorCodes.Count > 0)
{
problemDetails.Extensions["errorCodes"] = validationErrorCodes.ToDictionary(d => d.Key, d => d.Value.ToArray());
}
context.Result = new ObjectResult(problemDetails)
{
ContentTypes = { "application/problem+json" },
StatusCode = StatusCodes.Status400BadRequest
};
context.HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
context.ExceptionHandled = true;
}
}
}

View File

@@ -0,0 +1,35 @@
using Events.WebAPI.Util.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace Events.WebAPI.Util.Middleware;
public class ProblemDetailsForException : ExceptionFilterAttribute
{
private readonly ILogger<ProblemDetailsForException> logger;
public ProblemDetailsForException(ILogger<ProblemDetailsForException> logger)
{
this.logger = logger;
}
public override void OnException(ExceptionContext context)
{
string exceptionMessage = context.Exception.CompleteExceptionMessage();
logger.LogError("Error 500: {0}", exceptionMessage); //TO DO: Log data from context.ActionDescriptor?
logger.LogError(context.Exception.StackTrace);
context.ExceptionHandled = true;
var problemDetails = new ProblemDetails
{
Type = "https://httpstatuses.io/500",
Detail = exceptionMessage,
Title = "Internal server error",
Instance = context.HttpContext.TraceIdentifier
};
context.Result = new ObjectResult(problemDetails)
{
ContentTypes = { "application/problem+json" },
StatusCode = StatusCodes.Status500InternalServerError
};
}
}

View File

@@ -0,0 +1,73 @@
using Events.WebAPI.Util.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace Events.WebAPI.Util.Middleware;
public class ProblemDetailsForSqlException : ExceptionFilterAttribute
{
private readonly ILogger<ProblemDetailsForSqlException> logger;
public ProblemDetailsForSqlException(ILogger<ProblemDetailsForSqlException> logger)
{
this.logger = logger;
}
public override void OnException(ExceptionContext context)
{
Exception? exception = context.Exception;
PostgresException? postgresException = null;
while (exception is not null)
{
if (exception is PostgresException currentPostgresException)
{
postgresException = currentPostgresException;
break;
}
if (exception is DbUpdateException dbUpdateException && dbUpdateException.InnerException is not null)
{
exception = dbUpdateException.InnerException;
continue;
}
exception = exception.InnerException;
}
if (postgresException is null)
{
base.OnException(context);
return;
}
ProblemDetails problemDetails = postgresException.SqlState switch
{
PostgresErrorCodes.UniqueViolation => new ProblemDetails
{
Title = "Duplicate data",
Detail = "A record with the same data already exists."
},
PostgresErrorCodes.ForeignKeyViolation => new ProblemDetails
{
Title = "Related data",
Detail = "The operation is not allowed because related data exists."
},
_ => new ProblemDetails
{
Title = "Database error",
Detail = $"An error occurred while saving data to the database. {postgresException.MessageText}"
}
};
logger.LogDebug("Database exception: {message}", context.Exception.CompleteExceptionMessage());
context.ExceptionHandled = true;
context.Result = new ObjectResult(problemDetails)
{
ContentTypes = { "application/problem+json" },
StatusCode = StatusCodes.Status500InternalServerError
};
}
}

View File

@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace Events.WebAPI.Util.Settings;
public class RabbitMqSettings
{
[Required]
public string Host { get; set; } = string.Empty;
[Required]
public string Username { get; set; } = string.Empty;
[Required]
public string Password { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,49 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
namespace Events.WebAPI.Util.Startup;
public static class AuthSetupExtensions
{
public static void SetupAuthenticationAndAuthorization(this IServiceCollection services, IConfiguration configuration)
{
Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.DefaultInboundClaimTypeMap.Clear();
services.AddScoped<IClaimsTransformation, ScopeClaimsTransformation>();
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
opt.Authority = configuration["Auth:Authority"];
opt.Audience = configuration["Auth:Audience"];
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = true,
ValidateIssuerSigningKey = true,
NameClaimType = ClaimTypes.NameIdentifier
};
opt.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))
{
context.Response.Headers.Append("Token-Expired", "true");
}
return Task.CompletedTask;
}
};
});
services.AddAuthorization(options =>
{
foreach (var policy in Policies.All)
{
options.AddPolicy(policy.Key, policy.Value);
}
});
}
}

View File

@@ -0,0 +1,36 @@
using Events.WebAPI.Util.Settings;
using MassTransit;
using Microsoft.Extensions.Options;
namespace Events.WebAPI.Util.Startup;
public static class MassTransitSetupExtensions
{
public static void SetupMassTransit(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<RabbitMqSettings>()
.Bind(configuration.GetSection("RabbitMq"))
.ValidateDataAnnotations()
.Validate(
settings => Uri.TryCreate(settings.Host, UriKind.Absolute, out var uri) &&
uri.Scheme == "rabbitmq" &&
!string.IsNullOrWhiteSpace(uri.Host),
"RabbitMq:Host must be a valid absolute rabbitmq:// URI.")
.ValidateOnStart();
services.AddMassTransit(x =>
{
x.UsingRabbitMq((context, cfg) =>
{
var settings = context.GetRequiredService<IOptions<RabbitMqSettings>>().Value;
cfg.Host(new Uri(settings.Host), h =>
{
h.Username(settings.Username);
h.Password(settings.Password);
});
});
});
}
}

View File

@@ -0,0 +1,47 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
namespace Events.WebAPI.Util.Startup;
public sealed class ScopeClaimsTransformation : IClaimsTransformation
{
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
if (principal.Identity is not ClaimsIdentity identity || !identity.IsAuthenticated)
{
return Task.FromResult(principal);
}
Claim[] combinedScopeClaims = identity
.FindAll("scope")
.Where(claim => claim.Value.Contains(' '))
.ToArray();
if (combinedScopeClaims.Length == 0)
{
return Task.FromResult(principal);
}
var additionalIdentity = new ClaimsIdentity();
foreach (Claim combinedClaim in combinedScopeClaims)
{
foreach (string scope in combinedClaim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (identity.HasClaim("scope", scope) || additionalIdentity.HasClaim("scope", scope))
{
continue;
}
additionalIdentity.AddClaim(new Claim("scope", scope, combinedClaim.ValueType, combinedClaim.Issuer));
}
}
if (additionalIdentity.Claims.Any())
{
principal.AddIdentity(additionalIdentity);
}
return Task.FromResult(principal);
}
}

View File

@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Events.WebAPI.Util.Swagger;
public static class AddBearerTokenSchemeExtension
{
public static void AddBearerTokenScheme(this SwaggerGenOptions opt)
{
var jwtSecurityScheme = new OpenApiSecurityScheme
{
Description = "Paste token here",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.Http,
Scheme = JwtBearerDefaults.AuthenticationScheme,
BearerFormat = "JWT",
};
//Dodaj Authorize button u Swagger UI
opt.AddSecurityDefinition(jwtSecurityScheme.Scheme, jwtSecurityScheme);
//opt.AddSecurityRequirement(document => new() { [new OpenApiSecuritySchemeReference(jwtSecurityScheme.Scheme, document)] = [] });
// nemoj ga primijeniti na sve operacije (redak iznad), nego samo na one koje imaju Authorize atribut
opt.OperationFilter<AuthorizeOperationFilter>();
}
}

View File

@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Events.WebAPI.Util.Swagger;
public class AuthorizeOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var hasAuthorize = context.MethodInfo.DeclaringType?
.GetCustomAttributes(true)
.OfType<AuthorizeAttribute>().Any() == true
||
context.MethodInfo.GetCustomAttributes(true)
.OfType<AuthorizeAttribute>().Any();
if (!hasAuthorize)
return;
operation.Security ??= new List<OpenApiSecurityRequirement>();
operation.Security.Add(new OpenApiSecurityRequirement
{
[
new OpenApiSecuritySchemeReference(
JwtBearerDefaults.AuthenticationScheme,
context.Document
)
] = new List<string>()
});
}
}

View File

@@ -0,0 +1,30 @@
using Events.WebAPI.Contract.Validation;
namespace Events.WebAPI.Util.Validation;
public class ValidationMessageProvider : IValidationMessageProvider
{
public ValidationMessage UniqueSportName(string sportName)
=> new(ValidationErrorCodes.SportNameNotUnique, $"A sport named '{sportName}' already exists.");
public ValidationMessage UniquePersonDocumentAndCountry()
=> new(ValidationErrorCodes.PersonDocumentCountryNotUnique, "A person with the same document number already exists for the selected country.");
public ValidationMessage PersonEmailOrContactPhoneRequired()
=> new(ValidationErrorCodes.PersonEmailOrContactPhoneRequired, "Either e-mail address or contact phone is required.");
public ValidationMessage UniqueRegistration()
=> new(ValidationErrorCodes.RegistrationNotUnique, "The person is already registered for the selected sport at this event.");
public ValidationMessage EventNotFound()
=> new(ValidationErrorCodes.EventNotFound, "The selected event does not exist.");
public ValidationMessage PersonNotFound()
=> new(ValidationErrorCodes.PersonNotFound, "The selected person does not exist.");
public ValidationMessage SportNotFound()
=> new(ValidationErrorCodes.SportNotFound, "The selected sport does not exist.");
public ValidationMessage ForeignKeyNotFound(string propertyName)
=> new(ValidationErrorCodes.ForeignKeyNotFound, $"The selected value for {propertyName} does not exist.");
}