diff --git a/Events-WebApi/Events-WebApi.slnx b/Events-WebApi/Events-WebApi.slnx index 196d958..4f190c3 100644 --- a/Events-WebApi/Events-WebApi.slnx +++ b/Events-WebApi/Events-WebApi.slnx @@ -1,4 +1,5 @@ + diff --git a/Events-WebApi/Events.WebAPI/Util/Startup/AuthSetupExtensions.cs b/Events-WebApi/Events.Auth/AuthSetupExtensions.cs similarity index 79% rename from Events-WebApi/Events.WebAPI/Util/Startup/AuthSetupExtensions.cs rename to Events-WebApi/Events.Auth/AuthSetupExtensions.cs index aab681a..ff86c14 100644 --- a/Events-WebApi/Events.WebAPI/Util/Startup/AuthSetupExtensions.cs +++ b/Events-WebApi/Events.Auth/AuthSetupExtensions.cs @@ -1,14 +1,19 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; -namespace Events.WebAPI.Util.Startup; +namespace Events.Auth; public static class AuthSetupExtensions { - public static void SetupAuthenticationAndAuthorization(this IServiceCollection services, IConfiguration configuration) + public static void SetupAuthenticationAndAuthorization(this IServiceCollection services, string authority, string audience) { + ArgumentException.ThrowIfNullOrWhiteSpace(authority); + ArgumentException.ThrowIfNullOrWhiteSpace(audience); + Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.DefaultInboundClaimTypeMap.Clear(); services.AddScoped(); @@ -16,8 +21,8 @@ public static class AuthSetupExtensions services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(opt => { - opt.Authority = configuration["Auth:Authority"]; - opt.Audience = configuration["Auth:Audience"]; + opt.Authority = authority; + opt.Audience = audience; opt.TokenValidationParameters = new TokenValidationParameters { ValidateAudience = true, diff --git a/Events-WebApi/Events.Auth/Events.Auth.csproj b/Events-WebApi/Events.Auth/Events.Auth.csproj new file mode 100644 index 0000000..7e91f5c --- /dev/null +++ b/Events-WebApi/Events.Auth/Events.Auth.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/Events-WebApi/Events.WebAPI/Policies.cs b/Events-WebApi/Events.Auth/Policies.cs similarity index 51% rename from Events-WebApi/Events.WebAPI/Policies.cs rename to Events-WebApi/Events.Auth/Policies.cs index ec88332..ea5a1d2 100644 --- a/Events-WebApi/Events.WebAPI/Policies.cs +++ b/Events-WebApi/Events.Auth/Policies.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Authorization; -namespace Events.WebAPI; +namespace Events.Auth; -public class Policies +public static class Policies { public static IEnumerable>> All { @@ -13,19 +13,9 @@ public class Policies } } - public static Action ReadData - { - get - { - return policy => policy.RequireClaim("scope", "events:read"); - } - } + public static Action ReadData => + policy => policy.RequireClaim("scope", "events:read"); - public static Action EditData - { - get - { - return policy => policy.RequireClaim("scope", "events:write"); - } - } + public static Action EditData => + policy => policy.RequireClaim("scope", "events:write"); } diff --git a/Events-WebApi/Events.WebAPI/Util/Startup/ScopeClaimsTransformation.cs b/Events-WebApi/Events.Auth/ScopeClaimsTransformation.cs similarity index 97% rename from Events-WebApi/Events.WebAPI/Util/Startup/ScopeClaimsTransformation.cs rename to Events-WebApi/Events.Auth/ScopeClaimsTransformation.cs index f03b310..35726b3 100644 --- a/Events-WebApi/Events.WebAPI/Util/Startup/ScopeClaimsTransformation.cs +++ b/Events-WebApi/Events.Auth/ScopeClaimsTransformation.cs @@ -1,7 +1,7 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authentication; -namespace Events.WebAPI.Util.Startup; +namespace Events.Auth; public sealed class ScopeClaimsTransformation : IClaimsTransformation { diff --git a/Events-WebApi/Events.ClientApp/README.md b/Events-WebApi/Events.ClientApp/README.md index 8ff0414..6bb7b32 100644 --- a/Events-WebApi/Events.ClientApp/README.md +++ b/Events-WebApi/Events.ClientApp/README.md @@ -1,6 +1,6 @@ # Events.ClientApp -`Events.ClientApp` is the Vue 3 front-end for Topic 2. +`Events.ClientApp` is the Vue 3 front-end for the `Events-WebApi` solution. It uses: @@ -9,7 +9,10 @@ It uses: - PrimeVue - Auth0 Vue SDK -It is intended as a companion UI for the `Events.WebAPI` backend and demonstrates how the secured API can be consumed from a browser application. +It is intended as a companion UI for: + +- `Events.WebAPI` for CRUD and lookup operations +- `Events.FilesAPI` for certificate and Excel downloads ## Scripts @@ -59,7 +62,7 @@ The simplest setup is to copy `.env.example` to `.env.local` and fill in the rea Example: ```powershell -Copy-Item Topic2\Events.ClientApp\.env.example Topic2\Events.ClientApp\.env.local +Copy-Item .env.example .env.local ``` ## Environment Variables @@ -83,12 +86,19 @@ Copy-Item Topic2\Events.ClientApp\.env.example Topic2\Events.ClientApp\.env.loca ### API configuration - `VITE_API_BASE_URL` - Base URL of the Web API + Base URL of `Events.WebAPI` + +- `VITE_FILES_API_BASE_URL` + Base URL of `Events.FilesAPI` If `VITE_API_BASE_URL` is not set, the app falls back to: - `https://localhost:7150` +If `VITE_FILES_API_BASE_URL` is not set, the app falls back to: + +- `https://localhost:7296` + ## Example ```env @@ -96,18 +106,21 @@ VITE_AUTH0_DOMAIN=fer-web2.eu.auth0.com VITE_AUTH0_CLIENT_ID=whed5Hdb8l1b1fGyyAz7Qrdsb2oKcSh3 VITE_AUTH0_AUDIENCE=https://erasmus-sta-2026/events-api VITE_AUTH0_SCOPE=openid profile email events:read events:write -VITE_API_BASE_URL=https://localhost:7150 +VITE_API_BASE_URL=https://localhost:7295 +VITE_FILES_API_BASE_URL=https://localhost:7296 ``` ## Notes - `VITE_AUTH0_DOMAIN` and `VITE_AUTH0_CLIENT_ID` are required if you want the Auth0 login flow to work. -- `VITE_AUTH0_AUDIENCE` and `VITE_AUTH0_SCOPE` are optional in code, but usually needed if the API expects bearer tokens with a specific audience and scopes. +- `VITE_AUTH0_AUDIENCE` and `VITE_AUTH0_SCOPE` are optional in code, but are usually needed if the APIs expect bearer tokens with a specific audience and scopes. - `VITE_API_BASE_URL` should point to the running `Events.WebAPI` instance for local development. +- `VITE_FILES_API_BASE_URL` should point to the running `Events.FilesAPI` instance for local development. - `.env.local` is for local development and should not be treated as a shared secrets file. ## What The Client Demonstrates - login and token acquisition through Auth0 -- calling the secured Topic 2 API -- local development against a separately running ASP.NET Core backend +- calling secured `Events.WebAPI` endpoints +- downloading protected files from `Events.FilesAPI` +- local development against separately running ASP.NET Core backends diff --git a/Events-WebApi/Events.FilesAPI/Events.FilesAPI.csproj b/Events-WebApi/Events.FilesAPI/Events.FilesAPI.csproj index b0c8e66..40aa1dc 100644 --- a/Events-WebApi/Events.FilesAPI/Events.FilesAPI.csproj +++ b/Events-WebApi/Events.FilesAPI/Events.FilesAPI.csproj @@ -14,11 +14,13 @@ + + diff --git a/Events-WebApi/Events.FilesAPI/Features/Certificates/DownloadCertificateController.cs b/Events-WebApi/Events.FilesAPI/Features/Certificates/DownloadCertificateController.cs index f7268b4..d83c1f5 100644 --- a/Events-WebApi/Events.FilesAPI/Features/Certificates/DownloadCertificateController.cs +++ b/Events-WebApi/Events.FilesAPI/Features/Certificates/DownloadCertificateController.cs @@ -1,10 +1,13 @@ using Microsoft.AspNetCore.Mvc; +using Events.Auth; using Events.FilesAPI.Features.Certificates.Download; using MediatR; +using Microsoft.AspNetCore.Authorization; namespace Events.FilesAPI.Features.Certificates; [ApiController] +[Authorize(Policy = nameof(Policies.ReadData))] [Route("Registrations")] public class DownloadCertificateController : ControllerBase { diff --git a/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/DownloadRegistrationsExcelController.cs b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/DownloadRegistrationsExcelController.cs index eccdfda..7b5c8df 100644 --- a/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/DownloadRegistrationsExcelController.cs +++ b/Events-WebApi/Events.FilesAPI/Features/RegistrationsExcel/DownloadRegistrationsExcelController.cs @@ -1,10 +1,13 @@ using Microsoft.AspNetCore.Mvc; +using Events.Auth; using Events.FilesAPI.Features.RegistrationsExcel.Download; using MediatR; +using Microsoft.AspNetCore.Authorization; namespace Events.FilesAPI.Features.RegistrationsExcel; [ApiController] +[Authorize(Policy = nameof(Policies.ReadData))] [Route("Events")] public class DownloadRegistrationsExcelController : ControllerBase { diff --git a/Events-WebApi/Events.FilesAPI/Program.cs b/Events-WebApi/Events.FilesAPI/Program.cs index f6af075..79a22c3 100644 --- a/Events-WebApi/Events.FilesAPI/Program.cs +++ b/Events-WebApi/Events.FilesAPI/Program.cs @@ -1,9 +1,7 @@ -using Events.FilesAPI.Features.Certificates; -using Events.FilesAPI.Features.RegistrationsExcel; +using Events.Auth; using Events.FilesAPI.Infrastructure.Messaging; using Events.FilesAPI.Infrastructure.Options; using Events.WebAPI.Handlers.EF.Data.Postgres; -using MediatR; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -11,7 +9,7 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddDbContext(options => - options.UseNpgsql(builder.Configuration.GetConnectionString("EventDB"))); + options.UseNpgsql(builder.Configuration.GetConnectionString("EventsPostgres"))); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection("Paths")) @@ -24,9 +22,26 @@ builder.Services.AddOptions() builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)); builder.Services.SetupMassTransit(builder.Configuration); +builder.Services.SetupAuthenticationAndAuthorization( + builder.Configuration["Auth:Authority"] ?? throw new InvalidOperationException("Missing configuration value Auth:Authority."), + builder.Configuration["Auth:Audience"] ?? throw new InvalidOperationException("Missing configuration value Auth:Audience.")); var app = builder.Build(); +app.UseRouting(); + +app.UseCors(builder => +{ + builder + .AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader() + .WithExposedHeaders("Token-Expired", "Content-Disposition"); +}); + +app.UseAuthentication(); +app.UseAuthorization(); + app.MapControllers(); app.Run(); diff --git a/Events-WebApi/Events.FilesAPI/appsettings.json b/Events-WebApi/Events.FilesAPI/appsettings.json index ec009c6..e667cfe 100644 --- a/Events-WebApi/Events.FilesAPI/appsettings.json +++ b/Events-WebApi/Events.FilesAPI/appsettings.json @@ -15,6 +15,10 @@ "OutputPath": "./Certificates" }, "ConnectionStrings": { - "EventDB": "Host=localhost;Port=5432;Database=events;Username=sport;Password=go and look in the secrets file;Persist Security Info=True;" + "EventsPostgres": "Host=localhost;Port=5432;Database=events;Username=sport;Password=go and look in the secrets file;Persist Security Info=True;" + }, + "Auth": { + "Authority": "https://fer-web2.eu.auth0.com/", + "Audience": "https://erasmus-sta-2026/events-api" } } diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/Generic/GenericCommandHandler.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/Generic/GenericCommandHandler.cs index 80694fa..d54b05e 100644 --- a/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/Generic/GenericCommandHandler.cs +++ b/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/Generic/GenericCommandHandler.cs @@ -15,42 +15,42 @@ public class GenericCommandHandler : IRequestHandler where TPK : IEquatable { - protected DbContext Ctx { get; } - protected ILogger Logger { get; } - protected IMapper Mapper { get; } + protected DbContext ctx { get; } + protected ILogger logger { get; } + protected IMapper mapper { get; } protected GenericCommandHandler(DbContext ctx, ILogger logger, IMapper mapper) { - Ctx = ctx; - Logger = logger; - Mapper = mapper; + this.ctx = ctx; + this.logger = logger; + this.mapper = mapper; } public virtual async Task Handle(AddCommand request, CancellationToken cancellationToken) { - var entity = Mapper.Map(request.Dto); - Ctx.Add(entity); - await Ctx.SaveChangesAsync(cancellationToken); + var entity = mapper.Map(request.Dto); + ctx.Add(entity); + await ctx.SaveChangesAsync(cancellationToken); return entity.Id; } public virtual async Task Handle(UpdateCommand request, CancellationToken cancellationToken) { - var entity = await Ctx.Set().FindAsync(request.Dto.Id); + var entity = await ctx.Set().FindAsync(request.Dto.Id); if (entity != null) { - Mapper.Map(request.Dto, entity); - await Ctx.SaveChangesAsync(cancellationToken); + mapper.Map(request.Dto, entity); + await ctx.SaveChangesAsync(cancellationToken); } else { - Logger.LogError($"UpdateCommand<{typeof(TDto).Name}> : Invalid id #{request.Dto.Id}"); + logger.LogError($"UpdateCommand<{typeof(TDto).Name}> : Invalid id #{request.Dto.Id}"); throw new ArgumentException($"Invalid id: {request.Dto.Id}"); } } public virtual async Task Handle(DeleteCommand request, CancellationToken cancellationToken) { - await Ctx.Set().Where(d => d.Id.Equals(request.Id)).ExecuteDeleteAsync(cancellationToken); + await ctx.Set().Where(d => d.Id.Equals(request.Id)).ExecuteDeleteAsync(cancellationToken); } } diff --git a/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/RegistrationsCommandsHandler.cs b/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/RegistrationsCommandsHandler.cs index b3e41ed..ac27d02 100644 --- a/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/RegistrationsCommandsHandler.cs +++ b/Events-WebApi/Events.WebAPI.Handlers.EF/CommandHandlers/RegistrationsCommandsHandler.cs @@ -43,10 +43,10 @@ public class RegistrationsCommandsHandler : GenericCommandHandler request, CancellationToken cancellationToken) { - var entity = await Ctx.Set().SingleOrDefaultAsync(r => r.Id == request.Dto.Id, cancellationToken); + var entity = await ctx.Set().SingleOrDefaultAsync(r => r.Id == request.Dto.Id, cancellationToken); if (entity == null) { - Logger.LogError("UpdateCommand<{DtoName}> : Invalid id #{Id}", typeof(RegistrationDTO).Name, request.Dto.Id); + logger.LogError("UpdateCommand<{DtoName}> : Invalid id #{Id}", typeof(RegistrationDTO).Name, request.Dto.Id); throw new ArgumentException($"Invalid id: {request.Dto.Id}"); } @@ -70,13 +70,13 @@ public class RegistrationsCommandsHandler : GenericCommandHandler request, CancellationToken cancellationToken) { - var entity = await Ctx.Set() + var entity = await ctx.Set() .AsNoTracking() .SingleOrDefaultAsync(r => r.Id == request.Id, cancellationToken); if (entity == null) { - Logger.LogError("DeleteCommand<{DtoName}> : Invalid id #{Id}", typeof(RegistrationDTO).Name, request.Id); + logger.LogError("DeleteCommand<{DtoName}> : Invalid id #{Id}", typeof(RegistrationDTO).Name, request.Id); throw new ArgumentException($"Invalid id: {request.Id}"); } diff --git a/Events-WebApi/Events.WebAPI/Controllers/Generic/CrudController.cs b/Events-WebApi/Events.WebAPI/Controllers/Generic/CrudController.cs index 900c6da..11fe458 100644 --- a/Events-WebApi/Events.WebAPI/Controllers/Generic/CrudController.cs +++ b/Events-WebApi/Events.WebAPI/Controllers/Generic/CrudController.cs @@ -1,4 +1,5 @@ using Events.WebAPI.Contract.Command; +using Events.Auth; using Events.WebAPI.Contract.DTOs; using Events.WebAPI.Contract.Queries.Generic; using MediatR; diff --git a/Events-WebApi/Events.WebAPI/Controllers/Generic/GetController.cs b/Events-WebApi/Events.WebAPI/Controllers/Generic/GetController.cs index 70a1258..1151212 100644 --- a/Events-WebApi/Events.WebAPI/Controllers/Generic/GetController.cs +++ b/Events-WebApi/Events.WebAPI/Controllers/Generic/GetController.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Events.Auth; using Events.WebAPI.Contract.DTOs; using Events.WebAPI.Contract.Queries.Generic; using Events.WebAPI.Models; diff --git a/Events-WebApi/Events.WebAPI/Controllers/LookupController.cs b/Events-WebApi/Events.WebAPI/Controllers/LookupController.cs index af1a608..6dc8cdf 100644 --- a/Events-WebApi/Events.WebAPI/Controllers/LookupController.cs +++ b/Events-WebApi/Events.WebAPI/Controllers/LookupController.cs @@ -1,4 +1,5 @@ using Events.WebAPI.Contract.DTOs; +using Events.Auth; using Events.WebAPI.Contract.LookupQueries; using MediatR; using Microsoft.AspNetCore.Authorization; @@ -8,10 +9,10 @@ namespace Events.WebAPI.Controllers; [ApiController] [Route("[controller]/[action]")] -public class LookupController : ControllerBase +public class LookupController(IMediator mediator) : ControllerBase { [HttpGet] - public async Task>>> Countries(string? text, [FromServices] IMediator mediator) + public async Task>>> Countries(string? text) { var countries = await mediator.Send(new LookupCountryQuery { Text = text }); return countries; @@ -19,7 +20,7 @@ public class LookupController : ControllerBase [Authorize(Policy = nameof(Policies.ReadData))] [HttpGet] - public async Task>>> People(string? text, string? countryCode, [FromServices] IMediator mediator) + public async Task>>> People(string? text, string? countryCode) { var people = await mediator.Send(new LookupPeopleQuery { diff --git a/Events-WebApi/Events.WebAPI/Events.WebAPI.csproj b/Events-WebApi/Events.WebAPI/Events.WebAPI.csproj index 4e26d7d..c527899 100644 --- a/Events-WebApi/Events.WebAPI/Events.WebAPI.csproj +++ b/Events-WebApi/Events.WebAPI/Events.WebAPI.csproj @@ -25,6 +25,7 @@ + diff --git a/Events-WebApi/Events.WebAPI/Program.cs b/Events-WebApi/Events.WebAPI/Program.cs index 88820c1..38bbedf 100644 --- a/Events-WebApi/Events.WebAPI/Program.cs +++ b/Events-WebApi/Events.WebAPI/Program.cs @@ -1,5 +1,6 @@ using System.Reflection; using AutoMapper; +using Events.Auth; using Events.WebAPI; using Events.WebAPI.Contract.Validation.Sport; using Events.WebAPI.Contract.Validation; @@ -23,7 +24,7 @@ builder.Services .AddJsonOptions(configure => configure.JsonSerializerOptions.PropertyNamingPolicy = null); builder.Services.AddDbContext(options => - options.UseNpgsql(builder.Configuration.GetConnectionString("EventDB"))); + options.UseNpgsql(builder.Configuration.GetConnectionString("EventsPostgres"))); builder.Services.Configure(builder.Configuration.GetSection("Sieve")); builder.Services.AddScoped(); @@ -31,12 +32,12 @@ builder.Services.AddScoped), typeof(ValidationBehaviour<,>)); builder.Services.AddValidatorsFromAssemblyContaining(typeof(AddSportValidator)); -builder.Services.AddMediatR(cfg => { - cfg.RegisterServicesFromAssembly(typeof(SportsQueryHandler).Assembly); -}); +builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(CountriesLookupQueryHandler).Assembly)); builder.Services.SetupMassTransit(builder.Configuration); -builder.Services.SetupAuthenticationAndAuthorization(builder.Configuration); +builder.Services.SetupAuthenticationAndAuthorization( + builder.Configuration["Auth:Authority"] ?? throw new InvalidOperationException("Missing configuration value Auth:Authority."), + builder.Configuration["Auth:Audience"] ?? throw new InvalidOperationException("Missing configuration value Auth:Audience.")); #region AutoMapper settings Action mapperConfigAction = (serviceProvider, cfg) => diff --git a/Events-WebApi/Events.WebAPI/appsettings.json b/Events-WebApi/Events.WebAPI/appsettings.json index b01deb2..60ddb54 100644 --- a/Events-WebApi/Events.WebAPI/appsettings.json +++ b/Events-WebApi/Events.WebAPI/appsettings.json @@ -21,7 +21,7 @@ "Password": "guest" }, "ConnectionStrings": { - "EventDB": "Host=localhost;Port=5432;Database=events;Username=sport;Password=go and look in the secrets file;Persist Security Info=True;" + "EventsPostgres": "Host=localhost;Port=5432;Database=events;Username=sport;Password=go and look in the secrets file;Persist Security Info=True;" }, "Auth": { "Authority": "https://fer-web2.eu.auth0.com/", diff --git a/Events-WebApi/README.md b/Events-WebApi/README.md index 2f8381b..28ebadb 100644 --- a/Events-WebApi/README.md +++ b/Events-WebApi/README.md @@ -1,121 +1,192 @@ ## Solution Overview +`Events-WebApi` currently contains these projects: +- `Events.ClientApp` - Vue 3 + Vite single-page application +- `Events.WebAPI` - main ASP.NET Core REST API for CRUD and lookup operations +- `Events.FilesAPI` - ASP.NET Core API for certificate and Excel export downloads +- `Events.Auth` - shared authentication project for JWT and policy configuration +- `Events.WebAPI.Contract` - DTO, command, query, and message contracts +- `Events.WebAPI.Handlers.EF` - EF Core models, `DbContext`, and handlers -Swagger UI is exposed at: +The typical runtime setup is: -```text -https://localhost:7290/docs -``` +- `Events.ClientApp` calls `Events.WebAPI` +- `Events.ClientApp` downloads files from `Events.FilesAPI` +- `Events.WebAPI` publishes registration events to RabbitMQ +- `Events.FilesAPI` consumes those events and synchronizes generated files +- both APIs use PostgreSQL and the same Auth0 authority/audience -The exact port may vary depending on your local launch profile. +## Default Local URLs + +According to the current `launchSettings.json` files: + +- `Events.WebAPI` -> `https://localhost:7295` +- `Events.FilesAPI` -> `https://localhost:7296` +- Swagger for `Events.WebAPI` -> `https://localhost:7295/docs` +- the Vite dev server for `Events.ClientApp` is typically `http://localhost:5173` + +Ports may differ if you change the launch profile or run the projects with a different profile. ## Prerequisites - .NET SDK 10.0 -- Docker Desktop -- PostgreSQL, usually via [docker-definitions](docker-definitions/README.md) -- RabbitMQ if you want to run the full API with its real MassTransit transport -- Node.js 20+ for the client app -- An Auth0 tenant if you want to run real bearer-token and browser-login flows outside the test suite +- Node.js 20+ +- PostgreSQL +- RabbitMQ +- An Auth0 tenant if you want real login and bearer-token flows + +## Authentication Setup Options + +To use the solution as-is, you need working Auth0 configuration for both: + +- an API application using the configured audience +- a SPA application used by `Events.ClientApp` + +In practice, that means you either: + +1. create and configure the required applications in Auth0, then keep the current `Authorize` attributes and SPA login flow +2. simplify the solution for local/demo usage by removing `Authorize` attributes from the APIs and removing Auth0-based authorization from the SPA + +If you choose the second option, remember that: + +- `Events.WebAPI` and `Events.FilesAPI` currently expect bearer tokens on protected endpoints +- `Events.ClientApp` is wired to request Auth0 access tokens before calling protected APIs +- removing authorization from only one layer usually is not enough; the APIs and SPA should be adjusted together ## Configuration -`Events.WebAPI` reads settings from: +### WebAPI and FilesAPI -- [Events.WebAPI/appsettings.json](Topic2/Events.WebAPI/appsettings.json) -- [Events.WebAPI/appsettings.Development.json](Topic2/Events.WebAPI/appsettings.Development.json) -- the shared .NET user secrets store with id `Erasmus-STA-2026` +Both APIs use: -Important configuration sections: - -- `ConnectionStrings:EventDB` - `RabbitMq:Host` - `RabbitMq:Username` - `RabbitMq:Password` - `Auth:Authority` - `Auth:Audience` -- `Paths:Certificates` -The current Auth configuration in [Events.WebAPI/appsettings.json](Topic2/Events.WebAPI/appsettings.json) is: +`Events.FilesAPI` additionally uses: -- `Auth:Authority=https://fer-web2.eu.auth0.com/` -- `Auth:Audience=https://erasmus-sta-2026/events-api` +- `Paths:OutputPath` -Set the PostgreSQL connection string: +Authentication settings are now applied through the shared `Events.Auth` project, but each API still reads its own values from configuration and passes them into the shared setup. + +### Connection String Note + +The current `Program.cs` files for both APIs read the connection string from: + +- `ConnectionStrings:EventsPostgres` + +Examples: ```powershell -dotnet user-secrets set "ConnectionStrings:EventDB" "Host=localhost;Port=5432;Database=events;Username=sport;Password=your-password;Persist Security Info=True;" --project Topic2\Events.WebAPI\Events.WebAPI.csproj +dotnet user-secrets set "ConnectionStrings:EventsPostgres" "Host=localhost;Port=5432;Database=events;Username=sport;Password=your-password;Persist Security Info=True;" --project Events.WebAPI\Events.WebAPI.csproj +dotnet user-secrets set "ConnectionStrings:EventsPostgres" "Host=localhost;Port=5432;Database=events;Username=sport;Password=your-password;Persist Security Info=True;" --project Events.FilesAPI\Events.FilesAPI.csproj ``` -You can also override RabbitMQ and Auth settings with user secrets if you do not want to keep local values in `appsettings.json`. +If needed, you can also override the Auth values with user secrets: -For the SPA client, copy `Topic2/Events.ClientApp/.env.example` to `.env.local`. The example file already contains the current Auth0 values used by this repository: +```powershell +dotnet user-secrets set "Auth:Authority" "https://fer-web2.eu.auth0.com/" --project Events.WebAPI\Events.WebAPI.csproj +dotnet user-secrets set "Auth:Audience" "https://erasmus-sta-2026/events-api" --project Events.WebAPI\Events.WebAPI.csproj + +dotnet user-secrets set "Auth:Authority" "https://fer-web2.eu.auth0.com/" --project Events.FilesAPI\Events.FilesAPI.csproj +dotnet user-secrets set "Auth:Audience" "https://erasmus-sta-2026/events-api" --project Events.FilesAPI\Events.FilesAPI.csproj +``` + +### ClientApp + +For the SPA client, copy `Events.ClientApp/.env.example` to `.env.local`: + +```powershell +Copy-Item Events.ClientApp\.env.example Events.ClientApp\.env.local +``` + +The current example includes: - `VITE_AUTH0_DOMAIN=fer-web2.eu.auth0.com` - `VITE_AUTH0_CLIENT_ID=whed5Hdb8l1b1fGyyAz7Qrdsb2oKcSh3` - `VITE_AUTH0_AUDIENCE=https://erasmus-sta-2026/events-api` - -`Paths:Certificates` points to the directory where generated certificates and Excel files are stored. By default it is: - -```text -./Certificates -``` +- `VITE_AUTH0_SCOPE=openid profile email events:read events:write` +- `VITE_API_BASE_URL=https://localhost:7295` +- `VITE_FILES_API_BASE_URL=https://localhost:7296` ## Running Required Infrastructure Start PostgreSQL using the repository Docker definitions: ```powershell -docker compose -f docker-definitions\postgres-eventsdb\docker-compose.yml up -d +docker compose -f ..\docker-definitions\postgres-eventsdb\docker-compose.yml up -d ``` -Start RabbitMQ if you want the API to use its real MassTransit transport: +Start RabbitMQ: ```powershell -docker run -d --name rabbitmq-erasmus-sta -p 5672:5672 -p 15672:15672 rabbitmq:4-management +docker run -d --name rabbitmq-for-events -p 5672:5672 -p 15672:15672 rabbitmq:4-management ``` -The RabbitMQ management UI is usually available at: +The RabbitMQ management UI is typically available at: ```text http://localhost:15672 ``` -## Running The Web API +## Running The APIs + +Restore and build the full solution: ```powershell -dotnet restore Topic2\Topic2.sln -dotnet build Topic2\Topic2.sln -dotnet run --project Topic2\Events.WebAPI\Events.WebAPI.csproj +dotnet restore Events-WebApi.slnx +dotnet build Events-WebApi.slnx ``` -Once the API is running: +Run `Events.WebAPI`: -- open Swagger at `/docs` -- test anonymous lookup endpoints -- test secured endpoints with a valid bearer token if your Auth0 configuration is set +```powershell +dotnet run --project Events.WebAPI\Events.WebAPI.csproj +``` + +Run `Events.FilesAPI`: + +```powershell +dotnet run --project Events.FilesAPI\Events.FilesAPI.csproj +``` + +Once the APIs are running: + +- open Swagger for `Events.WebAPI` at `/docs` +- most `WebAPI` endpoints require a bearer token +- `FilesAPI` download endpoints are also protected and require the `events:read` scope ## Running The Client App -See [Events.ClientApp/README.md](Topic2/Events.ClientApp/README.md) for full details. +See [Events.ClientApp/README.md](/C:/GitRepos/FPMOZ-PI/predavanja/Events-WebApi/Events.ClientApp/README.md:1) for more details. Typical local flow: ```powershell -cd Topic2\Events.ClientApp +cd Events.ClientApp npm install npm run dev ``` -The client expects: +## Generated Files -- `VITE_API_BASE_URL` pointing to the running API -- Auth0 SPA settings if login is enabled +`Events.FilesAPI` stores generated files in the directory configured by: + +- `Paths:OutputPath` + +According to the current `appsettings.json`, the default is: + +```text +./Certificates +``` ## Troubleshooting -- If the API fails at startup, verify `ConnectionStrings:EventDB`, RabbitMQ connectivity, and `Paths:Certificates` -- If Swagger opens but secured requests fail, verify `Auth:Authority`, `Auth:Audience`, and the token scopes -- If the client loads but cannot authenticate, verify the values in `.env.local` -- If generated certificates or Excel exports are missing, verify that the output directory exists and is writable +- If an API cannot connect to the database, first verify `ConnectionStrings:EventsPostgres` +- If Swagger opens but secured requests return 401 or 403, verify `Auth:Authority`, `Auth:Audience`, and scope claims +- If `ClientApp` cannot download files, verify `VITE_FILES_API_BASE_URL` +- If PDF or XLSX files are not generated, verify `Paths:OutputPath` and filesystem permissions +- If `dotnet build` reports locked DLLs, one of the API processes is probably still running in the background diff --git a/Events-WebApi/docs/structurizr-registration-flow.dsl b/Events-WebApi/docs/structurizr-registration-flow.dsl new file mode 100644 index 0000000..fffa474 --- /dev/null +++ b/Events-WebApi/docs/structurizr-registration-flow.dsl @@ -0,0 +1,146 @@ +workspace "Events-WebApi" "Structurizr model za rješenje Events-WebApi" { + + model { + auth0 = softwareSystem "Auth0" "Vanjski identity provider za prijavu i JWT tokene." { + tags "External", "Auth" + } + + postgres = softwareSystem "PostgreSQL" "Relacijska baza podataka za domenu događaja." { + tags "External", "Database" + } + + rabbitmq = softwareSystem "RabbitMQ" "Sabirnica poruka za asinkronu razmjenu događaja." { + tags "External", "MessageBus" + } + + eventsSystem = softwareSystem "Events-WebApi" "Rješenje za upravljanje događajima, prijavama i generiranim datotekama." { + + clientApp = container "ClientApp" "" "Vue 3 + Vite" { + tags "Frontend" + } + + webApi = container "WebAPI" "CRUD servisi" "ASP.NET Core Web API" { + tags "API", "WebAPI" + } + + filesApi = container "FilesAPI" "API za sinkronizaciju i isporuku PDF/XLSX datoteka." "ASP.NET Core Web API" { + tags "API", "FilesAPI" + } + + clientApp -> auth0 "Prijava i dohvat pristupnog tokena" + clientApp -> webApi "CRUD operacije i registracija za događaj" "JSON/HTTPS" + clientApp -> filesApi "Preuzimanje certifikata i Excel izvoza" "HTTPS" + + webApi -> auth0 "Validira JWT tokene" "OAuth2/JWT" + webApi -> postgres "Čita i zapisuje događaje, osobe, sportove i prijave" "EF Core/Npgsql" + webApi -> rabbitmq "Objavljuje RegistrationCreated/Updated/Deleted" "MassTransit/AMQP" + + rabbitmq -> filesApi "Isporučuje registration događaje consumerima" "MassTransit/AMQP" + filesApi -> postgres "Čita podatke za generiranje datoteka" "EF Core/Npgsql" + } + } + + views { + container eventsSystem "Containers" { + include clientApp + include webApi + include filesApi + include auth0 + include postgres + include rabbitmq + + animation { + clientApp + webApi postgres auth0 + rabbitmq + filesApi + } + } + + dynamic eventsSystem "RegistrationFlow" "Tok registracije za događaj" { + clientApp -> webApi "Korisnik u ClientApp šalje prijavu za događaj" + webApi -> auth0 "Validacija pristupnog tokena" + webApi -> postgres "Spremanje registracije u bazu" + webApi -> rabbitmq "Objava događaja RegistrationCreated" + rabbitmq -> filesApi "Isporuka događaja RegistrationCreated" + filesApi -> postgres "Dohvat podataka za certifikat i Excel" + } + + theme default + + styles { + element "Container" { + background #ffffff + color #222222 + stroke #444444 + fontSize 34 + } + + element "Frontend" { + shape WebBrowser + background #ffffff + color #1565c0 + stroke #42a5f5 + strokeWidth 3 + fontSize 36 + } + + element "API" { + shape Hexagon + background #ffffff + strokeWidth 3 + fontSize 36 + } + + element "WebAPI" { + color #1565c0 + stroke #42a5f5 + } + + element "FilesAPI" { + color #2e7d32 + stroke #66bb6a + } + + element "Database" { + shape Cylinder + background #ffffff + color #1565c0 + stroke #42a5f5 + strokeWidth 3 + fontSize 34 + description false + } + + element "MessageBus" { + shape Pipe + background #fff8e1 + color #8d6e63 + stroke #ff9800 + strokeWidth 3 + width 760 + height 140 + fontSize 34 + description false + } + + element "External" { + opacity 85 + } + + element "Auth" { + background #ffffff + color #c62828 + stroke #e53935 + strokeWidth 3 + fontSize 34 + } + + relationship "Relationship" { + color #666666 + thickness 2 + fontSize 34 + } + } + } +} diff --git a/Events-WebApi/docs/structurizr-webapi-crud-flow.dsl b/Events-WebApi/docs/structurizr-webapi-crud-flow.dsl new file mode 100644 index 0000000..e4a70d2 --- /dev/null +++ b/Events-WebApi/docs/structurizr-webapi-crud-flow.dsl @@ -0,0 +1,184 @@ +workspace "Events-WebApi CRUD Flow" "Structurizr model za tipični CRUD tok u WebAPI-ju" { + + model { + postgres = softwareSystem "PostgreSQL" "" { + tags "Database" + } + + rabbitmq = softwareSystem "RabbitMQ" "" { + tags "MessageBus" + } + + eventsSystem = softwareSystem "Events-WebApi" "Rješenje za upravljanje događajima i prijavama." { + clientApp = container "ClientApp" "" "Vue 3 + Vite" { + tags "Frontend" + } + + webApi = container "WebAPI" "CRUD servisi" "ASP.NET Core Web API" { + tags "API" + + controller = component "Controller" "Ulazna točka za CRUD nad prijavama." "ASP.NET Core Controller" { + tags "Controller" + } + + validation = component "Validation" "Validacija DTO-a i poslovnih pravila prije obrade." "MediatR Pipeline + FluentValidation" { + tags "Validation" + } + + mediator = component "MediatR" "Prosljeđuje command odgovarajućem handleru." "Mediator" { + tags "Mediator" + } + + handler = component "Handler" "Obrada Add/Update/Delete zahtjeva i objava događaja." "Command Handler" { + tags "Handler" + } + + dbContext = component "DbContext" "EF Core pristup tablicama registration, person, event i sport." "DbContext" { + tags "DbContext" + } + + publishEndpoint = component "Publisher" "Objava događaja RegistrationCreated/Updated/Deleted." "MassTransit publisher" { + tags "Publisher" + } + + controller -> validation "Pokreće validaciju kroz pipeline" + validation -> mediator "Prosljeđuje valjani zahtjev" + mediator -> handler "Poziva command handler" + handler -> dbContext "Čita i zapisuje podatke" + handler -> publishEndpoint "Objavljuje događaj nakon promjene registracije" + } + + clientApp -> controller "Šalje CRUD zahtjev" "JSON/HTTPS" + dbContext -> postgres "Persistira promjene" "EF Core/Npgsql" + publishEndpoint -> rabbitmq "Šalje poruku" "AMQP" + } + } + + views { + component webApi "WebApiComponents" { + include clientApp + include controller + include validation + include mediator + include handler + include dbContext + include publishEndpoint + include postgres + include rabbitmq + } + + dynamic webApi "CreateRegistrationFlow" "Tipični CRUD tok: kreiranje prijave za događaj" { + clientApp -> controller "Korisnik šalje prijavu za događaj" + controller -> validation "Provjera DTO-a i pravila" + validation -> mediator "Valjan zahtjev ide dalje" + mediator -> handler "Poziv AddCommand handlera" + handler -> dbContext "Spremanje registracije" + dbContext -> postgres "INSERT registration" + handler -> publishEndpoint "Objava RegistrationCreated" + publishEndpoint -> rabbitmq "Slanje događaja" + autolayout lr + } + + theme default + + styles { + element "Element" { + background #ffffff + color #222222 + stroke #444444 + fontSize 40 + } + + element "Frontend" { + shape WebBrowser + color #1565c0 + stroke #42a5f5 + strokeWidth 3 + fontSize 42 + } + + element "API" { + shape Hexagon + color #1565c0 + stroke #42a5f5 + strokeWidth 3 + fontSize 42 + } + + element "Controller" { + shape RoundedBox + color #1e88e5 + stroke #64b5f6 + strokeWidth 3 + fontSize 40 + } + + element "Validation" { + shape RoundedBox + color #6a1b9a + stroke #ab47bc + strokeWidth 3 + fontSize 40 + } + + element "Mediator" { + shape RoundedBox + color #00897b + stroke #4db6ac + strokeWidth 3 + fontSize 40 + } + + element "Handler" { + shape RoundedBox + color #2e7d32 + stroke #66bb6a + strokeWidth 3 + fontSize 40 + } + + element "DbContext" { + shape RoundedBox + color #ef6c00 + stroke #ffb74d + strokeWidth 3 + fontSize 40 + } + + element "Publisher" { + shape RoundedBox + color #5d4037 + stroke #a1887f + strokeWidth 3 + fontSize 40 + } + + element "Database" { + shape Cylinder + color #1565c0 + stroke #42a5f5 + strokeWidth 3 + fontSize 40 + description false + } + + element "MessageBus" { + shape Pipe + background #fff8e1 + color #8d6e63 + stroke #ff9800 + strokeWidth 3 + width 760 + height 140 + fontSize 40 + description false + } + + relationship "Relationship" { + color #666666 + thickness 2 + fontSize 36 + } + } + } +}