From 14f48feb8923049a3525da7879f1c94ec7ac30a9 Mon Sep 17 00:00:00 2001 From: Matt Hoffmeister Date: Thu, 9 Feb 2023 15:10:47 -0600 Subject: [PATCH] Add Update Song Director API --- Asaph.Bootstrapper/Asaph.Bootstrapper.csproj | 10 ++-- .../Asaph.Core.UnitTests.csproj | 14 ++--- Asaph.Core/Asaph.Core.csproj | 8 +-- .../AggregateSongDirectorRepositoryTests.cs | 18 +++--- ...aph.Infrastructure.IntegrationTests.csproj | 10 ++-- .../AzureAdb2cSongDirectorRepositoryTests.cs | 12 ++-- .../DynamoDBSongDirectorRepositoryTests.cs | 10 ++-- .../Asaph.Infrastructure.csproj | 12 ++-- .../DynamoDBSongDirectorRepository.cs | 2 +- Asaph.WebApi/ApiDocumentationBuilder.cs | 28 +++++++-- Asaph.WebApi/Asaph.WebApi.csproj | 12 ++-- Asaph.WebApi/ForbiddenObjectResult.cs | 52 ++++++++++++++++ Asaph.WebApi/Program.cs | 11 +++- Asaph.WebApi/ResultsExtensions.cs | 24 ++++++++ .../AddSongDirectorApiBoundary.cs | 1 - Asaph.WebApi/UseCases/ApiBoundary.cs | 15 ++++- .../UpdateSongDirectorApi.cs | 59 +++++++++++++++++++ .../UpdateSongDirectorApiBoundary.cs | 58 ++++++++++++++++++ 18 files changed, 293 insertions(+), 63 deletions(-) create mode 100644 Asaph.WebApi/ForbiddenObjectResult.cs create mode 100644 Asaph.WebApi/UseCases/UpdateSongDirector/UpdateSongDirectorApi.cs create mode 100644 Asaph.WebApi/UseCases/UpdateSongDirector/UpdateSongDirectorApiBoundary.cs diff --git a/Asaph.Bootstrapper/Asaph.Bootstrapper.csproj b/Asaph.Bootstrapper/Asaph.Bootstrapper.csproj index 1671451..c24581e 100644 --- a/Asaph.Bootstrapper/Asaph.Bootstrapper.csproj +++ b/Asaph.Bootstrapper/Asaph.Bootstrapper.csproj @@ -1,14 +1,14 @@  - net6.0 + net7.0 - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Asaph.Core.UnitTests/Asaph.Core.UnitTests.csproj b/Asaph.Core.UnitTests/Asaph.Core.UnitTests.csproj index a93583e..3fa9f9e 100644 --- a/Asaph.Core.UnitTests/Asaph.Core.UnitTests.csproj +++ b/Asaph.Core.UnitTests/Asaph.Core.UnitTests.csproj @@ -1,7 +1,7 @@  - net6.0 + net7.0 false @@ -11,11 +11,11 @@ - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -28,7 +28,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Asaph.Core/Asaph.Core.csproj b/Asaph.Core/Asaph.Core.csproj index bd67e2e..4dbb6e8 100644 --- a/Asaph.Core/Asaph.Core.csproj +++ b/Asaph.Core/Asaph.Core.csproj @@ -1,15 +1,15 @@  - net6.0 + net7.0 enable True - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Asaph.Infrastructure.IntegrationTests/AggregateSongDirectorRepositoryTests.cs b/Asaph.Infrastructure.IntegrationTests/AggregateSongDirectorRepositoryTests.cs index d20d4a3..e71e716 100644 --- a/Asaph.Infrastructure.IntegrationTests/AggregateSongDirectorRepositoryTests.cs +++ b/Asaph.Infrastructure.IntegrationTests/AggregateSongDirectorRepositoryTests.cs @@ -284,21 +284,21 @@ private static AggregateSongDirectorRepository GetAggregateSongDirectorRepositor IConfiguration configuration = builder.Build(); AzureAdb2cConfiguration azureAdb2CConfiguration = new( - configuration[$"{userSecretsSection}:AzureAdb2c:ClientId"], - configuration[$"{userSecretsSection}:AzureAdb2c:ClientSecret"], - configuration[$"{userSecretsSection}:AzureAdb2c:Domain"], - configuration[$"{userSecretsSection}:AzureAdb2c:ExtensionsAppClientId"], - configuration[$"{userSecretsSection}:AzureAdb2c:TenantId"]); + configuration[$"{userSecretsSection}:AzureAdb2c:ClientId"]!, + configuration[$"{userSecretsSection}:AzureAdb2c:ClientSecret"]!, + configuration[$"{userSecretsSection}:AzureAdb2c:Domain"]!, + configuration[$"{userSecretsSection}:AzureAdb2c:ExtensionsAppClientId"]!, + configuration[$"{userSecretsSection}:AzureAdb2c:TenantId"]!); AzureAdb2cSongDirectorRepository azureAdb2CSongDirectorRepository = new( azureAdb2CConfiguration); DynamoDBConfiguration dynamoDBConfiguration = new( - configuration[$"{userSecretsSection}:DynamoDB:AwsAccessKeyId"], - configuration[$"{userSecretsSection}:DynamoDB:AwsSecretAccessKey"], + configuration[$"{userSecretsSection}:DynamoDB:AwsAccessKeyId"]!, + configuration[$"{userSecretsSection}:DynamoDB:AwsSecretAccessKey"]!, awsRegionSystemName, - configuration[$"{userSecretsSection}:DynamoDB:TableNamePrefix"], - configuration[$"{userSecretsSection}:DynamoDB:DynamoDBLocalUrl"], + configuration[$"{userSecretsSection}:DynamoDB:TableNamePrefix"]!, + configuration[$"{userSecretsSection}:DynamoDB:DynamoDBLocalUrl"]!, useDynamoDBLocal); DynamoDBSongDirectorRepository amazonDynamoDBSongDirectorRepository = new( diff --git a/Asaph.Infrastructure.IntegrationTests/Asaph.Infrastructure.IntegrationTests.csproj b/Asaph.Infrastructure.IntegrationTests/Asaph.Infrastructure.IntegrationTests.csproj index 04553a0..d0d9242 100644 --- a/Asaph.Infrastructure.IntegrationTests/Asaph.Infrastructure.IntegrationTests.csproj +++ b/Asaph.Infrastructure.IntegrationTests/Asaph.Infrastructure.IntegrationTests.csproj @@ -1,7 +1,7 @@ - net6.0 + net7.0 false @@ -11,9 +11,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -26,7 +26,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Asaph.Infrastructure.IntegrationTests/AzureAdb2cSongDirectorRepositoryTests.cs b/Asaph.Infrastructure.IntegrationTests/AzureAdb2cSongDirectorRepositoryTests.cs index d6e3e87..69ffd7a 100644 --- a/Asaph.Infrastructure.IntegrationTests/AzureAdb2cSongDirectorRepositoryTests.cs +++ b/Asaph.Infrastructure.IntegrationTests/AzureAdb2cSongDirectorRepositoryTests.cs @@ -244,11 +244,11 @@ private static AzureAdb2cConfiguration GetConfiguration() IConfiguration configuration = builder.Build(); return new( - configuration["AzureAdb2c:ClientId"], - configuration["AzureAdb2c:ClientSecret"], - configuration["AzureAdb2c:Domain"], - configuration["AzureAdb2c:ExtensionsAppClientId"], - configuration["AzureAdb2c:TenantId"]); + configuration["AzureAdb2c:ClientId"]!, + configuration["AzureAdb2c:ClientSecret"]!, + configuration["AzureAdb2c:Domain"]!, + configuration["AzureAdb2c:ExtensionsAppClientId"]!, + configuration["AzureAdb2c:TenantId"]!); } } -} +} \ No newline at end of file diff --git a/Asaph.Infrastructure.IntegrationTests/DynamoDBSongDirectorRepositoryTests.cs b/Asaph.Infrastructure.IntegrationTests/DynamoDBSongDirectorRepositoryTests.cs index 42c62c4..62a9e76 100644 --- a/Asaph.Infrastructure.IntegrationTests/DynamoDBSongDirectorRepositoryTests.cs +++ b/Asaph.Infrastructure.IntegrationTests/DynamoDBSongDirectorRepositoryTests.cs @@ -252,12 +252,12 @@ private static DynamoDBConfiguration GetConfiguration( .AddUserSecrets() .Build(); - string awsAccessKeyId = configuration["DynamoDB:AwsAccessKeyId"]; - string awsSecretAccessKey = configuration["DynamoDB:AwsSecretAccessKey"]; - string dynamoDBLocalUrl = configuration["DynamoDB:DynamoDBLocalUrl"]; + string awsAccessKeyId = configuration["DynamoDB:AwsAccessKeyId"]!; + string awsSecretAccessKey = configuration["DynamoDB:AwsSecretAccessKey"]!; + string dynamoDBLocalUrl = configuration["DynamoDB:DynamoDBLocalUrl"]!; string tableNamePrefix = "Dev_"; - return new( + return new DynamoDBConfiguration( awsAccessKeyId, awsSecretAccessKey, awsRegionSystemName, @@ -266,4 +266,4 @@ private static DynamoDBConfiguration GetConfiguration( useDynamoDBLocal); } } -} +} \ No newline at end of file diff --git a/Asaph.Infrastructure/Asaph.Infrastructure.csproj b/Asaph.Infrastructure/Asaph.Infrastructure.csproj index 7707d53..f2d51dc 100644 --- a/Asaph.Infrastructure/Asaph.Infrastructure.csproj +++ b/Asaph.Infrastructure/Asaph.Infrastructure.csproj @@ -1,17 +1,17 @@  - net6.0 + net7.0 enable - + - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Asaph.Infrastructure/SongDirectorRepository/DynamoDBSongDirectorRepository.cs b/Asaph.Infrastructure/SongDirectorRepository/DynamoDBSongDirectorRepository.cs index 3597e6e..140d1b8 100644 --- a/Asaph.Infrastructure/SongDirectorRepository/DynamoDBSongDirectorRepository.cs +++ b/Asaph.Infrastructure/SongDirectorRepository/DynamoDBSongDirectorRepository.cs @@ -11,7 +11,7 @@ namespace Asaph.Infrastructure.SongDirectorRepository; -public record struct DynamoDBConfiguration( +public record DynamoDBConfiguration( string AwsAccessKeyId, string AwsSecretAccessKey, string AwsRegionSystemName, diff --git a/Asaph.WebApi/ApiDocumentationBuilder.cs b/Asaph.WebApi/ApiDocumentationBuilder.cs index 7c4c851..9adaf83 100644 --- a/Asaph.WebApi/ApiDocumentationBuilder.cs +++ b/Asaph.WebApi/ApiDocumentationBuilder.cs @@ -1,4 +1,6 @@ -using Microsoft.OpenApi.Models; +using Microsoft.Identity.Web.Resource; +using Microsoft.OpenApi.Models; +using System.Configuration; using System.Security.Claims; /// @@ -58,7 +60,9 @@ public static OpenApiDocument GetAsaphOpenApiDocument( Scopes = swaggerUIConfiguration .GetSection("Scopes") .GetChildren() - .ToDictionary(kv => kv.Value, kv => kv.Key), + .Where(section => section.Value != null) + .ToDictionary( + section => section.Value!, section => section.Key), TokenUrl = GetTokenUrl(azureAdb2cConfiguration), }, @@ -76,8 +80,16 @@ public static OpenApiDocument GetAsaphOpenApiDocument( /// Authorization URL. private static Uri GetAuthorizationUrl(IConfiguration azureAdb2cConfiguration) { + string? authorizationUrlTemplate = azureAdb2cConfiguration["AuthorizationUrlTemplate"]; + + if (authorizationUrlTemplate == null) + { + throw new ConfigurationErrorsException( + "Missing Azure AD B2C authorization URL template configuration."); + } + return new(string.Format( - azureAdb2cConfiguration["AuthorizationUrlTemplate"], + authorizationUrlTemplate, azureAdb2cConfiguration["Instance"], azureAdb2cConfiguration["Domain"], azureAdb2cConfiguration["SignUpSignInPolicyId"])); @@ -123,8 +135,16 @@ private static List GetOpenApiSecurityRequirements( /// Token URL. private static Uri GetTokenUrl(IConfiguration azureAdb2cConfiguration) { + string? tokenUrlTemplate = azureAdb2cConfiguration["TokenUrlTemplate"]; + + if (tokenUrlTemplate == null) + { + throw new ConfigurationErrorsException( + "Missing Azure AD B2C token URL template configuration."); + } + return new(string.Format( - azureAdb2cConfiguration["TokenUrlTemplate"], + tokenUrlTemplate, azureAdb2cConfiguration["Instance"], azureAdb2cConfiguration["Domain"], azureAdb2cConfiguration["SignUpSignInPolicyId"])); diff --git a/Asaph.WebApi/Asaph.WebApi.csproj b/Asaph.WebApi/Asaph.WebApi.csproj index c68d475..57522e8 100644 --- a/Asaph.WebApi/Asaph.WebApi.csproj +++ b/Asaph.WebApi/Asaph.WebApi.csproj @@ -1,7 +1,7 @@  - net6.0 + net7.0 enable enable fc8fb0f9-8023-4345-8aae-7355b736e2ff @@ -10,12 +10,12 @@ - + - - + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -23,7 +23,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Asaph.WebApi/ForbiddenObjectResult.cs b/Asaph.WebApi/ForbiddenObjectResult.cs new file mode 100644 index 0000000..6b2f64e --- /dev/null +++ b/Asaph.WebApi/ForbiddenObjectResult.cs @@ -0,0 +1,52 @@ +/// +/// Forbidden object result. +/// +public class ForbiddenObjectResult : IResult +{ + /// + /// Initializes a new instance of the class. + /// + /// Value. + /// Content type. + public ForbiddenObjectResult(object value, string contentType) => + (ContentType, Value) = (contentType, value); + + /// + /// Gets the value for the Content-Type header. + /// + public string ContentType { get; } + + /// + /// The object result. + /// + public object Value { get; } + + /// + public Task ExecuteAsync(HttpContext httpContext) + { + int forbiddenStatusCode = StatusCodes.Status403Forbidden; + + Type valueType = Value.GetType(); + + LogForbiddenObjectResultExecuting(httpContext, valueType); + + httpContext.Response.StatusCode = forbiddenStatusCode; + + return httpContext.Response.WriteAsJsonAsync( + Value, valueType, options: null, contentType: ContentType); + } + + private void LogForbiddenObjectResultExecuting(HttpContext httpContext, Type valueType) + { + ILoggerFactory? loggerFactory = httpContext + .RequestServices + .GetRequiredService(); + + ILogger? logger = loggerFactory.CreateLogger(GetType()); + + logger?.LogInformation( + "Executing {ResultName}. Writing value of type {ValueTypeName}.", + nameof(ForbiddenObjectResult), + valueType.Name); + } +} \ No newline at end of file diff --git a/Asaph.WebApi/Program.cs b/Asaph.WebApi/Program.cs index b04899a..4c29f5e 100644 --- a/Asaph.WebApi/Program.cs +++ b/Asaph.WebApi/Program.cs @@ -6,6 +6,7 @@ using Microsoft.OpenApi; using Microsoft.OpenApi.Extensions; using Microsoft.OpenApi.Models; +using System.Configuration; WebApplicationBuilder? builder = WebApplication.CreateBuilder(args); @@ -13,7 +14,10 @@ builder.Configuration.AddEnvironmentVariables(); -string baseUri = builder.Configuration["BaseUri"]; +string? baseUri = builder.Configuration["BaseUri"]; + +if (baseUri == null) + throw new ConfigurationErrorsException("Missing base URI configuration."); builder.Logging .ClearProviders() @@ -34,7 +38,10 @@ WebApplication? app = builder.Build(); -string[] allowedOrigins = builder.Configuration["Cors:AllowedOrigins"].Split(','); +string[]? allowedOrigins = builder.Configuration["Cors:AllowedOrigins"]?.Split(','); + +if (allowedOrigins == null) + throw new ConfigurationErrorsException("Missing CORS allowed origins configuration."); app .UseCors(c => c diff --git a/Asaph.WebApi/ResultsExtensions.cs b/Asaph.WebApi/ResultsExtensions.cs index 8a00620..8c89b1a 100644 --- a/Asaph.WebApi/ResultsExtensions.cs +++ b/Asaph.WebApi/ResultsExtensions.cs @@ -1,4 +1,5 @@ using Hydra.NET; +using Microsoft.AspNetCore.Mvc; using System.Net; /// @@ -44,4 +45,27 @@ public static IResult BadGatewayStatusJsonLD( return resultExtensions.BadGatewayStatusJsonLD( hydraContext, string.Join(Environment.NewLine, messages)); } + + /// + /// Returns a Forbidden response with a Hydra Status object serialized as JSON LD. + /// + /// . + /// Hydra context. + /// Error message. + /// . + public static IResult ForbiddenStatusJsonLD( + this IResultExtensions resultExtensions, + Context hydraContext, + string message) + { + ArgumentNullException.ThrowIfNull(resultExtensions); + + return new ForbiddenObjectResult( + new Status( + hydraContext, + (int)HttpStatusCode.Forbidden, + "Forbidden", + message), + "application/ld+json"); + } } \ No newline at end of file diff --git a/Asaph.WebApi/UseCases/AddSongDirector/AddSongDirectorApiBoundary.cs b/Asaph.WebApi/UseCases/AddSongDirector/AddSongDirectorApiBoundary.cs index 7d78023..a9a8cf8 100644 --- a/Asaph.WebApi/UseCases/AddSongDirector/AddSongDirectorApiBoundary.cs +++ b/Asaph.WebApi/UseCases/AddSongDirector/AddSongDirectorApiBoundary.cs @@ -12,7 +12,6 @@ internal class AddSongDirectorApiBoundary : ApiBoundary, IAddSongDirectorBoundar /// Initializes a new instance of the class. /// /// Configuration. - /// Relative song directors URL. public AddSongDirectorApiBoundary(IConfiguration configuration) : base(configuration, RelativeResourceUrls.SongDirectors) { diff --git a/Asaph.WebApi/UseCases/ApiBoundary.cs b/Asaph.WebApi/UseCases/ApiBoundary.cs index defa03f..b0133d5 100644 --- a/Asaph.WebApi/UseCases/ApiBoundary.cs +++ b/Asaph.WebApi/UseCases/ApiBoundary.cs @@ -1,4 +1,5 @@ using Hydra.NET; +using System.Configuration; internal record ApiBoundaryConfiguration( string HydraContextUriString, string ResourceBaseUriString); @@ -25,8 +26,18 @@ protected ApiBoundary(ApiBoundaryConfiguration configuration) /// Relative resource URL. protected ApiBoundary(IConfiguration configuration, string relativeResourceUrl) { - HydraContext = new Context(new Uri(configuration["HydraContextUri"])); - ResourceBaseUri = new Uri($"{configuration["BaseUri"].TrimEnd('/')}{relativeResourceUrl}/"); + string? hydraContextUri = configuration["HydraContextUri"]; + + if (hydraContextUri == null) + throw new ConfigurationErrorsException("Missing Hydra context URI configuration."); + + string? baseUri = configuration["BaseUri"]; + + if (baseUri == null) + throw new ConfigurationErrorsException("Missing base URI configuration."); + + HydraContext = new Context(new Uri(hydraContextUri)); + ResourceBaseUri = new Uri($"{baseUri.TrimEnd('/')}{relativeResourceUrl}/"); } /// diff --git a/Asaph.WebApi/UseCases/UpdateSongDirector/UpdateSongDirectorApi.cs b/Asaph.WebApi/UseCases/UpdateSongDirector/UpdateSongDirectorApi.cs new file mode 100644 index 0000000..9ad8630 --- /dev/null +++ b/Asaph.WebApi/UseCases/UpdateSongDirector/UpdateSongDirectorApi.cs @@ -0,0 +1,59 @@ +using Asaph.Core.UseCases; +using Asaph.Core.UseCases.UpdateSongDirector; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Identity.Web; + +namespace Asaph.WebApi.UseCases.UpdateSongDirector; + +/// +/// Update song director API. +/// +public class UpdateSongDirectorApi : IUseCaseApi +{ + /// + public IServiceCollection AddServices(IServiceCollection services, IConfiguration configuration) + { + return services + .AddTransient< + IUpdateSongDirectorBoundary, + UpdateSongDirectorApiBoundary>(factory => new(configuration)) + .AddTransient< + IAsyncUseCaseInteractor, + UpdateSongDirectorInteractor>(); + } + + /// + public IEndpointRouteBuilder MapEndpoint(IEndpointRouteBuilder builder) + { + builder.MapPut( + "/song-directors/{id}", + [Authorize] + async ( + string id, + SongDirectorApiModel updatedSongDirector, + HttpContext http, + IAsyncUseCaseInteractor + updateSongDirectorInteractor) => + { + string? requesterId = http.User.GetNameIdentifierId(); + + if (requesterId == null) + return Results.Unauthorized(); + + UpdateSongDirectorRequest updateSongDirectorRequest = new( + requesterId, + id, + updatedSongDirector.Name, + updatedSongDirector.EmailAddress, + updatedSongDirector.PhoneNumber, + updatedSongDirector.Rank, + updatedSongDirector.IsActive); + + return await updateSongDirectorInteractor + .HandleAsync(updateSongDirectorRequest) + .ConfigureAwait(false); + }); + + return builder; + } +} \ No newline at end of file diff --git a/Asaph.WebApi/UseCases/UpdateSongDirector/UpdateSongDirectorApiBoundary.cs b/Asaph.WebApi/UseCases/UpdateSongDirector/UpdateSongDirectorApiBoundary.cs new file mode 100644 index 0000000..d329f9c --- /dev/null +++ b/Asaph.WebApi/UseCases/UpdateSongDirector/UpdateSongDirectorApiBoundary.cs @@ -0,0 +1,58 @@ +using Asaph.Core.UseCases.UpdateSongDirector; +using Hydra.NET; +using System.Net; + +namespace Asaph.WebApi.UseCases.UpdateSongDirector; + +/// +/// API boundary for the Update Song Director use case. +/// +internal class UpdateSongDirectorApiBoundary : ApiBoundary, IUpdateSongDirectorBoundary +{ + /// + /// Initializes a new instance of the class. + /// + /// Configuration. + public UpdateSongDirectorApiBoundary(IConfiguration configuration) + : base(configuration, RelativeResourceUrls.SongDirectors) + { + } + + /// + public IResult InsufficientPermissions(UpdateSongDirectorResponse response) + { + return Results.Unauthorized(); + } + + /// + public IResult InvalidRequest(UpdateSongDirectorResponse response) + { + return Results.BadRequest(new Status( + HydraContext, + (int)HttpStatusCode.BadRequest, + "Bad Request", + response.Message)); + } + + /// + public IResult RequesterRankNotFound(UpdateSongDirectorResponse response) + { + return Results.Extensions.ForbiddenStatusJsonLD(HydraContext, response.Message); + } + + /// + public IResult SongDirectorUpdated(UpdateSongDirectorResponse response) + { + return Results.Ok(new Status( + HydraContext, + (int)HttpStatusCode.OK, + "OK", + response.Message)); + } + + /// + public IResult SongDirectorUpdateFailed(UpdateSongDirectorResponse response) + { + return Results.Extensions.BadGatewayStatusJsonLD(HydraContext, response.Message); + } +} \ No newline at end of file