diff --git a/AdvancedAPI.Business/AdvancedAPI.Business.csproj b/AdvancedAPI.Business/AdvancedAPI.Business.csproj index 06d6311..3a6b85f 100644 --- a/AdvancedAPI.Business/AdvancedAPI.Business.csproj +++ b/AdvancedAPI.Business/AdvancedAPI.Business.csproj @@ -10,6 +10,7 @@ + all diff --git a/AdvancedAPI.Business/MappingProfile.cs b/AdvancedAPI.Business/MappingProfile.cs new file mode 100644 index 0000000..a65aa77 --- /dev/null +++ b/AdvancedAPI.Business/MappingProfile.cs @@ -0,0 +1,19 @@ +using AdvancedAPI.Data.Models; +using AdvancedAPI.Data.ViewModels.NewsArticle; +using AutoMapper; + +namespace Business; + +/// +/// Auto mapper profiles. +/// +public class MappingProfile : Profile +{ + /// + /// Mapping Models against entities and opposite. + /// + public MappingProfile() + { + CreateMap(); + } +} diff --git a/AdvancedAPI.Business/ServiceExtension.cs b/AdvancedAPI.Business/ServiceExtension.cs new file mode 100644 index 0000000..8e629b8 --- /dev/null +++ b/AdvancedAPI.Business/ServiceExtension.cs @@ -0,0 +1,23 @@ +using Business.Services; +using Business.Services.Interfaces; + +namespace Business; + +/// +/// Service extension used to prepare the business layer for usage. +/// +public static class ServiceExtension +{ + /// + /// Registers everything business layer related. + /// + public static IServiceCollection AddBusinessServices(this IServiceCollection services) + { + services.AddAutoMapper(typeof(MappingProfile).Assembly); + + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/AdvancedAPI.Business/Services/AuthenticationService.cs b/AdvancedAPI.Business/Services/AuthenticationService.cs index 9b7dc56..fcdd9ac 100644 --- a/AdvancedAPI.Business/Services/AuthenticationService.cs +++ b/AdvancedAPI.Business/Services/AuthenticationService.cs @@ -30,12 +30,14 @@ public AuthenticationService(IIdentityRepository identityRepository, IConfigurat IdentityUser? user = await _identityRepository.GetUser(requestModel.Username); if (user != null && await _identityRepository.CheckPassword(user, requestModel.Password)) { - Claim[] authClaims = new[] + List authClaims = new() { new Claim(JwtRegisteredClaimNames.Sub, user.UserName), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), }; + IList userRoles = await _identityRepository.GetRoles(user); + authClaims.AddRange(userRoles.Select(role => new Claim(ClaimTypes.Role, role))); SymmetricSecurityKey authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"])); JwtSecurityToken token = new JwtSecurityToken( diff --git a/AdvancedAPI.Business/Services/Interfaces/INewsArticleService.cs b/AdvancedAPI.Business/Services/Interfaces/INewsArticleService.cs new file mode 100644 index 0000000..4f1b8c2 --- /dev/null +++ b/AdvancedAPI.Business/Services/Interfaces/INewsArticleService.cs @@ -0,0 +1,19 @@ +using AdvancedAPI.Data.ViewModels.NewsArticle; + +namespace Business.Services.Interfaces; + +/// +/// news article service. +/// +public interface INewsArticleService +{ + /// + /// Inserts a new news article into the database. + /// + public Task CreateNewsArticle(NewsArticleRequestModel requestModel); + + /// + /// Deletes the news article from the database. + /// + public Task DeleteNewsArticle(int id); +} diff --git a/AdvancedAPI.Business/Services/NewsArticleService.cs b/AdvancedAPI.Business/Services/NewsArticleService.cs new file mode 100644 index 0000000..42e0cd5 --- /dev/null +++ b/AdvancedAPI.Business/Services/NewsArticleService.cs @@ -0,0 +1,68 @@ +using AdvancedAPI.Data.Models; +using AdvancedAPI.Data.Repositories.Interfaces; +using AdvancedAPI.Data.ViewModels.NewsArticle; +using AutoMapper; +using Business.Services.Interfaces; + +namespace Business.Services; + +/// +public class NewsArticleService : INewsArticleService +{ + private readonly ILogger _logger; + private readonly IMapper _mapper; + private readonly INewsArticleRepository _newsArticleRepository; + + /// + /// Constructor. + /// + public NewsArticleService(ILogger logger, IMapper mapper, INewsArticleRepository newsArticleRepository) + { + _logger = logger; + _mapper = mapper; + _newsArticleRepository = newsArticleRepository; + } + + /// + public async Task CreateNewsArticle(NewsArticleRequestModel requestModel) + { + var mapped = _mapper.Map(requestModel); + mapped.ReleaseDate = DateTime.Now; + + try + { + await _newsArticleRepository.AddAsync(mapped); + await _newsArticleRepository.SaveAsync(); + } + catch (Exception e) + { + _logger.LogError(20, e, "Failed to insert the news article"); + throw new Exception("Could not insert news article"); + } + + return true; + } + + /// + public async Task DeleteNewsArticle(int id) + { + try + { + NewsArticle? newsArticle = await _newsArticleRepository.GetByIdAsync(id); + if (newsArticle != null) + { + _newsArticleRepository.Delete(newsArticle); + await _newsArticleRepository.SaveAsync(); + + return true; + } + } + catch (Exception e) + { + _logger.LogError(20, e, $"Failed to delete the news article with id: {id}"); + throw new Exception($"Could not delete the news article with id: {id}"); + } + + return false; + } +} diff --git a/AdvancedAPI.Data/AdvancedApiContext.cs b/AdvancedAPI.Data/AdvancedApiContext.cs index b0e5e83..caed2eb 100644 --- a/AdvancedAPI.Data/AdvancedApiContext.cs +++ b/AdvancedAPI.Data/AdvancedApiContext.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Identity; +using AdvancedAPI.Data.Models; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; @@ -16,4 +17,25 @@ public AdvancedApiContext(DbContextOptions options) : base(options) { } + + /// + /// News article database objects. + /// + public DbSet NewsArticles { get; set; } + + /// + /// when creating models. + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Ensure primary keys are defined for IdentityUserLogin + modelBuilder.Entity>(entity => + { + entity.HasKey(e => new { e.LoginProvider, e.ProviderKey }); + }); + + // Additional model configurations + } } diff --git a/AdvancedAPI.Data/DbInitializer.cs b/AdvancedAPI.Data/DbInitializer.cs index b72e251..9c59272 100644 --- a/AdvancedAPI.Data/DbInitializer.cs +++ b/AdvancedAPI.Data/DbInitializer.cs @@ -18,8 +18,9 @@ public static async Task Initialize(IServiceProvider serviceProvider) // Seed roles await SeedRoles(roleManager); - // Seed admin user + // Seed users await SeedAdminUser(userManager); + await SeedUserUser(userManager); } /// @@ -41,7 +42,7 @@ private static async Task SeedRoles(RoleManager roleManager) } /// - /// Seeding user into the database. + /// Seeding admin user into the database. /// private static async Task SeedAdminUser(UserManager userManager) { @@ -61,5 +62,27 @@ private static async Task SeedAdminUser(UserManager userManager) } } } + + /// + /// Seeding user user into the database. + /// + private static async Task SeedUserUser(UserManager userManager) + { + IdentityUser? adminUser = await userManager.FindByEmailAsync("user@example.com"); + if (adminUser == null) + { + adminUser = new IdentityUser + { + UserName = "user@example.com", + Email = "user@example.com", + }; + + IdentityResult? result = await userManager.CreateAsync(adminUser, "P@ssw0rd"); + if (result.Succeeded) + { + await userManager.AddToRoleAsync(adminUser, "User"); + } + } + } } } diff --git a/AdvancedAPI.Data/Migrations/20240721111906_AddNewsArticle.Designer.cs b/AdvancedAPI.Data/Migrations/20240721111906_AddNewsArticle.Designer.cs new file mode 100644 index 0000000..37012ab --- /dev/null +++ b/AdvancedAPI.Data/Migrations/20240721111906_AddNewsArticle.Designer.cs @@ -0,0 +1,302 @@ +// +using System; +using AdvancedAPI.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace AdvancedAPI.Data.Migrations +{ + [DbContext(typeof(AdvancedApiContext))] + [Migration("20240721111906_AddNewsArticle")] + partial class AddNewsArticle + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.32") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); + + modelBuilder.Entity("AdvancedAPI.Data.Models.NewsArticle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); + + b.Property("ContentHtml") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("HeaderText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReleaseDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("NewsArticles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/AdvancedAPI.Data/Migrations/20240721111906_AddNewsArticle.cs b/AdvancedAPI.Data/Migrations/20240721111906_AddNewsArticle.cs new file mode 100644 index 0000000..91d4bc7 --- /dev/null +++ b/AdvancedAPI.Data/Migrations/20240721111906_AddNewsArticle.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace AdvancedAPI.Data.Migrations +{ + public partial class AddNewsArticle : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "NewsArticles", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + HeaderText = table.Column(type: "nvarchar(max)", nullable: false), + ContentHtml = table.Column(type: "nvarchar(max)", nullable: false), + ReleaseDate = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_NewsArticles", x => x.Id); + }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "NewsArticles"); + } + } +} diff --git a/AdvancedAPI.Data/Migrations/AdvancedApiContextModelSnapshot.cs b/AdvancedAPI.Data/Migrations/AdvancedApiContextModelSnapshot.cs index d27c4fd..5a8b639 100644 --- a/AdvancedAPI.Data/Migrations/AdvancedApiContextModelSnapshot.cs +++ b/AdvancedAPI.Data/Migrations/AdvancedApiContextModelSnapshot.cs @@ -22,6 +22,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder, 1L, 1); + modelBuilder.Entity("AdvancedAPI.Data.Models.NewsArticle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"), 1L, 1); + + b.Property("ContentHtml") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("HeaderText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReleaseDate") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.ToTable("NewsArticles"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { b.Property("Id") diff --git a/AdvancedAPI.Data/Models/NewsArticle.cs b/AdvancedAPI.Data/Models/NewsArticle.cs new file mode 100644 index 0000000..3834b10 --- /dev/null +++ b/AdvancedAPI.Data/Models/NewsArticle.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace AdvancedAPI.Data.Models; + +/// +/// News article model. +/// +public class NewsArticle +{ + /// + /// Identifier of the news article. + /// + [Key] + public int Id { get; set; } + + /// + /// Header text of the news article. + /// + public string HeaderText { get; set; } + + /// + /// Content (html) of the news article. + /// + public string ContentHtml { get; set; } + + /// + /// Release date of the news article. + /// + public DateTime ReleaseDate { get; set; } +} diff --git a/AdvancedAPI.Data/Repositories/BaseRepository.cs b/AdvancedAPI.Data/Repositories/BaseRepository.cs index 2519429..a32a8e3 100644 --- a/AdvancedAPI.Data/Repositories/BaseRepository.cs +++ b/AdvancedAPI.Data/Repositories/BaseRepository.cs @@ -55,7 +55,7 @@ public void Update(T entity) _dbSet.Attach(entity); _context.Entry(entity).State = EntityState.Modified; } - + /// public void Delete(T entity) { diff --git a/AdvancedAPI.Data/Repositories/IdentityRepository.cs b/AdvancedAPI.Data/Repositories/IdentityRepository.cs index 29a584a..db4c594 100644 --- a/AdvancedAPI.Data/Repositories/IdentityRepository.cs +++ b/AdvancedAPI.Data/Repositories/IdentityRepository.cs @@ -22,4 +22,6 @@ public IdentityRepository(UserManager userManager) /// public async Task CheckPassword(IdentityUser user, string password) => await _userManager.CheckPasswordAsync(user, password); + + public async Task> GetRoles(IdentityUser user) => await _userManager.GetRolesAsync(user); } diff --git a/AdvancedAPI.Data/Repositories/Interfaces/IIdentityRepository.cs b/AdvancedAPI.Data/Repositories/Interfaces/IIdentityRepository.cs index 57758ea..a45e1e7 100644 --- a/AdvancedAPI.Data/Repositories/Interfaces/IIdentityRepository.cs +++ b/AdvancedAPI.Data/Repositories/Interfaces/IIdentityRepository.cs @@ -10,10 +10,15 @@ public interface IIdentityRepository /// /// Getting the user from User manager. /// - Task GetUser(string userName); + public Task GetUser(string userName); /// /// Checks the password of the user with user manager. /// - Task CheckPassword(IdentityUser user, string password); + public Task CheckPassword(IdentityUser user, string password); + + /// + /// Getting a list of roles assigned to the user. + /// + public Task> GetRoles(IdentityUser user); } diff --git a/AdvancedAPI.Data/Repositories/Interfaces/INewsArticleRepository.cs b/AdvancedAPI.Data/Repositories/Interfaces/INewsArticleRepository.cs new file mode 100644 index 0000000..1475b1a --- /dev/null +++ b/AdvancedAPI.Data/Repositories/Interfaces/INewsArticleRepository.cs @@ -0,0 +1,10 @@ +using AdvancedAPI.Data.Models; + +namespace AdvancedAPI.Data.Repositories.Interfaces; + +/// +/// News article repository. +/// +public interface INewsArticleRepository : IBaseRepository +{ +} diff --git a/AdvancedAPI.Data/Repositories/NewsArticleRepository.cs b/AdvancedAPI.Data/Repositories/NewsArticleRepository.cs new file mode 100644 index 0000000..b124922 --- /dev/null +++ b/AdvancedAPI.Data/Repositories/NewsArticleRepository.cs @@ -0,0 +1,15 @@ +using AdvancedAPI.Data.Models; +using AdvancedAPI.Data.Repositories.Interfaces; + +namespace AdvancedAPI.Data.Repositories; + +public class NewsArticleRepository : BaseRepository, INewsArticleRepository +{ + /// + /// constructor. + /// + public NewsArticleRepository(AdvancedApiContext context) + : base(context) + { + } +} diff --git a/AdvancedAPI.Data/ViewModels/NewsArticle/NewsArticleRequestModel.cs b/AdvancedAPI.Data/ViewModels/NewsArticle/NewsArticleRequestModel.cs new file mode 100644 index 0000000..373aea7 --- /dev/null +++ b/AdvancedAPI.Data/ViewModels/NewsArticle/NewsArticleRequestModel.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; + +namespace AdvancedAPI.Data.ViewModels.NewsArticle; + +/// +/// Request model to create or update a news article. +/// +[JsonObject] +public class NewsArticleRequestModel +{ + /// + /// Identifier of news article (if editted) + /// + public int id { get; set; } + + /// + /// Header text. + /// + [Required] + [MaxLength(75)] + public string HeaderText { get; set; } + + /// + /// Content written in HTML. + /// + [Required] + public string ContentHtml { get; set; } +} diff --git a/AdvancedAPI.Tests/Services/NewsArticleTests.cs b/AdvancedAPI.Tests/Services/NewsArticleTests.cs new file mode 100644 index 0000000..8c28474 --- /dev/null +++ b/AdvancedAPI.Tests/Services/NewsArticleTests.cs @@ -0,0 +1,150 @@ +using AdvancedAPI.Data.Models; +using AdvancedAPI.Data.Repositories.Interfaces; +using AdvancedAPI.Data.ViewModels.NewsArticle; +using AutoFixture; +using AutoFixture.AutoMoq; +using AutoMapper; +using Business.Services; +using Business.Services.Interfaces; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Tests.Services; + +/// +/// all tests for . +/// +public class NewsArticleTests +{ + private readonly IFixture _fixture; + private readonly INewsArticleService _newsArticleService; + private readonly Mock _newsArticleRepository; + private readonly Mock _mapper; + + /// + /// Constructor. + /// + public NewsArticleTests() + { + _fixture = new Fixture().Customize(new AutoMoqCustomization()); + _newsArticleRepository = _fixture.Freeze>(); + _mapper = _fixture.Freeze>(); + Mock> logger = _fixture.Freeze>>(); + _newsArticleService = new NewsArticleService(logger.Object, _mapper.Object, _newsArticleRepository.Object); + } + + /// + /// CreateNewArticle Creates successfully. + /// + [Fact] + public async Task CreateNewsArticleCreateSuccessfully() + { + NewsArticleRequestModel? requestModel = _fixture.Create(); + NewsArticle? newsArticleFixture = _fixture.Create(); + _mapper.Setup(m => m.Map(It.IsAny())).Returns(newsArticleFixture); + _newsArticleRepository.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); + _newsArticleRepository.Setup(r => r.SaveAsync()).Returns(Task.CompletedTask); + + bool result = await _newsArticleService.CreateNewsArticle(requestModel); + + Assert.True(result); + } + + /// + /// CreateNewsArticle will fail on AddAsync. + /// + [Fact] + public async Task CreateNewsArticleCreateFailedOnAdd() + { + NewsArticleRequestModel? requestModel = _fixture.Create(); + NewsArticle? newsArticleFixture = _fixture.Create(); + _mapper.Setup(m => m.Map(It.IsAny())).Returns(newsArticleFixture); + _newsArticleRepository.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); + _newsArticleRepository.Setup(r => r.SaveAsync()).Throws(It.IsAny()); + + var exception = await Assert.ThrowsAsync(() => _newsArticleService.CreateNewsArticle(requestModel)); + + Assert.Equal("Could not insert news article", exception.Message); + } + + /// + /// CreateNewsArticle will fail on SaveChanges. + /// + [Fact] + public async Task CreateNewsArticleCreateFailedOnSaveChanges() + { + NewsArticleRequestModel? requestModel = _fixture.Create(); + NewsArticle? newsArticleFixture = _fixture.Create(); + _mapper.Setup(m => m.Map(It.IsAny())).Returns(newsArticleFixture); + _newsArticleRepository.Setup(r => r.AddAsync(It.IsAny())).Throws(It.IsAny()); + _newsArticleRepository.Setup(r => r.SaveAsync()).Returns(Task.CompletedTask); + + Exception? exception = await Assert.ThrowsAsync(() => _newsArticleService.CreateNewsArticle(requestModel)); + + Assert.Equal("Could not insert news article", exception.Message); + } + + /// + /// DeleteNewsArticle will returns false after GetById. + /// + [Fact] + public async Task DeleteNewsArticleDeletesFailedOnNotFound() + { + int id = 1; + NewsArticle? newsArticleFixture = _fixture.Create(); + _mapper.Setup(m => m.Map(It.IsAny())).Returns(newsArticleFixture); + _newsArticleRepository.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync(value: null); + + bool result = await _newsArticleService.DeleteNewsArticle(id); + + Assert.False(result); + } + + /// + /// DeleteNewsArticle will fail on Delete. + /// + [Fact] + public async Task DeleteNewsArticleDeletesFailedOnDelete() + { + int id = 1; + NewsArticle newsArticle = new NewsArticle + { + Id = 1, + HeaderText = "unit test", + ContentHtml = "

Unit test being used

", + }; + + NewsArticle? newsArticleFixture = _fixture.Create(); + _mapper.Setup(m => m.Map(It.IsAny())).Returns(newsArticleFixture); + _newsArticleRepository.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync(newsArticle); + _newsArticleRepository.Setup(r => r.Delete(It.IsAny())).Throws(It.IsAny()); + + Exception? exception = await Assert.ThrowsAsync(() => _newsArticleService.DeleteNewsArticle(id)); + + Assert.Equal($"Could not delete the news article with id: {id}", exception.Message); + } + + /// + /// DeleteNewArticle will succeed. + /// + [Fact] + public async Task DeleteNewsArticleSucceed() + { + int id = 1; + NewsArticle newsArticle = new NewsArticle + { + Id = 1, + HeaderText = "unit test", + ContentHtml = "

Unit test being used

", + }; + + NewsArticle? newsArticleFixture = _fixture.Create(); + _mapper.Setup(m => m.Map(It.IsAny())).Returns(newsArticleFixture); + _newsArticleRepository.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync(newsArticle); + _newsArticleRepository.Setup(r => r.Delete(It.IsAny())); + + bool result = await _newsArticleService.DeleteNewsArticle(id); + + Assert.True(result); + } +} diff --git a/AdvancedAPI/BaseControllers/AdminBaseController.cs b/AdvancedAPI/BaseControllers/AdminBaseController.cs index 10c38a9..65c3396 100644 --- a/AdvancedAPI/BaseControllers/AdminBaseController.cs +++ b/AdvancedAPI/BaseControllers/AdminBaseController.cs @@ -1,12 +1,14 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; namespace AdvancedAPI.BaseControllers; /// /// Controller for admin related endpoints. /// -[Microsoft.AspNetCore.Components.Route("admin")] -[ApiExplorerSettings(GroupName = "Admin")] +[Authorize(Policy = "AdminPolicy")] +[Route("admin")] public class AdminBaseController : BaseController { /// diff --git a/AdvancedAPI/Controllers/Admin/DashboardController.cs b/AdvancedAPI/Controllers/Admin/DashboardController.cs new file mode 100644 index 0000000..01b0b0b --- /dev/null +++ b/AdvancedAPI/Controllers/Admin/DashboardController.cs @@ -0,0 +1,29 @@ +// +// Copyright (c) PlaceholderCompany. All rights reserved. +// + +using AdvancedAPI.BaseControllers; +using Microsoft.AspNetCore.Mvc; + +namespace AdvancedAPI.Controllers.Admin; + +/// +/// Dashboard endpoints. +/// +[Microsoft.AspNetCore.Components.Route("dashboard")] +public class DashboardController : AdminBaseController +{ + /// + /// Constructor. + /// + public DashboardController() + { + } + + [HttpGet] + [Route("overview")] + public async Task GetOverview() + { + return Ok(); + } +} diff --git a/AdvancedAPI/Controllers/Admin/NewsArticleController.cs b/AdvancedAPI/Controllers/Admin/NewsArticleController.cs new file mode 100644 index 0000000..e876e8e --- /dev/null +++ b/AdvancedAPI/Controllers/Admin/NewsArticleController.cs @@ -0,0 +1,61 @@ +using AdvancedAPI.BaseControllers; +using AdvancedAPI.Data.ViewModels.NewsArticle; +using Business.Services.Interfaces; +using Microsoft.AspNetCore.Mvc; + +namespace AdvancedAPI.Controllers.Admin; + +/// +/// News article admin controller. +/// +[Route("news")] +public class NewsArticleController : AdminBaseController +{ + private readonly INewsArticleService _newsArticleService; + + /// + /// Constructor. + /// + public NewsArticleController(INewsArticleService newsArticleService) + { + _newsArticleService = newsArticleService; + } + + /// + /// Creates a news article + /// + [HttpPost] + public async Task Create([FromBody] NewsArticleRequestModel requestModel) + { + if (!ModelState.IsValid) + { + return BadRequestResult(null); + } + + if (await _newsArticleService.CreateNewsArticle(requestModel)) + { + return Ok("News article got created"); + } + + return Problem("could not create the news article"); + } + + /// + /// Deletes the news article. + /// + [HttpDelete] + public async Task Create([FromBody] int id) + { + if (id == 0) + { + return BadRequestResult("No Id was filled in!"); + } + + if (await _newsArticleService.DeleteNewsArticle(id)) + { + return Ok("The news article got deleted"); + } + + return Problem("Could not delete the given new article"); + } +} diff --git a/AdvancedAPI/Program.cs b/AdvancedAPI/Program.cs index da32119..8df40cf 100644 --- a/AdvancedAPI/Program.cs +++ b/AdvancedAPI/Program.cs @@ -3,12 +3,12 @@ using AdvancedAPI.Data; using AdvancedAPI.Data.Repositories; using AdvancedAPI.Data.Repositories.Interfaces; -using Business.Services; -using Business.Services.Interfaces; +using Business; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); @@ -16,17 +16,18 @@ builder.Services.AddControllersWithViews(); // Services. -builder.Services.AddScoped(); +builder.Services.AddBusinessServices(); // Repositories. builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSwaggerGen( c => { c.SwaggerDoc( "v1", - new Microsoft.OpenApi.Models.OpenApiInfo + new OpenApiInfo { Title = "AdvancedAPI", Version = "v1", @@ -36,11 +37,42 @@ var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); c.IncludeXmlComments(xmlPath); + + c.AddSecurityDefinition( + "Bearer", + new OpenApiSecurityScheme + { + Description = + "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + Scheme = "Bearer", + }); + + // Add the security requirement + c.AddSecurityRequirement( + new OpenApiSecurityRequirement() + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer", + }, + Scheme = "oauth2", + Name = "Bearer", + In = ParameterLocation.Header, + }, + new List() + }, + }); }); var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); -// Register ApplicationDbContext with the connection string builder.Services.AddDbContext( options => options.UseSqlServer(connectionString)); @@ -49,29 +81,41 @@ .AddEntityFrameworkStores() .AddDefaultTokenProviders(); -builder.Services.AddScoped(); -// Configure JWT authentication -// Get the JWT secret key from configuration string jwtKey = builder.Configuration["Jwt:Key"]; byte[] key = Encoding.ASCII.GetBytes(jwtKey); -builder.Services.AddAuthentication(x => - { - x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - }) - .AddJwtBearer(x => - { - x.RequireHttpsMetadata = false; - x.SaveToken = true; - x.TokenValidationParameters = new TokenValidationParameters +builder.Services.AddAuthentication( + x => + { + x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer( + x => { - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(key), - ValidateIssuer = false, - ValidateAudience = false, - }; + x.RequireHttpsMetadata = false; + x.SaveToken = true; + x.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(key), + ValidateIssuer = false, + ValidateAudience = false, + }; + }); + +builder.Services.AddAuthorization( + options => + { + options.AddPolicy( + "AdminPolicy", + policy => + policy.RequireRole("Admin")); + options.AddPolicy( + "UserPolicy", + policy => + policy.RequireRole("User")); }); WebApplication app = builder.Build();