diff --git a/.vscode/settings.json b/.vscode/settings.json index d7770cd..ed0b12c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,13 +1,8 @@ { "rest-client.environmentVariables": { - - "$shared": {}, - "dev" : { + "$shared": { "host":"https://localhost:7059" }, - "prod" : { - "host":"https://localhost:7059" - } }, "cSpell.words": [ "Xunit" diff --git a/BuberDinner.Api/src/2022-12-21/DinnersController.cs b/BuberDinner.Api/src/2022-12-21/DinnersController.cs index 4a58035..2343c0a 100644 --- a/BuberDinner.Api/src/2022-12-21/DinnersController.cs +++ b/BuberDinner.Api/src/2022-12-21/DinnersController.cs @@ -1,10 +1,20 @@ -namespace BuberDinner.Api.Controllers; +namespace BuberDinner._2022_12_21.Controllers; -using FunctionalDDD.BuberDinner.Api; +using Asp.Versioning; +using BuberDinner.Api; using Microsoft.AspNetCore.Mvc; +/// +/// CRUD for dinner. +/// +[ApiVersion("2022-10-01")] public class DinnersController : ApiControllerBase { + /// + /// Get all the dinners. + /// + /// + [HttpGet] public IActionResult ListDinners() { return Ok(Array.Empty()); diff --git a/BuberDinner.Api/src/ApiControllerBase.cs b/BuberDinner.Api/src/ApiControllerBase.cs index e0477fe..036c4f8 100644 --- a/BuberDinner.Api/src/ApiControllerBase.cs +++ b/BuberDinner.Api/src/ApiControllerBase.cs @@ -4,8 +4,10 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +/// +/// API Base Controller. +/// [Route("[controller]")] -[ApiController] [Authorize] public class ApiControllerBase : FunctionalDDDBase { diff --git a/BuberDinner.Api/src/BuberDinner.Api.csproj b/BuberDinner.Api/src/BuberDinner.Api.csproj index 82d463d..b5a0a13 100644 --- a/BuberDinner.Api/src/BuberDinner.Api.csproj +++ b/BuberDinner.Api/src/BuberDinner.Api.csproj @@ -2,12 +2,15 @@ 56db5788-9ea3-4265-858d-0a347e105439 + true + + @@ -17,6 +20,6 @@ + - diff --git a/BuberDinner.Api/src/ConfigureSwaggerOptions.cs b/BuberDinner.Api/src/ConfigureSwaggerOptions.cs new file mode 100644 index 0000000..9dc35b6 --- /dev/null +++ b/BuberDinner.Api/src/ConfigureSwaggerOptions.cs @@ -0,0 +1,89 @@ +namespace BuberDinner.Api; + +using Asp.Versioning; +using Asp.Versioning.ApiExplorer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Text; + +/// +/// Configures the Swagger generation options. +/// +/// This allows API versioning to define a Swagger document per API version after the +/// service has been resolved from the service container. +public class ConfigureSwaggerOptions : IConfigureOptions +{ + private readonly IApiVersionDescriptionProvider provider; + + /// + /// Initializes a new instance of the class. + /// + /// The provider used to generate Swagger documents. + public ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) => this.provider = provider; + + /// + public void Configure(SwaggerGenOptions options) + { + // add a swagger document for each discovered API version + // note: you might choose to skip or document deprecated API versions differently + foreach (var description in provider.ApiVersionDescriptions) + { + options.SwaggerDoc(description.GroupName, CreateInfoForApiVersion(description)); + } + } + + private static OpenApiInfo CreateInfoForApiVersion(ApiVersionDescription description) + { + var text = new StringBuilder("Buber Dinner the AirBnB for dinner."); + var info = new OpenApiInfo() + { + Title = "Buber Dinner", + Version = description.ApiVersion.ToString(), + Contact = new OpenApiContact() { Name = "Xavier John", Email = "xavier@somewhere.com" }, + License = new OpenApiLicense() { Name = "MIT", Url = new Uri("https://opensource.org/licenses/MIT") } + }; + + if (description.IsDeprecated) + { + text.Append(" This API version has been deprecated."); + } + + if (description.SunsetPolicy is SunsetPolicy policy) + { + if (policy.Date is DateTimeOffset when) + { + text.Append(" The API will be sunset on ") + .Append(when.Date.ToShortDateString()) + .Append('.'); + } + + if (policy.HasLinks) + { + text.AppendLine(); + + for (var i = 0; i < policy.Links.Count; i++) + { + var link = policy.Links[i]; + + if (link.Type == "text/html") + { + text.AppendLine(); + + if (link.Title.HasValue) + { + text.Append(link.Title.Value).Append(": "); + } + + text.Append(link.LinkTarget.OriginalString); + } + } + } + } + + info.Description = text.ToString(); + + return info; + } +} diff --git a/BuberDinner.Api/src/DependencyInjection.cs b/BuberDinner.Api/src/DependencyInjection.cs index ac09327..25c8d06 100644 --- a/BuberDinner.Api/src/DependencyInjection.cs +++ b/BuberDinner.Api/src/DependencyInjection.cs @@ -4,13 +4,37 @@ using Mapster; using MapsterMapper; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Swashbuckle.AspNetCore.SwaggerGen; -public static class DependencyInjection +internal static class DependencyInjection { public static IServiceCollection AddPresentation(this IServiceCollection services) { services.AddControllers(); services.AddMappings(); + services.AddApiVersioning( + options => + { + options.ReportApiVersions = true; + }) + .AddMvc() + .AddApiExplorer(); + + // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle + services.AddTransient, ConfigureSwaggerOptions>(); + services.AddSwaggerGen( + options => + { + // add a custom operation filter which sets default values + options.OperationFilter(); + + var fileName = typeof(Program).Assembly.GetName().Name + ".xml"; + var filePath = Path.Combine(AppContext.BaseDirectory, fileName); + + // integrate XML comments + options.IncludeXmlComments(filePath); + }); return services; } diff --git a/BuberDinner.Api/src/Netural/Controllers/AuthenticationController.cs b/BuberDinner.Api/src/Netural/Controllers/AuthenticationController.cs index 6925a7a..f43c5da 100644 --- a/BuberDinner.Api/src/Netural/Controllers/AuthenticationController.cs +++ b/BuberDinner.Api/src/Netural/Controllers/AuthenticationController.cs @@ -1,34 +1,54 @@ namespace BuberDinner.Api.Netural.Controllers; +using Asp.Versioning; using BuberDinner.Api.Netural.Models.Authentication; using MapsterMapper; using Mediator; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +/// +/// Authentication Controller +/// [AllowAnonymous] +[ApiVersionNeutral] public class AuthenticationController : ApiControllerBase { private readonly ISender _sender; private readonly IMapper _mapper; + /// + /// Constructor. + /// + /// + /// public AuthenticationController(ISender sender, IMapper mapper) { _sender = sender; _mapper = mapper; } + /// + /// Register a new user. + /// + /// + /// [HttpPost("register")] public async Task> Register(RegisterRequest request) => await request.ToRegisterCommand() .BindAsync(command => _sender.Send(command)) - .MapAsync(authResult => _mapper.Map(authResult)) + .MapAsync(_mapper.Map) .FinallyAsync(result => MapToActionResult(result)); + /// + /// Login for existing user. + /// + /// + /// [HttpPost("login")] public async Task> Login(LoginRequest request) => await request.ToLoginQuery() .BindAsync(command => _sender.Send(command)) - .MapAsync(authResult => _mapper.Map(authResult)) + .MapAsync(_mapper.Map) .FinallyAsync(result => MapToActionResult(result)); } diff --git a/BuberDinner.Api/src/Netural/Controllers/ErrorController.cs b/BuberDinner.Api/src/Netural/Controllers/ErrorController.cs index 3a7f95c..db2c9e1 100644 --- a/BuberDinner.Api/src/Netural/Controllers/ErrorController.cs +++ b/BuberDinner.Api/src/Netural/Controllers/ErrorController.cs @@ -1,14 +1,23 @@ -namespace FunctionalDDD.BuberDinner.Api.Netural.Controllers -{ - using Microsoft.AspNetCore.Mvc; +namespace BuberDinner.Api.Netural.Controllers; +using Microsoft.AspNetCore.Mvc; +using Asp.Versioning; - [ApiController] - public class ErrorController : ControllerBase +/// +/// Unhandled error controller. +/// +[ApiVersionNeutral] +[ApiExplorerSettings(IgnoreApi = true)] +public class ErrorController : ControllerBase +{ + /// + /// Show error + /// + /// + [HttpGet] + [Route("/error")] + public IActionResult Error() { - [Route("/error")] - public IActionResult Error() - { - return Problem(); - } + return Problem(); } } + diff --git a/BuberDinner.Api/src/Netural/Models/Authentication/AuthenticationMappingConfig.cs b/BuberDinner.Api/src/Netural/Models/Authentication/AuthenticationMappingConfig.cs index 248c427..986e247 100644 --- a/BuberDinner.Api/src/Netural/Models/Authentication/AuthenticationMappingConfig.cs +++ b/BuberDinner.Api/src/Netural/Models/Authentication/AuthenticationMappingConfig.cs @@ -3,12 +3,13 @@ using BuberDinner.Application.Services.Authentication.Common; using Mapster; -public class AuthenticationMappingConfig : IRegister +internal class AuthenticationMappingConfig : IRegister { public void Register(TypeAdapterConfig config) { config.NewConfig() .Map(dest => dest.Token, src => src.Token) + .Map(dest => dest.UserId, src => src.User.Id) .Map(dest => dest, src => src.User); } } diff --git a/BuberDinner.Api/src/Netural/Models/Authentication/AuthenticationResponse.cs b/BuberDinner.Api/src/Netural/Models/Authentication/AuthenticationResponse.cs index 3419db6..e347b53 100644 --- a/BuberDinner.Api/src/Netural/Models/Authentication/AuthenticationResponse.cs +++ b/BuberDinner.Api/src/Netural/Models/Authentication/AuthenticationResponse.cs @@ -1,10 +1,33 @@ namespace BuberDinner.Api.Netural.Models.Authentication; +/// +/// Authentication Response +/// public class AuthenticationResponse { - //public Guid Id { get; set; } + /// + /// User Id + /// + public string? UserId { get; set; } + + + /// + /// First Name + /// public string? FirstName { get; set; } + + /// + /// Last Name + /// public string? LastName { get; set; } + + /// + /// Email address + /// public string? Email { get; set; } + + /// + /// Token + /// public string? Token { get; set; } } diff --git a/BuberDinner.Api/src/Netural/Models/Authentication/LoginRequest.cs b/BuberDinner.Api/src/Netural/Models/Authentication/LoginRequest.cs index 9c1a6ff..25dfe12 100644 --- a/BuberDinner.Api/src/Netural/Models/Authentication/LoginRequest.cs +++ b/BuberDinner.Api/src/Netural/Models/Authentication/LoginRequest.cs @@ -3,12 +3,17 @@ using BuberDinner.Application.Services.Authentication.Queries; using BuberDinner.Domain.User.ValueObjects; +/// +/// Login request model. +/// +/// Email address +/// Password public record LoginRequest( string email, string password ) { - public Result ToLoginQuery() => + internal Result ToLoginQuery() => EmailAddress.Create(email) .Combine(Password.Create(password)) .Bind((email, pwd) => LoginQuery.Create(email, pwd)); diff --git a/BuberDinner.Api/src/Netural/Models/Authentication/RegisterRequest.cs b/BuberDinner.Api/src/Netural/Models/Authentication/RegisterRequest.cs index 80ac560..3ca23af 100644 --- a/BuberDinner.Api/src/Netural/Models/Authentication/RegisterRequest.cs +++ b/BuberDinner.Api/src/Netural/Models/Authentication/RegisterRequest.cs @@ -3,6 +3,13 @@ using BuberDinner.Application.Services.Authentication.Commands; using BuberDinner.Domain.User.ValueObjects; +/// +/// Register request model. +/// +/// First Name +/// Last Name +/// Email address +/// Password public record RegisterRequest( string firstName, string lastName, @@ -11,7 +18,7 @@ string password ) { - public Result ToRegisterCommand() => + internal Result ToRegisterCommand() => FirstName.Create(firstName) .Combine(LastName.Create(lastName)) .Combine(EmailAddress.Create(email)) diff --git a/BuberDinner.Api/src/Program.cs b/BuberDinner.Api/src/Program.cs index fc45c85..7107d94 100644 --- a/BuberDinner.Api/src/Program.cs +++ b/BuberDinner.Api/src/Program.cs @@ -12,6 +12,21 @@ var app = builder.Build(); { + app.UseSwagger(); + app.UseSwaggerUI( + options => + { + options.RoutePrefix = string.Empty; + var descriptions = app.DescribeApiVersions(); + + // build a swagger endpoint for each discovered API version + foreach (var description in descriptions) + { + var url = $"/swagger/{description.GroupName}/swagger.json"; + var name = description.GroupName.ToUpperInvariant(); + options.SwaggerEndpoint(url, name); + } + }); app.UseExceptionHandler("/error"); app.UseHttpsRedirection(); app.UseAuthentication(); @@ -20,4 +35,7 @@ app.Run(); } +/// +/// Main program +/// public partial class Program { } diff --git a/BuberDinner.Api/src/Properties/launchSettings.json b/BuberDinner.Api/src/Properties/launchSettings.json index 0aaf69f..c87607c 100644 --- a/BuberDinner.Api/src/Properties/launchSettings.json +++ b/BuberDinner.Api/src/Properties/launchSettings.json @@ -1,41 +1,14 @@ { "$schema": "https://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:32364", - "sslPort": 44338 - } - }, "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "http://localhost:5246", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, "https": { "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": true, - "launchUrl": "swagger", "applicationUrl": "https://localhost:7059;http://localhost:5246", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } } } } diff --git a/BuberDinner.Api/src/SwaggerDefaultValues.cs b/BuberDinner.Api/src/SwaggerDefaultValues.cs new file mode 100644 index 0000000..b908352 --- /dev/null +++ b/BuberDinner.Api/src/SwaggerDefaultValues.cs @@ -0,0 +1,68 @@ +namespace BuberDinner.Api; + +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Text.Json; + +/// +/// Represents the OpenAPI/Swashbuckle operation filter used to document information provided, but not used. +/// +/// This is only required due to bugs in the . +/// Once they are fixed and published, this class can be removed. +public class SwaggerDefaultValues : IOperationFilter +{ + /// + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var apiDescription = context.ApiDescription; + + operation.Deprecated |= apiDescription.IsDeprecated(); + + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1752#issue-663991077 + foreach (var responseType in context.ApiDescription.SupportedResponseTypes) + { + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/b7cf75e7905050305b115dd96640ddd6e74c7ac9/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs#L383-L387 + var responseKey = responseType.IsDefaultResponse ? "default" : responseType.StatusCode.ToString(); + var response = operation.Responses[responseKey]; + + foreach (var contentType in response.Content.Keys) + { + if (!responseType.ApiResponseFormats.Any(x => x.MediaType == contentType)) + { + response.Content.Remove(contentType); + } + } + } + + if (operation.Parameters == null) + { + return; + } + + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/412 + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/pull/413 + foreach (var parameter in operation.Parameters) + { + var description = apiDescription.ParameterDescriptions.First(p => p.Name == parameter.Name); + + if (parameter.Description == null) + { + parameter.Description = description.ModelMetadata?.Description; + } + + if (parameter.Schema.Default == null && + description.DefaultValue != null && + description.DefaultValue is not DBNull && + description.ModelMetadata is ModelMetadata modelMetadata) + { + // REF: https://github.com/Microsoft/aspnet-api-versioning/issues/429#issuecomment-605402330 + var json = JsonSerializer.Serialize(description.DefaultValue, modelMetadata.ModelType); + parameter.Schema.Default = OpenApiAnyFactory.CreateFromJson(json); + } + + parameter.Required |= description.IsRequired; + } + } +} diff --git a/BuberDinner.Api/tests/Netural/AuthenticationControllerTests.cs b/BuberDinner.Api/tests/Netural/AuthenticationControllerTests.cs index b50746b..330e214 100644 --- a/BuberDinner.Api/tests/Netural/AuthenticationControllerTests.cs +++ b/BuberDinner.Api/tests/Netural/AuthenticationControllerTests.cs @@ -58,17 +58,7 @@ public async Task Register_user() var response = await client.PostAsync(url, new StringContent(json, Encoding.UTF8, "application/json")); // Assert - response.EnsureSuccessStatusCode(); -#pragma warning disable CS8602 // Dereference of a possibly null reference. - response.Content.Headers.ContentType.ToString().Should().Be("application/json; charset=utf-8"); -#pragma warning restore CS8602 // Dereference of a possibly null reference. - var registeredUser = await response.Content.ReadAsExample(new { firstName = default(string), lastName = default(string), email = default(string) }); - registeredUser.Should().BeEquivalentTo(new - { - firstName = "Xavier", - lastName = "John", - email = "someone@somewhere.com" - }); + await ValidateAuthenticationResponse(response); } [Fact, Priority(3)] @@ -88,16 +78,25 @@ public async Task Login_with_registered_user() var response = await client.PostAsync(url, new StringContent(json, Encoding.UTF8, "application/json")); // Assert - response.StatusCode.Should().Be(HttpStatusCode.OK); + await ValidateAuthenticationResponse(response); + } + + private static async Task ValidateAuthenticationResponse(HttpResponseMessage response) + { + response.EnsureSuccessStatusCode(); #pragma warning disable CS8602 // Dereference of a possibly null reference. response.Content.Headers.ContentType.ToString().Should().Be("application/json; charset=utf-8"); -#pragma warning restore CS8602 // Dereference of a possibly null reference. - var registeredUser = await response.Content.ReadAsExample(new { firstName = default(string), lastName = default(string), email = default(string) }); +#pragma warning restore CS8602 // Dereference of a possibly null reference. + + var registeredUser = await response.Content.ReadAsExample(new { userId = default(string), firstName = default(string), lastName = default(string), email = default(string) }); registeredUser.Should().BeEquivalentTo(new { firstName = "Xavier", lastName = "John", email = "someone@somewhere.com" }); + + if (registeredUser == null) return; + Guid.TryParse(registeredUser.userId, out var parsedUserId).Should().BeTrue(); } } diff --git a/Directory.Packages.props b/Directory.Packages.props index 3d73a6e..97895a0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,35 +4,36 @@ - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + - - - + + \ No newline at end of file