diff --git a/.gitignore b/.gitignore index cf332414..bd7895e0 100644 --- a/.gitignore +++ b/.gitignore @@ -368,3 +368,4 @@ FodyWeavers.xsd */**/bin/Release */**/obj/Debug */**/obj/Release +*/**/Migrations diff --git a/api-cinema-challenge/api-cinema-challenge/Controllers/UserController.cs b/api-cinema-challenge/api-cinema-challenge/Controllers/UserController.cs new file mode 100644 index 00000000..fb1cd938 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Controllers/UserController.cs @@ -0,0 +1,100 @@ +using api_cinema_challenge.DataModels; +using api_cinema_challenge.Enums; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using api_cinema_challenge.Data; +using api_cinema_challenge.DataTransfer.Requests; +using api_cinema_challenge.DataTransfer.Response; +using api_cinema_challenge.Services; + +namespace api_cinema_challenge.Controllers +{ + + [ApiController] + [Route("/api/[controller]")] + public class UsersController : ControllerBase + { + private readonly UserManager _userManager; + private readonly CinemaContext _context; + private readonly TokenService _tokenService; + + public UsersController(UserManager userManager, CinemaContext context, + TokenService tokenService, ILogger logger) + { + _userManager = userManager; + _context = context; + _tokenService = tokenService; + } + + + [HttpPost] + [Route("register")] + public async Task Register(RegistrationRequest request) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var result = await _userManager.CreateAsync( + new ApplicationUser { UserName = request.Username, Email = request.Email, Role = request.Role }, + request.Password! + ); + + if (result.Succeeded) + { + request.Password = ""; + return CreatedAtAction(nameof(Register), new { email = request.Email, role = Role.User }, request); + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(error.Code, error.Description); + } + + return BadRequest(ModelState); + } + + + [HttpPost] + [Route("login")] + public async Task> Authenticate([FromBody] AuthRequest request) + { + if (!ModelState.IsValid) + { + return BadRequest(ModelState); + } + + var managedUser = await _userManager.FindByEmailAsync(request.Email!); + + if (managedUser == null) + { + return BadRequest("Bad credentials"); + } + + var isPasswordValid = await _userManager.CheckPasswordAsync(managedUser, request.Password!); + + if (!isPasswordValid) + { + return BadRequest("Bad credentials"); + } + + var userInDb = _context.Users.FirstOrDefault(u => u.Email == request.Email); + + if (userInDb is null) + { + return Unauthorized(); + } + + var accessToken = _tokenService.CreateToken(userInDb); + await _context.SaveChangesAsync(); + + return Ok(new AuthResponse + { + Username = userInDb.UserName, + Email = userInDb.Email, + Token = accessToken, + }); + } + } +} \ No newline at end of file diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerGet.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerGet.cs new file mode 100644 index 00000000..69254986 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerGet.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace api_cinema_challenge.DTOs +{ + public class CustomerGet + { + public int Id { get; set; } + public string Name { get; set; } + public string Email { get; set; } + public string Phone { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerPost.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerPost.cs new file mode 100644 index 00000000..3670e2bb --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerPost.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace api_cinema_challenge.DTOs +{ + public class CustomerPost + { + public string Name { get; set; } + [EmailAddress] + public string Email { get; set; } + public string Phone { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerPut.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerPut.cs new file mode 100644 index 00000000..4a695f00 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/CustomerPut.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace api_cinema_challenge.DTOs +{ + public class CustomerPut + { + public string Name { get; set; } + [EmailAddress] + public string Email { get; set; } + public string Phone { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/MovieFactory.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/MovieFactory.cs new file mode 100644 index 00000000..4159d090 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/MovieFactory.cs @@ -0,0 +1,38 @@ +using api_cinema_challenge.Models; +using System.Reflection; + +namespace api_cinema_challenge.DTOs +{ + public static class MovieFactory + { + public static Movie NewMovie(MoviePost item) + { + if (item is null) return null; + + return new Movie() + { + Title = item.Title, + Rating = item.Rating, + Description = item.Description, + RuntimeMins = item.RuntimeMins, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + } + public static MovieGet NewMovieGet(Movie item) + { + if (item is null) return null; + + return new MovieGet() + { + Id = item.Id, + Title = item.Title, + Rating = item.Rating, + Description = item.Description, + RuntimeMins = item.RuntimeMins, + CreatedAt = item.CreatedAt, + UpdatedAt = item.UpdatedAt, + }; + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/MovieGet.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/MovieGet.cs new file mode 100644 index 00000000..c67e4f79 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/MovieGet.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Http.HttpResults; + +namespace api_cinema_challenge.DTOs +{ + public class MovieGet + { + public int Id { get; set; } + public string Title { get; set; } + public string Rating { get; set; } + public string Description { get; set; } + public int RuntimeMins { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/MoviePost.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/MoviePost.cs new file mode 100644 index 00000000..a765657e --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/MoviePost.cs @@ -0,0 +1,10 @@ +namespace api_cinema_challenge.DTOs +{ + public class MoviePost + { + public string Title { get; set; } + public string Rating { get; set; } + public string Description { get; set; } + public int RuntimeMins { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/MoviePut.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/MoviePut.cs new file mode 100644 index 00000000..b313f1a2 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/MoviePut.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; + +namespace api_cinema_challenge.DTOs +{ + public class MoviePut + { + public string Title { get; set; } + public string Rating { get; set; } + public string Description { get; set; } + public int RuntimeMins { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/ScreeningFactory.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/ScreeningFactory.cs new file mode 100644 index 00000000..edab9690 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/ScreeningFactory.cs @@ -0,0 +1,36 @@ +using api_cinema_challenge.Models; + +namespace api_cinema_challenge.DTOs +{ + public static class ScreeningFactory + { + public static ScreeningGet NewScreeningGet(Screening item) + { + if (item == null) return null; + + return new ScreeningGet() + { + Id = item.Id, + ScreenNumber = item.ScreenNumber, + Capacity = item.Capacity, + StartsAt = item.StartsAt, + CreatedAt = item.CreatedAt, + UpdatedAt = item.UpdatedAt, + }; + } + + public static Screening NewScreening(int movieId, ScreeningPost item) + { + if (item == null) return null; + return new Screening() + { + MovieId = movieId, + ScreenNumber = item.ScreenNumber, + Capacity = item.Capacity, + StartsAt = item.StartsAt, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/ScreeningGet.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/ScreeningGet.cs new file mode 100644 index 00000000..975339ea --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/ScreeningGet.cs @@ -0,0 +1,12 @@ +namespace api_cinema_challenge.DTOs +{ + public class ScreeningGet + { + public int Id { get; set; } + public int ScreenNumber { get; set; } + public int Capacity { get; set; } + public DateTime StartsAt { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/ScreeningPost.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/ScreeningPost.cs new file mode 100644 index 00000000..f58f4eac --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/ScreeningPost.cs @@ -0,0 +1,9 @@ +namespace api_cinema_challenge.DTOs +{ + public class ScreeningPost + { + public int ScreenNumber { get; set; } + public int Capacity { get; set; } + public DateTime StartsAt { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs index ad4fe854..190c7a0c 100644 --- a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs +++ b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs @@ -1,9 +1,13 @@ -using Microsoft.EntityFrameworkCore; +using api_cinema_challenge.DataModels; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using api_cinema_challenge.Models; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; using Newtonsoft.Json.Linq; namespace api_cinema_challenge.Data { - public class CinemaContext : DbContext + public class CinemaContext : IdentityUserContext { private string _connectionString; public CinemaContext(DbContextOptions options) : base(options) @@ -20,7 +24,28 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder) { + List customers = new List(); + customers.Add(new Customer { Id = 1, Name = "Nigel", Email = "nigel@nigel.nigel", Phone = "+44729388192", CreatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc), UpdatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc) }); + customers.Add(new Customer { Id = 2, Name = "Dave", Email = "dave@dave.dave", Phone = "+44729388180", CreatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc), UpdatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc) }); + customers.Add(new Customer { Id = 3, Name = "Walter", Email = "walter@white.bb", Phone = "+44729383492", CreatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc), UpdatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc) }); + modelBuilder.Entity().HasData(customers); + List movies = new List(); + movies.Add(new Movie {Id = 1, Title = "Dodgeball", Rating = "PG-13", Description = "Dodge this", RuntimeMins = 126, CreatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc), UpdatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc) }); + movies.Add(new Movie {Id = 2, Title = "Dune: Part 3", Rating = "PG-100", Description = "Spice", RuntimeMins = 158, CreatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc), UpdatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc) }); + movies.Add(new Movie { Id = 3, Title = "Return of the King", Rating = "PG-18", Description = "Epic", RuntimeMins = 240, CreatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc), UpdatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc) }); + modelBuilder.Entity().HasData(movies); + + List screenings = new List(); + screenings.Add(new Screening {Id = 1, MovieId = 2, ScreenNumber=1, Capacity=100, StartsAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc), CreatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc), UpdatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc) }); + screenings.Add(new Screening {Id = 2, MovieId = 2, ScreenNumber=2, Capacity=150, StartsAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc), CreatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc), UpdatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc) }); + screenings.Add(new Screening { Id = 3, MovieId = 3, ScreenNumber = 3, Capacity = 60, StartsAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc), CreatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc), UpdatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc) }); + screenings.Add(new Screening { Id = 4, MovieId = 1, ScreenNumber = 4, Capacity = 700, StartsAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc), CreatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc), UpdatedAt = new DateTime(2025, 08, 25, 10, 00, 00, 00, DateTimeKind.Utc) }); + modelBuilder.Entity().HasData(screenings); } + + public DbSet Customers { get; set; } + public DbSet Movies { get; set; } + public DbSet Screenings { get; set; } } } diff --git a/api-cinema-challenge/api-cinema-challenge/DataTransfer/Request/AuthRequest.cs b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Request/AuthRequest.cs new file mode 100644 index 00000000..811ba311 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Request/AuthRequest.cs @@ -0,0 +1,12 @@ +namespace api_cinema_challenge.DataTransfer.Requests; + +public class AuthRequest +{ + public string? Email { get; set; } + public string? Password { get; set; } + + public bool IsValid() + { + return true; + } +} \ No newline at end of file diff --git a/api-cinema-challenge/api-cinema-challenge/DataTransfer/Request/RegistrationRequest.cs b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Request/RegistrationRequest.cs new file mode 100644 index 00000000..cf2f93da --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Request/RegistrationRequest.cs @@ -0,0 +1,19 @@ + + +using System.ComponentModel.DataAnnotations; +using api_cinema_challenge.Enums; + + +public class RegistrationRequest +{ + [Required] + public string? Email { get; set; } + + [Required] + public string? Username { get { return this.Email; } set { } } + + [Required] + public string? Password { get; set; } + + public Role Role { get; set; } = Role.User; +} \ No newline at end of file diff --git a/api-cinema-challenge/api-cinema-challenge/DataTransfer/Response/AuthResponse.cs b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Response/AuthResponse.cs new file mode 100644 index 00000000..7b448154 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Response/AuthResponse.cs @@ -0,0 +1,9 @@ +namespace api_cinema_challenge.DataTransfer.Response; + + +public class AuthResponse +{ + public string? Username { get; set; } + public string? Email { get; set; } + public string? Token { get; set; } +} \ No newline at end of file diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/CinemaEndpoint.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/CinemaEndpoint.cs new file mode 100644 index 00000000..d006dcb1 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/CinemaEndpoint.cs @@ -0,0 +1,126 @@ +using api_cinema_challenge.DTOs; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repository; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection.Repositories; +using System.Runtime.CompilerServices; + +namespace api_cinema_challenge.Endpoints +{ + public static class CinemaEndpoint + { + public static void ConfigureCinemaEndpoint(this WebApplication app) + { + app.MapGet("/customers", GetCustomers); + app.MapPost("/customers", CreateCustomer); + app.MapPut("/customers/{id}", UpdateCustomer); + app.MapDelete("/customers/{id}", DeleteCustomer); + + app.MapGet("/movies", GetMovies); + app.MapPost("/movies", CreateMovie); + app.MapPut("/movies/{id}", UpdateMovie); + app.MapDelete("/movies/{id}", DeleteMovie); + + app.MapGet("/movies/{movieId}/screenings", GetScreenings); + app.MapPost("/movies/{movieId}/screenings", CreateScreening); + + } + + [Authorize] + private static async Task CreateScreening(IRepository repository, int movieId, ScreeningPost screeningPost) + { + var entity = await repository.CreateScreening(movieId, screeningPost); + if (entity == null) return TypedResults.BadRequest(); + ScreeningGet result = ScreeningFactory.NewScreeningGet(entity); + return TypedResults.Created($"movies/{movieId}/screenings/{entity.Id}", result); + } + [Authorize] + private static async Task GetScreenings(IRepository repository, int movieId) + { + var response = await repository.GetScreenings(movieId); + List results = new List(); + foreach (var item in response) + { + results.Add(ScreeningFactory.NewScreeningGet(item)); + } + return TypedResults.Ok(results); + } + [Authorize] + private static async Task DeleteMovie(IRepository repository, int id) + { + var entity = await repository.DeleteMovie(id); + return TypedResults.Ok(entity); + } + [Authorize] + private static async Task UpdateMovie(IRepository repository, int id, MoviePut movie) + { + var entity = await repository.UpdateMovie(id, movie); + return TypedResults.Created($"/customers/{id}", entity); + } + [Authorize] + private static async Task CreateMovie(IRepository repository, MoviePost movie) + { + var entity = await repository.CreateMovie(movie); + if (entity == null) return TypedResults.BadRequest(); + MovieGet result = MovieFactory.NewMovieGet(entity); + return TypedResults.Created($"/movies/{entity.Id}", result); + } + [Authorize] + private static async Task GetMovies(IRepository repository) + { + var response = await repository.GetMovies(); + List results = new List(); + foreach (var item in response) + { + results.Add(MovieFactory.NewMovieGet(item)); + } + return TypedResults.Ok(results); + } + [Authorize] + private static async Task DeleteCustomer(IRepository repository, int id) + { + var entity = await repository.DeleteCustomer(id); + return TypedResults.Ok(entity); + } + [Authorize] + private static async Task UpdateCustomer(IRepository repository, int id, CustomerPut customer) + { + var entity = await repository.UpdateCustomer(id, customer); + return TypedResults.Created($"/customers/{id}", entity); + } + [Authorize] + private static async Task CreateCustomer(IRepository repository, CustomerPost customer) + { + var entity = await repository.CreateCustomer(customer); + if (entity == null) return TypedResults.BadRequest(); + CustomerGet result = new CustomerGet() + { + Id = entity.Id, + Name = entity.Name, + Email = entity.Email, + Phone = entity.Phone, + CreatedAt = entity.CreatedAt, + UpdatedAt = entity.UpdatedAt, + }; + return TypedResults.Created($"/customers/{entity.Id}", result); + } + [Authorize] + private static async Task GetCustomers(IRepository repository) + { + var response = await repository.GetCustomers(); + List results = new List(); + foreach (var item in response) + { + CustomerGet customer = new CustomerGet(); + customer.Id = item.Id; + customer.Name = item.Name; + customer.Email = item.Email; + customer.Phone = item.Phone; + customer.CreatedAt = item.CreatedAt; + customer.UpdatedAt = item.UpdatedAt; + results.Add(customer); + } + return TypedResults.Ok(results); + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Enums/Role.cs b/api-cinema-challenge/api-cinema-challenge/Enums/Role.cs new file mode 100644 index 00000000..b1211cdb --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Enums/Role.cs @@ -0,0 +1,7 @@ +namespace api_cinema_challenge.Enums; + +public enum Role +{ + Admin, + User +} \ No newline at end of file diff --git a/api-cinema-challenge/api-cinema-challenge/Helpers/ClaimsPrincipalHelper.cs b/api-cinema-challenge/api-cinema-challenge/Helpers/ClaimsPrincipalHelper.cs new file mode 100644 index 00000000..ce30c25a --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Helpers/ClaimsPrincipalHelper.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.Runtime.CompilerServices; +using System.Security.Claims; + +namespace api_cinema_challenge +{ + public static class ClaimsPrincipalHelper + { + public static string? UserId(this ClaimsPrincipal user) + { + Claim? claim = user.FindFirst(ClaimTypes.NameIdentifier); + return claim?.Value; + } + public static string? Email(this ClaimsPrincipal user) + { + Claim? claim = user.FindFirst(ClaimTypes.Email); + return claim?.Value; + } + + // public static string? UserId(this IIdentity identity) + // { + // if (identity != null && identity.IsAuthenticated) + // { + // // return Guid.Parse(((ClaimsIdentity)identity).Claims.Where(x => x.Type == "NameIdentifier").FirstOrDefault()!.Value); + // return ((ClaimsIdentity)identity).Claims.Where(x => x.Type == "NameIdentifier").FirstOrDefault()!.Value; + // } + // return null; + // } + } +} \ No newline at end of file diff --git a/api-cinema-challenge/api-cinema-challenge/Models/ApplicationUser.cs b/api-cinema-challenge/api-cinema-challenge/Models/ApplicationUser.cs new file mode 100644 index 00000000..ff960706 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/ApplicationUser.cs @@ -0,0 +1,10 @@ +using api_cinema_challenge.Enums; +using Microsoft.AspNetCore.Identity; + + +namespace api_cinema_challenge.DataModels; + +public class ApplicationUser : IdentityUser +{ + public Role Role { get; set; } +} \ No newline at end of file diff --git a/api-cinema-challenge/api-cinema-challenge/Models/Customer.cs b/api-cinema-challenge/api-cinema-challenge/Models/Customer.cs new file mode 100644 index 00000000..3ac1f73d --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Customer.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace api_cinema_challenge.Models +{ + [Table("customers")] + public class Customer + { + [Key] + public int Id { get; set; } + public string Name { get; set; } + public string Email { get; set; } + public string Phone { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs b/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs new file mode 100644 index 00000000..1c4779dc --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace api_cinema_challenge.Models +{ + [Table("movies")] + public class Movie + { + [Key] + public int Id { get; set; } + public string Title { get; set; } + public string Rating { get; set; } + public string Description { get; set; } + public int RuntimeMins { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public ICollection Screenings { get; set; } = new List(); + + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Models/Screening.cs b/api-cinema-challenge/api-cinema-challenge/Models/Screening.cs new file mode 100644 index 00000000..790ee18a --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Screening.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace api_cinema_challenge.Models +{ + [Table("screenings")] + public class Screening + { + public int Id { get; set; } + public int MovieId { get; set; } + public Movie Movie { get; set; } + public int ScreenNumber { get; set; } + public int Capacity { get; set; } + public DateTime StartsAt { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Program.cs b/api-cinema-challenge/api-cinema-challenge/Program.cs index e55d9d54..18d256ea 100644 --- a/api-cinema-challenge/api-cinema-challenge/Program.cs +++ b/api-cinema-challenge/api-cinema-challenge/Program.cs @@ -1,11 +1,113 @@ +using System.Text; +using System.Text.Json.Serialization; + +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using Microsoft.Extensions.Options; +using System.Diagnostics; + using api_cinema_challenge.Data; +using api_cinema_challenge.Endpoints; +using api_cinema_challenge.Repository; +using api_cinema_challenge.Services; +using api_cinema_challenge.DataModels; + var builder = WebApplication.CreateBuilder(args); // Add services to the container. +builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); + +builder.Services.AddSwaggerGen(option => +{ + option.SwaggerDoc("v1", new OpenApiInfo { Title = "Test API", Version = "v1" }); + option.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Header, + Description = "Please enter a valid token", + Name = "Authorization", + Type = SecuritySchemeType.Http, + BearerFormat = "JWT", + Scheme = "Bearer" + }); + option.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + new string[] { } + } + }); +}); + +builder.Services.AddProblemDetails(); +builder.Services.AddRouting(options => options.LowercaseUrls = true); builder.Services.AddDbContext(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Support string to enum conversions +builder.Services.AddControllers().AddJsonOptions(opt => +{ + opt.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); +}); + + +// Specify identity requirements +// Must be added before .AddAuthentication otherwise a 404 is thrown on authorized endpoints +builder.Services + .AddIdentity(options => + { + options.SignIn.RequireConfirmedAccount = false; + options.User.RequireUniqueEmail = true; + options.Password.RequireDigit = false; + options.Password.RequiredLength = 6; + options.Password.RequireNonAlphanumeric = false; + options.Password.RequireUppercase = false; + }) + .AddRoles() + .AddEntityFrameworkStores(); + + +// These will eventually be moved to a secrets file, but for alpha development appsettings is fine +var validIssuer = builder.Configuration.GetValue("JwtTokenSettings:ValidIssuer"); +var validAudience = builder.Configuration.GetValue("JwtTokenSettings:ValidAudience"); +var symmetricSecurityKey = builder.Configuration.GetValue("JwtTokenSettings:SymmetricSecurityKey"); + +builder.Services.AddAuthentication(options => +{ + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; +}) + .AddJwtBearer(options => + { + options.IncludeErrorDetails = true; + options.TokenValidationParameters = new TokenValidationParameters() + { + ClockSkew = TimeSpan.Zero, + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = validIssuer, + ValidAudience = validAudience, + IssuerSigningKey = new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(symmetricSecurityKey) + ), + }; + }); + var app = builder.Build(); @@ -17,4 +119,12 @@ } app.UseHttpsRedirection(); +app.UseStatusCodePages(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.ConfigureCinemaEndpoint(); + +app.MapControllers(); app.Run(); diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs new file mode 100644 index 00000000..11cf79f0 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs @@ -0,0 +1,19 @@ +using api_cinema_challenge.DTOs; +using api_cinema_challenge.Models; + +namespace api_cinema_challenge.Repository +{ + public interface IRepository + { + public Task> GetCustomers(); + public Task CreateCustomer(CustomerPost customer); + public Task UpdateCustomer(int id, CustomerPut customer); + public Task DeleteCustomer(int id); + public Task> GetMovies(); + public Task CreateMovie(MoviePost movie); + public Task UpdateMovie(int id, MoviePut movie); + public Task DeleteMovie(int id); + public Task> GetScreenings(int movieId); + public Task CreateScreening(int movieId, ScreeningPost screening); + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs new file mode 100644 index 00000000..53219cc2 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs @@ -0,0 +1,103 @@ +using Microsoft.EntityFrameworkCore; +using api_cinema_challenge.Data; +using api_cinema_challenge.DTOs; +using api_cinema_challenge.Models; +using api_cinema_challenge.DataModels; + +namespace api_cinema_challenge.Repository +{ + public class Repository: IRepository + { + private CinemaContext _databaseContext; + + public Repository(CinemaContext db) + { + _databaseContext = db; + } + + public async Task CreateCustomer(CustomerPost customer) + { + var newCustomer = new Customer() { Name = customer.Name, Email = customer.Email, Phone = customer.Phone, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; + await _databaseContext.Customers.AddAsync(newCustomer); + await _databaseContext.SaveChangesAsync(); + return newCustomer; + } + + public async Task CreateMovie(MoviePost movie) + { + var newMovie = MovieFactory.NewMovie(movie); + await _databaseContext.Movies.AddAsync(newMovie); + await _databaseContext.SaveChangesAsync(); + return newMovie; + } + + public async Task CreateScreening(int movieId, ScreeningPost screening) + { + var newScreening = ScreeningFactory.NewScreening(movieId, screening); + await _databaseContext.Screenings.AddAsync(newScreening); + await _databaseContext.SaveChangesAsync(); + return newScreening; + } + + public async Task DeleteCustomer(int id) + { + var customer = await _databaseContext.Customers.Where(x => x.Id == id).FirstOrDefaultAsync(); + if (customer == null) return null; + _databaseContext.Customers.Remove(customer); + await _databaseContext.SaveChangesAsync(); + return customer; + } + + public async Task DeleteMovie(int id) + { + var movie = await _databaseContext.Movies.Where(x => x.Id == id).FirstOrDefaultAsync(); + if (movie == null) return null; + _databaseContext.Movies.Remove(movie); + await _databaseContext.SaveChangesAsync(); + return movie; + } + + public async Task> GetCustomers() + { + return await _databaseContext.Customers.ToListAsync(); + } + + public async Task> GetMovies() + { + return await _databaseContext.Movies.ToListAsync(); + } + + public async Task> GetScreenings(int movieId) + { + var movie = await _databaseContext.Movies.Where(m => m.Id == movieId).Include(m => m.Screenings).FirstOrDefaultAsync(); + if (movie == null) return null; + return movie.Screenings; + } + + public async Task UpdateCustomer(int id, CustomerPut customerPut) + { + var customer = await _databaseContext.Customers.Where(c => c.Id == id).FirstOrDefaultAsync(); + if (customer == null) return null; + if (customerPut.Name is not null) customer.Name = customerPut.Name; + if (customerPut.Email is not null) customer.Email = customerPut.Email; + if (customerPut.Phone is not null) customer.Phone = customerPut.Phone; + customer.UpdatedAt = DateTime.UtcNow; + await _databaseContext.SaveChangesAsync(); + return customer; + + } + + public async Task UpdateMovie(int id, MoviePut moviePut) + { + var movie = await _databaseContext.Movies.Where(m => m.Id == id).FirstOrDefaultAsync(); + if (movie == null) return null; + if (movie.Title is not null) movie.Title = moviePut.Title; + if (movie.Rating is not null) movie.Rating = moviePut.Rating; + if (movie.Description is not null) movie.Description = moviePut.Description; + if (movie.RuntimeMins != 0 && movie.RuntimeMins != moviePut.RuntimeMins) movie.RuntimeMins = moviePut.RuntimeMins; + movie.UpdatedAt = DateTime.UtcNow; + await _databaseContext.SaveChangesAsync(); + return movie; + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Services/TokenService.cs b/api-cinema-challenge/api-cinema-challenge/Services/TokenService.cs new file mode 100644 index 00000000..fcde6618 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Services/TokenService.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Http.HttpResults; + +namespace api_cinema_challenge.Services; + +using System.IdentityModel.Tokens.Jwt; +using Microsoft.IdentityModel.Tokens; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Identity; +using api_cinema_challenge.DataModels; + +public class TokenService +{ + private const int ExpirationMinutes = 60; + private readonly ILogger _logger; + public TokenService(ILogger logger) + { + _logger = logger; + } + + public string CreateToken(ApplicationUser user) + { + + var expiration = DateTime.UtcNow.AddMinutes(ExpirationMinutes); + var token = CreateJwtToken( + CreateClaims(user), + CreateSigningCredentials(), + expiration + ); + var tokenHandler = new JwtSecurityTokenHandler(); + + _logger.LogInformation("JWT Token created"); + + return tokenHandler.WriteToken(token); + } + + private JwtSecurityToken CreateJwtToken(List claims, SigningCredentials credentials, + DateTime expiration) => + new( + new ConfigurationBuilder().AddJsonFile("appsettings.json").Build().GetSection("JwtTokenSettings")["ValidIssuer"], + new ConfigurationBuilder().AddJsonFile("appsettings.json").Build().GetSection("JwtTokenSettings")["ValidAudience"], + claims, + expires: expiration, + signingCredentials: credentials + ); + + private List CreateClaims(ApplicationUser user) + { + var jwtSub = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build().GetSection("JwtTokenSettings")["JwtRegisteredClaimNamesSub"]; + + try + { + var claims = new List + { + new Claim(JwtRegisteredClaimNames.Sub, jwtSub), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()), + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(ClaimTypes.Name, user.UserName), + new Claim(ClaimTypes.Email, user.Email), + new Claim(ClaimTypes.Role, user.Role.ToString()) + }; + + return claims; + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } + + private SigningCredentials CreateSigningCredentials() + { + var symmetricSecurityKey = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build().GetSection("JwtTokenSettings")["SymmetricSecurityKey"]; + + return new SigningCredentials( + new SymmetricSecurityKey( + Encoding.UTF8.GetBytes(symmetricSecurityKey) + ), + SecurityAlgorithms.HmacSha256 + ); + } +} \ No newline at end of file diff --git a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj index 11e5c66b..bc1bc9d3 100644 --- a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj +++ b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -8,15 +8,14 @@ - - - - - - - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + all @@ -27,8 +26,4 @@ - - - -