diff --git a/.gitignore b/.gitignore index cf332414..608ac5c0 100644 --- a/.gitignore +++ b/.gitignore @@ -362,6 +362,8 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd +Migrations/ + */**/appsettings.json */**/appsettings.Development.json */**/bin/Debug diff --git a/api-cinema-challenge/api-cinema-challenge.sln b/api-cinema-challenge/api-cinema-challenge.sln index 9cd490f5..c9e14d71 100644 --- a/api-cinema-challenge/api-cinema-challenge.sln +++ b/api-cinema-challenge/api-cinema-challenge.sln @@ -8,6 +8,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3C371BAA-344D-4C8A-AF08-7829816D726F}" ProjectSection(SolutionItems) = preProject ..\.gitignore = ..\.gitignore + api-cinema-challenge\Data\Cinema ERD.png = api-cinema-challenge\Data\Cinema ERD.png ..\README.md = ..\README.md EndProjectSection EndProject 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..19fce7f7 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Controllers/UserController.cs @@ -0,0 +1,100 @@ +using api_cinema_challenge.Models; +using api_cinema_challenge.Models.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, + }); + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Data/Cinema ERD.png b/api-cinema-challenge/api-cinema-challenge/Data/Cinema ERD.png new file mode 100644 index 00000000..b2d1703c Binary files /dev/null and b/api-cinema-challenge/api-cinema-challenge/Data/Cinema ERD.png differ diff --git a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs index ad4fe854..946f001e 100644 --- a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs +++ b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs @@ -1,9 +1,12 @@ using Microsoft.EntityFrameworkCore; using Newtonsoft.Json.Linq; +using api_cinema_challenge.Models; +using System.Security.Claims; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; namespace api_cinema_challenge.Data { - public class CinemaContext : DbContext + public class CinemaContext : IdentityUserContext { private string _connectionString; public CinemaContext(DbContextOptions options) : base(options) @@ -20,7 +23,18 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder) { + Seeder seeder = new Seeder(); + modelBuilder.Entity().HasData(seeder.Customers); + modelBuilder.Entity().HasData(seeder.Movies); + modelBuilder.Entity().HasData(seeder.Screenings); + modelBuilder.Entity().HasData(seeder.Tickets); + base.OnModelCreating(modelBuilder); } + public DbSet Customers { get; set; } + public DbSet Movies { get; set; } + public DbSet Screenings { get; set; } + public DbSet Tickets { get; set; } + } } diff --git a/api-cinema-challenge/api-cinema-challenge/Data/Seeder.cs b/api-cinema-challenge/api-cinema-challenge/Data/Seeder.cs new file mode 100644 index 00000000..857b425a --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Data/Seeder.cs @@ -0,0 +1,145 @@ +using api_cinema_challenge.Models; +using api_cinema_challenge.Models.Enums; + +namespace api_cinema_challenge.Data +{ + public class Seeder + { + private List _firstNames = new List() + { + "Audrey", + "Donald", + "Elvis", + "Barack", + "Oprah", + "Jimi", + "Mick", + "Kate", + "Charles", + "Kate" + }; + + private List _lastNames = new List() + { + "Hepburn", + "Trump", + "Presley", + "Obama", + "Winfrey", + "Hendrix", + "Jagger", + "Winslet", + "Windsor", + "Middleton" + }; + + private List _domains = new List() + { + "gmail.com", + "google.com", + "hotmail.com", + "something.com", + "mcdonalds.com", + "nasa.org.us", + "gov.us", + "gov.gr", + "gov.nl", + "gov.ru" + }; + + private List _movieTitles = new List() + { + "The Lost Kingdom", "Space Odyssey", "Dreamcatcher", + "Ocean Deep", "Hidden Truths", "Shadows Rising", + "Eternal Flame", "The Great Escape", "Parallel Worlds", "Infinite Loop" + }; + + private List _descriptions = new List() + { + "An epic adventure across unknown lands.", + "A thrilling journey through space and time.", + "A heartwarming story of friendship and courage.", + "A suspenseful drama filled with mystery.", + "A hilarious comedy for the whole family.", + "A dark tale of betrayal and survival." + }; + + private List _customers = new List(); + private List _movies = new List(); + private List _screenings = new List(); + private List _tickets = new List(); + + public Seeder() + { + Random random = new Random(); + + for (int x = 1; x < 50; x++) + { + var first = _firstNames[random.Next(_firstNames.Count)]; + var last = _lastNames[random.Next(_lastNames.Count)]; + var domain = _domains[random.Next(_domains.Count)]; + + Customer customer = new Customer + { + Id = x, + Name = $"{first} {last}", + Email = $"{first}{last}@{domain}", + Phone = $"06{random.Next(1000, 9999)}{random.Next(1000, 9999)}" + }; + _customers.Add(customer); + } + + for (int y = 1; y < 50; y++) + { + Movie movie = new Movie + { + Id = y, + Title = _movieTitles[random.Next(_movieTitles.Count)], + Rating = (MovieRating)random.Next(Enum.GetNames(typeof(MovieRating)).Length), + Description = _descriptions[random.Next(_descriptions.Count)], + RuntimeMins = random.Next(90, 180) + }; + _movies.Add(movie); + } + + foreach (var movie in _movies) + { + int screeningsAmount = random.Next(1, 5); + for (int z = 0; z < screeningsAmount; z++) + { + Screening screening = new Screening + { + Id = z, + MovieId = movie.Id, + ScreenNumber = random.Next(1, 5), + Capacity = random.Next(20, 60), + startsAt = DateTime.UtcNow.AddDays(random.Next(1, 15)) + }; + } + } + + foreach (var screening in _screenings) + { + int ticketsAmount = random.Next(1, 60); + for (int a = 0; a < ticketsAmount; a++) + { + var customer = _customers[random.Next(_customers.Count)]; + + Ticket ticket = new Ticket + { + Id = a, + NumSeats = random.Next(1, 5), + ScreeningId = screening.Id, + CustomerId = customer.Id + }; + _tickets.Add(ticket); + } + } + } + + public List Customers { get { return _customers; } } + public List Movies { get { return _movies; } } + public List Screenings { get { return _screenings; } } + public List Tickets { get { return _tickets; } } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/AuthRequest.cs b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/AuthRequest.cs new file mode 100644 index 00000000..606db053 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/AuthRequest.cs @@ -0,0 +1,13 @@ +namespace api_cinema_challenge.DataTransfer.Requests +{ + public class AuthRequest + { + public string? Email { get; set; } + public string? Password { get; set; } + + public bool IsValid() + { + return true; + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/CustomerPostRequest.cs b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/CustomerPostRequest.cs new file mode 100644 index 00000000..f29f791e --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/CustomerPostRequest.cs @@ -0,0 +1,9 @@ +namespace api_cinema_challenge.DataTransfer.Requests +{ + public class CustomerPostRequest + { + public string Name { get; set; } + public string Email { get; set; } + public string Phone { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/MoviePostRequest.cs b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/MoviePostRequest.cs new file mode 100644 index 00000000..699624af --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/MoviePostRequest.cs @@ -0,0 +1,15 @@ +using api_cinema_challenge.Models; +using api_cinema_challenge.Models.Enums; +using Npgsql.PostgresTypes; + +namespace api_cinema_challenge.DataTransfer.Requests +{ + public class MoviePostRequest + { + public string Title { get; set; } + public MovieRating Rating { get; set; } + public string Description { get; set; } + public int RuntimeMins { get; set; } + public List screenings { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/RegistrationRequest.cs b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/RegistrationRequest.cs new file mode 100644 index 00000000..3a26f463 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/RegistrationRequest.cs @@ -0,0 +1,19 @@ +using api_cinema_challenge.Models.Enums; +using System.ComponentModel.DataAnnotations; + +namespace api_cinema_challenge.DataTransfer.Requests +{ + 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; + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/ScreeningPostRequest.cs b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/ScreeningPostRequest.cs new file mode 100644 index 00000000..168762be --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/ScreeningPostRequest.cs @@ -0,0 +1,12 @@ +using api_cinema_challenge.Models; +using Npgsql.PostgresTypes; + +namespace api_cinema_challenge.DataTransfer.Requests +{ + public class ScreenPostRequest + { + public int ScreenNumber { get; set; } + public int Capacity { get; set; } + public DateTime StartsAt { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/TicketPostRequest.cs b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/TicketPostRequest.cs new file mode 100644 index 00000000..50df326c --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DataTransfer/Requests/TicketPostRequest.cs @@ -0,0 +1,7 @@ +namespace api_cinema_challenge.DataTransfer.Requests +{ + public class TicketPostRequest + { + public int NumSeats { get; set; } + } +} 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..b334a5a6 --- /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; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs new file mode 100644 index 00000000..5b0bbead --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoints.cs @@ -0,0 +1,79 @@ +using api_cinema_challenge.DataTransfer.Requests; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repository; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace api_cinema_challenge.Endpoints +{ + public static class CustomerEndpoints + { + public static void ConfigureCustomerEndpoint(this WebApplication app) + { + var customers = app.MapGroup("/customers"); + + customers.MapPost("/", Create); + customers.MapGet("/", GetAll); + customers.MapPut("/{id}", Update); + customers.MapDelete("/{id}", Delete); + } + + [Authorize(Roles = "Admin")] + [ProducesResponseType(StatusCodes.Status201Created)] + public static async Task Create(IRepository repository, CustomerPostRequest customer) + { + var newCustomer = new Customer() + { + Name = customer.Name, + Email = customer.Email, + Phone = customer.Phone, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + await repository.Create(newCustomer); + return TypedResults.Created($"/{newCustomer.Id}", newCustomer); + } + + [Authorize(Roles = "User")] + [ProducesResponseType(StatusCodes.Status200OK)] + public static async Task GetAll(IRepository repository) + { + var entities = await repository.GetAll(); + List results = new List(); + + foreach (var entity in entities) + { + results.Add(new { Name = entity.Name, Email = entity.Email, Phone = entity.Phone }); + } + + return TypedResults.Ok(results); + } + + [Authorize(Roles = "Admin")] + [ProducesResponseType(StatusCodes.Status200OK)] + public static async Task Update(IRepository repository, int id, CustomerPostRequest customer) + { + var entity = await repository.GetById(id); + + entity.Name = !string.IsNullOrEmpty(customer.Name) ? customer.Name : entity.Name; + entity.Phone = !string.IsNullOrEmpty(customer.Phone) ? customer.Phone : entity.Phone; + entity.Email = !string.IsNullOrEmpty(customer.Email) ? customer.Email : entity.Email; + entity.UpdatedAt = DateTime.UtcNow; + + var result = await repository.Update(entity); + + return result != null ? TypedResults.Ok(new { Name = result.Name, Phone = result.Phone, Email = result.Email ,UpdatedAt = result.UpdatedAt}) : TypedResults.BadRequest("Couldn't save to the database?!"); + } + + [Authorize(Roles = "Admin")] + [ProducesResponseType(StatusCodes.Status200OK)] + public static async Task Delete(IRepository repository, int id) + { + var entity = await repository.GetById(id); + var result = await repository.Delete(entity); + + return result != null ? TypedResults.Ok(new { Name = result.Name, Phone = result.Phone, Email = result.Email, UpdatedAt = result.UpdatedAt }) : TypedResults.BadRequest("Object wasnt deleted?!"); + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs new file mode 100644 index 00000000..e73b30be --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoints.cs @@ -0,0 +1,103 @@ +using api_cinema_challenge.DataTransfer.Requests; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repository; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace api_cinema_challenge.Endpoints +{ + public static class MovieEndpoints + { + public static void ConfigureMovieEndpoint(this WebApplication app) + { + var movies = app.MapGroup("/movies"); + + movies.MapPost("/", Create); + movies.MapGet("/", GetAll); + movies.MapPut("/{id}", Update); + movies.MapDelete("/{id}", Delete); + } + + [Authorize(Roles = "Admin")] + [ProducesResponseType(StatusCodes.Status201Created)] + public static async Task Create(IRepository repository, MoviePostRequest movie) + { + var newMovie = new Movie() + { + Title = movie.Title, + Rating = movie.Rating, + Description = movie.Description, + RuntimeMins = movie.RuntimeMins, + Screenings = movie.screenings, + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now + }; + + await repository.Create(newMovie); + return TypedResults.Created($"/{newMovie.Id}", newMovie); + } + + [Authorize(Roles = "User")] + [ProducesResponseType(StatusCodes.Status200OK)] + public static async Task GetAll(IRepository repository) + { + var entities = await repository.GetAll(); + List results = new List(); + + foreach (var entity in entities) + { + results.Add(new { + Title = entity.Title, + Rating = entity.Rating, + Description = entity.Description, + RuntimeMins = entity.RuntimeMins, + Screenings = entity.Screenings + }); + } + + return TypedResults.Ok(results); + } + + [Authorize(Roles = "Admin")] + [ProducesResponseType(StatusCodes.Status200OK)] + public static async Task Update(IRepository repository, int id, MoviePostRequest movie) + { + var entity = await repository.GetById(id); + + entity.Title = !string.IsNullOrEmpty(movie.Title) ? movie.Title : entity.Title; + entity.Rating = movie.Rating; + entity.Description = !string.IsNullOrEmpty(movie.Description) ? movie.Description : entity.Description; + entity.RuntimeMins = movie.RuntimeMins; + entity.Screenings = movie.screenings; + entity.UpdatedAt = DateTime.UtcNow; + + var result = await repository.Update(entity); + + return result != null ? TypedResults.Ok(new { + Title = entity.Title, + Rating = entity.Rating, + Description = entity.Description, + RuntimeMins = entity.RuntimeMins, + Screenings = entity.Screenings, + UpdatedAt = result.UpdatedAt + }) : TypedResults.BadRequest("Couldn't save to the database?!"); + } + + [Authorize(Roles = "Admin")] + [ProducesResponseType(StatusCodes.Status200OK)] + public static async Task Delete(IRepository repository, int id) + { + var entity = await repository.GetById(id); + var result = await repository.Delete(entity); + + return result != null ? TypedResults.Ok(new { + Title = entity.Title, + Rating = entity.Rating, + Description = entity.Description, + RuntimeMins = entity.RuntimeMins, + Screenings = entity.Screenings, + UpdatedAt = result.UpdatedAt + }) : TypedResults.BadRequest("Object wasnt deleted?!"); + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/ScreeningEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/ScreeningEndpoints.cs new file mode 100644 index 00000000..fecfe0b9 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/ScreeningEndpoints.cs @@ -0,0 +1,58 @@ +using api_cinema_challenge.DataTransfer.Requests; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repository; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace api_cinema_challenge.Endpoints +{ + public static class ScreeningEndpoints + { + public static void ConfigureScreeningEndpoint(this WebApplication app) + { + var screenings = app.MapGroup("/screenings"); + + screenings.MapPost("/", Create); + screenings.MapGet("/", GetAll); + } + + [Authorize(Roles = "Admin")] + [ProducesResponseType(StatusCodes.Status201Created)] + public static async Task Create(IRepository repository, ScreenPostRequest screening, int movieId) + { + var newScreening = new Screening() + { + ScreenNumber = screening.ScreenNumber, + Capacity = screening.Capacity, + startsAt = screening.StartsAt, + MovieId = movieId, + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now + }; + + await repository.Create(newScreening); + return TypedResults.Created($"/{newScreening.Id}", newScreening); + } + + [Authorize(Roles = "User")] + [ProducesResponseType(StatusCodes.Status200OK)] + public static async Task GetAll(IRepository repository) + { + var entities = await repository.GetAll(); + List results = new List(); + + foreach (var entity in entities) + { + results.Add(new + { + ScreenNumber = entity.ScreenNumber, + Capacity = entity.Capacity, + StartsAt = entity.startsAt, + MovieId = entity.MovieId + }); + } + + return TypedResults.Ok(results); + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/TicketEndpoints.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/TicketEndpoints.cs new file mode 100644 index 00000000..278898c9 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/TicketEndpoints.cs @@ -0,0 +1,56 @@ +using api_cinema_challenge.DataTransfer.Requests; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repository; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace api_cinema_challenge.Endpoints +{ + public static class TicketEndpoints + { + public static void ConfigureTicketEndpoints(this WebApplication app) + { + var screenings = app.MapGroup("/tickets"); + + screenings.MapPost("/", Create); + screenings.MapGet("/", GetAll); + } + + [Authorize(Roles = "Admin")] + [ProducesResponseType(StatusCodes.Status201Created)] + public static async Task Create(IRepository repository, TicketPostRequest ticket, int customerId, int screeningId) + { + var newTicket = new Ticket() + { + NumSeats = ticket.NumSeats, + CustomerId = customerId, + ScreeningId = screeningId, + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now + }; + + await repository.Create(newTicket); + return TypedResults.Created($"/{newTicket.Id}", newTicket); + } + + [Authorize(Roles = "User")] + [ProducesResponseType(StatusCodes.Status200OK)] + public static async Task GetAll(IRepository repository) + { + var entities = await repository.GetAll(); + List results = new List(); + + foreach (var entity in entities) + { + results.Add(new + { + NumSeats = entity.NumSeats, + CustomerId = entity.CustomerId, + ScreeningId = entity.ScreeningId, + }); + } + + return TypedResults.Ok(results); + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Helpers/ClaimsPrincipalHelpers.cs b/api-cinema-challenge/api-cinema-challenge/Helpers/ClaimsPrincipalHelpers.cs new file mode 100644 index 00000000..3d4c0468 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Helpers/ClaimsPrincipalHelpers.cs @@ -0,0 +1,28 @@ +using System.Security.Claims; + +namespace api_cinema_challenge.Helpers +{ + 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; + // } + } +} 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..3dbf15b5 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/ApplicationUser.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Identity; +using api_cinema_challenge.Models.Enums; + +namespace api_cinema_challenge.Models +{ + public class ApplicationUser : IdentityUser + { + public Role Role { get; set; } + } +} 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..b17d95e4 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Customer.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace api_cinema_challenge.Models +{ + [Table("customers")] + public class Customer : ParentModel + { + [Key] + [Column("id")] + public int Id { get; set; } + + [Column("name")] + public string Name { get; set; } + + [Column("email")] + public string Email { get; set; } + + [Column("phone")] + public string Phone { get; set; } + + public List Tickets { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Models/Enums/MovieRating.cs b/api-cinema-challenge/api-cinema-challenge/Models/Enums/MovieRating.cs new file mode 100644 index 00000000..008ea3ba --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Enums/MovieRating.cs @@ -0,0 +1,11 @@ +namespace api_cinema_challenge.Models.Enums +{ + public enum MovieRating + { + G, + PG, + PG13, + R, + NC17 + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Models/Enums/Role.cs b/api-cinema-challenge/api-cinema-challenge/Models/Enums/Role.cs new file mode 100644 index 00000000..45bfa19e --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Enums/Role.cs @@ -0,0 +1,8 @@ +namespace api_cinema_challenge.Models.Enums +{ + public enum Role + { + Admin, + User + } +} 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..089c0fa0 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs @@ -0,0 +1,28 @@ +using api_cinema_challenge.Models.Enums; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace api_cinema_challenge.Models +{ + [Table("movies")] + public class Movie : ParentModel + { + [Key] + [Column("id")] + public int Id { get; set; } + + [Column("title")] + public string Title { get; set; } + + [Column("rating")] + public MovieRating Rating { get; set; } + + [Column("description")] + public string Description { get; set; } + + [Column("runtimemins")] + public int RuntimeMins { get; set; } + + public List Screenings { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Models/ParentModel.cs b/api-cinema-challenge/api-cinema-challenge/Models/ParentModel.cs new file mode 100644 index 00000000..cf675a70 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/ParentModel.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace api_cinema_challenge.Models +{ + public class ParentModel + { + [Column("createdat")] + public DateTime CreatedAt { get; set; } + [Column("updatedat")] + public DateTime UpdatedAt { get; set; } + } +} 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..9cae32bc --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Screening.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace api_cinema_challenge.Models +{ + [Table("screenings")] + public class Screening : ParentModel + { + [Key] + [Column("id")] + public int Id { get; set; } + + [ForeignKey("movies")] + public int MovieId { get; set; } + + [Column("screennumber")] + public int ScreenNumber { get; set; } + + [Column("capacity")] + public int Capacity { get; set; } + + [Column("startsat")] + public DateTime startsAt { get; set; } + + public List Tickets { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Models/Ticket.cs b/api-cinema-challenge/api-cinema-challenge/Models/Ticket.cs new file mode 100644 index 00000000..de1fe745 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Ticket.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.ComponentModel.DataAnnotations; + +namespace api_cinema_challenge.Models +{ + [Table("tickets")] + public class Ticket : ParentModel + { + [Key] + [Column("id")] + public int Id { get; set; } + + [Column("numseats")] + public int NumSeats { get; set; } + + [ForeignKey("screenings")] + public int ScreeningId { get; set; } + + [ForeignKey("customers")] + public int CustomerId { get; set; } + + public Screening Screening { get; set; } + public Customer Customer { get; set; } + + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Program.cs b/api-cinema-challenge/api-cinema-challenge/Program.cs index e55d9d54..ed0265a3 100644 --- a/api-cinema-challenge/api-cinema-challenge/Program.cs +++ b/api-cinema-challenge/api-cinema-challenge/Program.cs @@ -1,11 +1,81 @@ using api_cinema_challenge.Data; +using api_cinema_challenge.Endpoints; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repository; +using api_cinema_challenge.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using System.Text; 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(typeof(IRepository<>), typeof(Repository<>)); +builder.Services.AddScoped(); + +// 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 +87,15 @@ } app.UseHttpsRedirection(); +app.UseStatusCodePages(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.ConfigureCustomerEndpoint(); +app.ConfigureMovieEndpoint(); +app.ConfigureScreeningEndpoint(); +app.ConfigureTicketEndpoints(); + +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..d0b3ec03 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repository/IRepository.cs @@ -0,0 +1,13 @@ +using api_cinema_challenge.Models; + +namespace api_cinema_challenge.Repository +{ + public interface IRepository + { + Task Create(T entity); + Task GetById(object id); + Task> GetAll(); + Task Update(T entity); + Task Delete(T entity); + } +} 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..2d2957cd --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repository/Repository.cs @@ -0,0 +1,47 @@ +using api_cinema_challenge.Data; +using Microsoft.EntityFrameworkCore; + +namespace api_cinema_challenge.Repository +{ + public class Repository : IRepository where T : class + { + private CinemaContext _db; + private DbSet _table = null; + public Repository(CinemaContext db) + { + _db = db; + _table = _db.Set(); + } + + public async Task Create(T entity) + { + _table.Add(entity); + await _db.SaveChangesAsync(); + return entity; + } + + public async Task GetById(object id) + { + return await _table.FindAsync(id); + } + + public async Task> GetAll() + { + return await _table.ToListAsync(); + } + + public async Task Update(T entity) + { + _table.Update(entity).State = EntityState.Modified; + await _db.SaveChangesAsync(); + return entity; + } + + public async Task Delete(T entity) + { + _db.Entry(entity).State = EntityState.Deleted; + await _db.SaveChangesAsync(); + return entity; + } + } +} 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..f10051e9 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Services/TokenService.cs @@ -0,0 +1,82 @@ +using api_cinema_challenge.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace api_cinema_challenge.Services; + +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..121cfb8c 100644 --- a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj +++ b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj @@ -8,13 +8,8 @@ - - - - - - - + + @@ -25,10 +20,11 @@ + - + diff --git a/api-cinema-challenge/api-cinema-challenge/appsettings.example.json b/api-cinema-challenge/api-cinema-challenge/appsettings.example.json deleted file mode 100644 index b9175fe6..00000000 --- a/api-cinema-challenge/api-cinema-challenge/appsettings.example.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "ConnectionStrings": { - "DefaultConnectionString": "Host=HOST; Database=DATABASE; Username=USERNAME; Password=PASSWORD;" - } -} \ No newline at end of file