diff --git a/.gitignore b/.gitignore index a72191d..bfcb692 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore # User-specific files *.rsuser @@ -9,7 +9,7 @@ *.user *.userosscache *.sln.docstates - +appsettings.json # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs @@ -29,6 +29,7 @@ x86/ bld/ [Bb]in/ [Oo]bj/ +[Oo]ut/ [Ll]og/ [Ll]ogs/ @@ -57,14 +58,11 @@ dlldata.c # Benchmark Results BenchmarkDotNet.Artifacts/ -# .NET +# .NET Core project.lock.json project.fragment.lock.json artifacts/ -# Tye -.tye/ - # ASP.NET Scaffolding ScaffoldingReadMe.txt @@ -93,7 +91,6 @@ StyleCopReport.xml *.tmp_proj *_wpftmp.csproj *.log -*.tlog *.vspscc *.vssscc .builds @@ -297,17 +294,6 @@ node_modules/ # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) *.vbw -# Visual Studio 6 auto-generated project file (contains which files were open etc.) -*.vbp - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts @@ -364,9 +350,6 @@ ASALocalRun/ # Local History for Visual Studio .localhistory/ -# Visual Studio History (VSHistory) files -.vshistory/ - # BeatPulse healthcheck temp database healthchecksdb @@ -379,106 +362,10 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# Local History for Visual Studio Code -.history/ - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp - -# JetBrains Rider -*.sln.iml - -## -## Visual studio for Mac -## - - -# globs -Makefile.in -*.userprefs -*.usertasks -config.make -config.status -aclocal.m4 -install-sh -autom4te.cache/ -*.tar.gz -tarballs/ -test-results/ - -# Mac bundle stuff -*.dmg -*.app - -# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore -# General -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore -# Windows thumbnail cache files -Thumbs.db -ehthumbs.db -ehthumbs_vista.db - -# Dump file -*.stackdump - -# Folder config file -[Dd]esktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msix -*.msm -*.msp - -# Windows shortcuts -*.lnk -/exercise.wwwapi/appsettings.json -/exercise.wwwapi/appsettings.Development.json - */**/bin/Debug +*/**/appsettings.json +*/**/appsettings.Development.json +*/**/bin/Debug */**/bin/Release */**/obj/Debug -*/**/obj/Release -exercise.wwwapi/Migrations \ No newline at end of file +*/**/obj/Release +/api-cinema-challenge/api-cinema-challenge/Migrations diff --git a/api-cinema-challenge/api-cinema-challenge.sln b/api-cinema-challenge/api-cinema-challenge.sln new file mode 100644 index 0000000..9cd490f --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.33516.290 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "api-cinema-challenge", "api-cinema-challenge\api-cinema-challenge.csproj", "{E407EDC9-D711-4E04-84D0-98D84EBE42D1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3C371BAA-344D-4C8A-AF08-7829816D726F}" + ProjectSection(SolutionItems) = preProject + ..\.gitignore = ..\.gitignore + ..\README.md = ..\README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E407EDC9-D711-4E04-84D0-98D84EBE42D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E407EDC9-D711-4E04-84D0-98D84EBE42D1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E407EDC9-D711-4E04-84D0-98D84EBE42D1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E407EDC9-D711-4E04-84D0-98D84EBE42D1}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {5BD9A95C-9AC8-4300-A9BD-FF464CACE65D} + EndGlobalSection +EndGlobal 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 0000000..837a07f --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Controllers/UserController.cs @@ -0,0 +1,99 @@ +using api_cinema_challenge.Data; +using api_cinema_challenge.DTOs.Auth; +using api_cinema_challenge.Enums; +using api_cinema_challenge.Models; +using api_cinema_challenge.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using System.Data; + +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/DTOs/Auth/AuthRequest.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/Auth/AuthRequest.cs new file mode 100644 index 0000000..a7fe06c --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/Auth/AuthRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using System.Data; + +namespace api_cinema_challenge.DTOs.Auth; + +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/DTOs/Auth/AuthResponse.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/Auth/AuthResponse.cs new file mode 100644 index 0000000..2507065 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/Auth/AuthResponse.cs @@ -0,0 +1,8 @@ +namespace api_cinema_challenge.DTOs.Auth; + +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/DTOs/Auth/RegistrationRequest.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/Auth/RegistrationRequest.cs new file mode 100644 index 0000000..266eff7 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/Auth/RegistrationRequest.cs @@ -0,0 +1,22 @@ +using api_cinema_challenge.Enums; +using System.ComponentModel.DataAnnotations; +using System.Data; + +namespace api_cinema_challenge.DTOs.Auth; + + + + +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/DTOs/Customer/CustomerDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/Customer/CustomerDto.cs new file mode 100644 index 0000000..ffea011 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/Customer/CustomerDto.cs @@ -0,0 +1,12 @@ +namespace api_cinema_challenge.DTOs.Customer +{ + public class CustomerDto + { + 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/Customer/CustomerPostDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/Customer/CustomerPostDto.cs new file mode 100644 index 0000000..d1927f5 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/Customer/CustomerPostDto.cs @@ -0,0 +1,9 @@ +namespace api_cinema_challenge.DTOs.Customer +{ + public class CustomerPostDto + { + public string Name { get; set; } + public string Email { get; set; } + public string Phone { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/Customer/CustomerPutDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/Customer/CustomerPutDto.cs new file mode 100644 index 0000000..ec0a434 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/Customer/CustomerPutDto.cs @@ -0,0 +1,9 @@ +namespace api_cinema_challenge.DTOs.Customer +{ + public class CustomerPutDto + { + public string? Name { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/Movie/MovieDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/Movie/MovieDto.cs new file mode 100644 index 0000000..ee8d83d --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/Movie/MovieDto.cs @@ -0,0 +1,15 @@ +using api_cinema_challenge.DTOs.Screening; + +namespace api_cinema_challenge.DTOs.Movie +{ + public class MovieDto + { + 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/Movie/MoviePostDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/Movie/MoviePostDto.cs new file mode 100644 index 0000000..35ab186 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/Movie/MoviePostDto.cs @@ -0,0 +1,13 @@ +using api_cinema_challenge.DTOs.Screening; + +namespace api_cinema_challenge.DTOs.Movie +{ + public class MoviePostDto + { + public string Title { get; set; } + public string Rating { get; set; } + public string Description { get; set; } + public int RuntimeMins { get; set; } + public ICollection Screenings { get; set; } = new List(); + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/Movie/MoviePutDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/Movie/MoviePutDto.cs new file mode 100644 index 0000000..c22c0c3 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/Movie/MoviePutDto.cs @@ -0,0 +1,10 @@ +namespace api_cinema_challenge.DTOs.Movie +{ + public class MoviePutDto + { + 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/Screening/ScreeningDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/Screening/ScreeningDto.cs new file mode 100644 index 0000000..8251279 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/Screening/ScreeningDto.cs @@ -0,0 +1,12 @@ +namespace api_cinema_challenge.DTOs.Screening +{ + public class ScreeningDto + { + 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/Screening/ScreeningPostDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/Screening/ScreeningPostDto.cs new file mode 100644 index 0000000..2fd735e --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/Screening/ScreeningPostDto.cs @@ -0,0 +1,9 @@ +namespace api_cinema_challenge.DTOs.Screening +{ + public class ScreeningPostDto + { + public int ScreenNumber { get; set; } + public int Capacity { get; set; } + public DateTime StartsAt { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/Ticket/TicketDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/Ticket/TicketDto.cs new file mode 100644 index 0000000..74629d0 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/Ticket/TicketDto.cs @@ -0,0 +1,10 @@ +namespace api_cinema_challenge.DTOs.Ticket +{ + public class TicketDto + { + public int Id { get; set; } + public int NumSeats { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/DTOs/Ticket/TicketPostDto.cs b/api-cinema-challenge/api-cinema-challenge/DTOs/Ticket/TicketPostDto.cs new file mode 100644 index 0000000..67c7545 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/DTOs/Ticket/TicketPostDto.cs @@ -0,0 +1,7 @@ +namespace api_cinema_challenge.DTOs.Ticket +{ + public class TicketPostDto + { + public int NumSeats { get; set; } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs new file mode 100644 index 0000000..e051062 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Data/CinemaContext.cs @@ -0,0 +1,58 @@ +using api_cinema_challenge.Models; +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json.Linq; + +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; + +namespace api_cinema_challenge.Data +{ + // IdentityUserContext instead of Db in workshop + public class CinemaContext : IdentityUserContext + { + public CinemaContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity() + .Property(u => u.Role) + .HasConversion(); + + // relations + modelBuilder.Entity() + .HasOne(t => t.Customer) + .WithMany(c => c.Tickets) + .HasForeignKey(t => t.CustomerId); + + modelBuilder.Entity() + .HasOne(t => t.Screening) + .WithMany(s => s.Tickets) + .HasForeignKey(t => t.ScreeningId); + + modelBuilder.Entity() + .HasOne(s => s.Movie) + .WithMany(m => m.Screenings) + .HasForeignKey(s => s.MovieId); + + // seeder + var seeder = new Seeder(); + seeder.Seed(); + modelBuilder.Entity().HasData(seeder.Customers); + modelBuilder.Entity().HasData(seeder.Movies); + modelBuilder.Entity().HasData(seeder.Screenings); + modelBuilder.Entity().HasData(seeder.Tickets); + + + } + + 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 0000000..47a1124 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Data/Seeder.cs @@ -0,0 +1,66 @@ +using api_cinema_challenge.Models; + +namespace api_cinema_challenge.Data +{ + public class Seeder + { + private List _customers = new(); + private List _movies = new(); + private List _screenings = new(); + private List _tickets = new(); + + public void Seed() + { + var customer1 = new Customer() { Id = 1, Name = "Adam", Email = "a@a.com", Phone = "111" }; + var customer2 = new Customer() { Id = 2, Name = "Blazej", Email = "b@b.com", Phone = "222" }; + var customer3 = new Customer() { Id = 3, Name = "Kristian", Email = "c@c.com", Phone = "333" }; + var customer4 = new Customer() { Id = 4, Name = "Filip", Email = "d@c.com", Phone = "444" }; + var customer5 = new Customer() { Id = 5, Name = "Damian", Email = "e@e.com", Phone = "555" }; + + var movie1 = new Movie() { Id = 1, Title = "Movie One", Rating = "PG13", Description = "fefef", RuntimeMins = 60 }; + var movie2 = new Movie() { Id = 2, Title = "Movie 2", Rating = "PG13", Description = "hrdr", RuntimeMins = 60 }; + var movie3 = new Movie() { Id = 3, Title = "333 movie", Rating = "PG13", Description = "esge", RuntimeMins = 60 }; + var movie4 = new Movie() { Id = 4, Title = "444 movie", Rating = "PG13", Description = "vesve", RuntimeMins = 60 }; + var movie5 = new Movie() { Id = 5, Title = "555 movie", Rating = "PG13", Description = "dwawd", RuntimeMins = 60 }; + + var screening1 = new Screening() { Id = 1, MovieId = 1, ScreenNumber = 1, Capacity = 50, StartsAt = DateTime.UtcNow }; + var screening2 = new Screening() { Id = 2, MovieId = 1, ScreenNumber = 1, Capacity = 50, StartsAt = DateTime.UtcNow }; + + var ticket1 = new Ticket() { Id = 1, ScreeningId = 1, CustomerId = 1, NumSeats = 1 }; + var ticket2 = new Ticket() { Id = 2, ScreeningId = 1, CustomerId = 2, NumSeats = 1 }; + var ticket3 = new Ticket() { Id = 3, ScreeningId = 1, CustomerId = 3, NumSeats = 1 }; + + var ticket4 = new Ticket() { Id = 4, ScreeningId = 2, CustomerId = 1, NumSeats = 1 }; + var ticket5 = new Ticket() { Id = 5, ScreeningId = 2, CustomerId = 2, NumSeats = 1 }; + var ticket6 = new Ticket() { Id = 6, ScreeningId = 2, CustomerId = 3, NumSeats = 1 }; + + _customers.Add(customer1); + _customers.Add(customer2); + _customers.Add(customer3); + _customers.Add(customer4); + _customers.Add(customer5); + + _movies.Add(movie1); + _movies.Add(movie2); + _movies.Add(movie3); + _movies.Add(movie4); + _movies.Add(movie5); + + _screenings.Add(screening1); + _screenings.Add(screening2); + + _tickets.Add(ticket1); + _tickets.Add(ticket2); + _tickets.Add(ticket3); + _tickets.Add(ticket4); + _tickets.Add(ticket5); + _tickets.Add(ticket6); + + } + + 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/Endpoints/CustomerEndpoint.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoint.cs new file mode 100644 index 0000000..d3e6c00 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/CustomerEndpoint.cs @@ -0,0 +1,155 @@ +using api_cinema_challenge.DTOs.Customer; +using api_cinema_challenge.DTOs.Ticket; +using api_cinema_challenge.Factories; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repository.Interfaces; +using api_cinema_challenge.Utils; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace api_cinema_challenge.Endpoints +{ + public static class CustomerEndpoint + { + public static void ConfigureCustomerEndpoint(this WebApplication app) + { + string groupName = "customers"; + string contentType = "application/json"; + + var customerGroup = app.MapGroup(groupName); + + customerGroup.MapGet("/", GetAllCustomers); + customerGroup.MapPost("/", CreateCustomer).Accepts(contentType); + customerGroup.MapPut("/{customerId}", UpdateCustomer).Accepts(contentType); + customerGroup.MapDelete("/{customerId}", DeleteCustomer); + + customerGroup.MapPost("/{customerId}/screenings/{screeningId}", BookTicket).Accepts(contentType); + customerGroup.MapGet("/{customerId}/screenings/{screeningId}", GetTickets); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + private static async Task GetAllCustomers(ICustomerRepository repository) + { + var customers = await repository.GetAllAsync(); + + List dtos = new List(); + foreach (var customer in customers) + { + dtos.Add(CustomerFactory.DtoFromCustomer(customer)); + } + + return TypedResults.Ok(new { Status = "success", Data = dtos}); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + private static async Task CreateCustomer(ICustomerRepository repository, HttpRequest request) + { + CustomerPostDto inDto = await Utility.ValidateFromRequest(request); + if (inDto is null) + { + return TypedResults.BadRequest( new { status = "failure"} ); + } + + var added = await repository.CreateCustomer(CustomerFactory.CustomerFromPostDto(inDto)); + if (added is null) + { + return TypedResults.BadRequest(new { status = "failure" }); + } + + var outDto = CustomerFactory.DtoFromCustomer(added); + + // TODO move to other class + var url = $"{request.Scheme}://{request.Host}{request.Path}/{outDto.Id}"; + + return TypedResults.Created(url, new {status = "success", data = outDto}); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + private static async Task UpdateCustomer(ICustomerRepository repository, HttpRequest request, int id) + { + CustomerPutDto inDto = await Utility.ValidateFromRequest(request); + if (inDto is null) + { + return TypedResults.BadRequest(new { status = "failure" }); + } + + var entity = await repository.GetByIdAsync(id); + if (entity is null) + { + return TypedResults.NotFound(new { status = "failure" }); + } + + var updated = await repository.UpdateCustomer(CustomerFactory.CustomerFromPutDto(inDto, entity)); + if (updated is null) + { + return TypedResults.BadRequest(new { status = "failure" }); + } + + + return TypedResults.Created(); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + private static async Task DeleteCustomer(ICustomerRepository repository, int customerId) + { + var customer = await repository.DeleteCustomer(customerId); + if (customer is null) + { + return TypedResults.NotFound(new { status = "failure" }); + } + + var dto = CustomerFactory.DtoFromCustomer(customer); + return TypedResults.Ok( new { status = "success", data = dto}); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + private static async Task BookTicket(ITicketRepository ticketRepository, HttpRequest request, int customerId, int screeningId) + { + TicketPostDto inDto = await Utility.ValidateFromRequest(request); + if (inDto is null) + { + return TypedResults.BadRequest(new { status = "failure" }); + } + + var ticket = await ticketRepository.CreateTicket(TicketFactory.TicketFromPostDto(inDto, customerId, screeningId)); + if (ticket is null) + { + return TypedResults.BadRequest(new { status = "failure" }); + } + + var dto = TicketFactory.DtoFromTicket(ticket); + return TypedResults.Created(); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + private static async Task GetTickets(ITicketRepository ticketRepository, HttpRequest request, int customerId, int screeningId) + { + var tickets = await ticketRepository.GetByIdAsync(customerId, screeningId); + if (tickets is null) + { + return TypedResults.NotFound(new { status = "failure" }); + } + + List dtos = new(); + foreach (var ticket in tickets) + { + dtos.Add(TicketFactory.DtoFromTicket(ticket)); + } + // TODO fix url + var url = $"{request.Scheme}://{request.Host}{request.Path}/"; + return TypedResults.Created(url, new { status = "success", data = dtos}); + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoint.cs b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoint.cs new file mode 100644 index 0000000..e6b86f0 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Endpoints/MovieEndpoint.cs @@ -0,0 +1,156 @@ +using api_cinema_challenge.DTOs.Customer; +using api_cinema_challenge.DTOs.Movie; +using api_cinema_challenge.DTOs.Screening; +using api_cinema_challenge.DTOs.Ticket; +using api_cinema_challenge.Factories; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repository.Interfaces; +using api_cinema_challenge.Utils; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace api_cinema_challenge.Endpoints +{ + public static class MovieEndpoint + { + public static void ConfigureMovieEndpoint(this WebApplication app) + { + const string groupName = "movies"; + const string contentType = "application/json"; + + var moviesGroup = app.MapGroup(groupName); + + moviesGroup.MapGet("/", GetAllMovies); + moviesGroup.MapPost("/", CreateMovie).Accepts(contentType); + moviesGroup.MapPut("/{movieId}", UpdateMovie).Accepts(contentType); + moviesGroup.MapDelete("/{movieId}", DeleteMovie); + + moviesGroup.MapPost("/{movieId}/screenings", CreateScreening).Accepts(contentType); + moviesGroup.MapGet("/{movieId}/screenings", GetScreenings); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + private static async Task GetAllMovies(IMovieRepository repository) + { + var movie = await repository.GetAllAsync(); + + List dtos = new(); + foreach (var customer in movie) + { + dtos.Add(MovieFactory.DtoFromMovie(customer)); + } + + return TypedResults.Ok(new { Status = "success", Data = dtos }); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + private static async Task CreateMovie(IMovieRepository repository, HttpRequest request) + { + MoviePostDto inDto = await Utility.ValidateFromRequest(request); + if (inDto is null) + { + return TypedResults.BadRequest(new { status = "failure" }); + } + + var added = await repository.CreateMovie(MovieFactory.MovieFromPostDto(inDto)); + if (added is null) + { + return TypedResults.BadRequest(new { status = "failure" }); + } + + var outDto = MovieFactory.DtoFromMovie(added); + + var url = $"{request.Scheme}://{request.Host}{request.Path}/{outDto.Id}"; + + return TypedResults.Created(url, new { status = "success", data = outDto }); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + private static async Task UpdateMovie(IMovieRepository repository, HttpRequest request, int movieId) + { + MoviePutDto inDto = await Utility.ValidateFromRequest(request); + if (inDto is null) + { + return TypedResults.BadRequest(new { status = "failure" }); + } + + var entity = await repository.GetByIdAsync(movieId); + if (entity is null) + { + return TypedResults.NotFound(new { status = "failure" }); + } + + var updated = await repository.UpdateMovie(MovieFactory.MovieFromPutDto(inDto, entity)); + if (updated is null) + { + return TypedResults.BadRequest(new { status = "failure" }); + } + + + return TypedResults.Created(); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + private static async Task DeleteMovie(IMovieRepository repository, int movieId) + { + var movie = await repository.DeleteMovie(movieId); + if (movie is null) + { + return TypedResults.NotFound(new { status = "failure" }); + } + + var dto = MovieFactory.DtoFromMovie(movie); + return TypedResults.Ok(new { status = "success", data = dto }); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + private static async Task CreateScreening(IScreeningRepository repository, HttpRequest request, int movieId) + { + ScreeningPostDto inDto = await Utility.ValidateFromRequest(request); + if (inDto is null) + { + return TypedResults.BadRequest(new { status = "failure" }); + } + + var ticket = await repository.CreateScreening(ScreeningFactory.ScreeningFromPostDto(inDto, movieId)); + if (ticket is null) + { + return TypedResults.BadRequest(new { status = "failure" }); + } + + var dto = ScreeningFactory.DtoFromScreening(ticket); + return TypedResults.Created(); + } + + [Authorize] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + private static async Task GetScreenings(IScreeningRepository repository, HttpRequest request, int movieId) + { + var screenings = await repository.GetByIdAsync(movieId); + if (screenings is null) + { + return TypedResults.NotFound(new { status = "failure" }); + } + + List dtos = new(); + foreach (var screening in screenings) + { + dtos.Add(ScreeningFactory.DtoFromScreening(screening)); + } + // TODO fix url + var url = $"{request.Scheme}://{request.Host}{request.Path}/"; + return TypedResults.Created(url, new { status = "success", data = dtos }); + } + } +} 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 0000000..3240a24 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Enums/Role.cs @@ -0,0 +1,8 @@ +namespace api_cinema_challenge.Enums +{ + public enum Role + { + User, + Admin + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Factories/CustomerFactory.cs b/api-cinema-challenge/api-cinema-challenge/Factories/CustomerFactory.cs new file mode 100644 index 0000000..67d7e23 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Factories/CustomerFactory.cs @@ -0,0 +1,46 @@ +using api_cinema_challenge.DTOs.Customer; +using api_cinema_challenge.Models; + +namespace api_cinema_challenge.Factories +{ + public static class CustomerFactory + { + public static CustomerDto DtoFromCustomer(Customer customer) + { + var dto = new CustomerDto(); + + dto.Id = customer.Id; + dto.Name = customer.Name; + dto.Email = customer.Email; + dto.Phone = customer.Phone; + dto.CreatedAt = customer.CreatedAt; + dto.UpdatedAt = customer.UpdatedAt; + + return dto; + } + + public static Customer CustomerFromPostDto(CustomerPostDto dto) + { + var customer = new Customer(); + + customer.Name = dto.Name; + customer.Email = dto.Email; + customer.Phone = dto.Phone; + customer.CreatedAt = DateTime.UtcNow; + customer.UpdatedAt = DateTime.UtcNow; + + return customer; + } + + public static Customer CustomerFromPutDto(CustomerPutDto dto, Customer oldCustomer) + { + var updated = oldCustomer; + + if (dto.Name is not null) updated.Name = dto.Name; + if (dto.Email is not null) updated.Email = dto.Email; + if (dto.Phone is not null) updated.Phone = dto.Phone; + + return updated; + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Factories/MovieFactory.cs b/api-cinema-challenge/api-cinema-challenge/Factories/MovieFactory.cs new file mode 100644 index 0000000..53eed04 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Factories/MovieFactory.cs @@ -0,0 +1,48 @@ +using api_cinema_challenge.DTOs.Movie; +using api_cinema_challenge.Models; +using static Microsoft.EntityFrameworkCore.DbLoggerCategory; + +namespace api_cinema_challenge.Factories +{ + public static class MovieFactory + { + public static MovieDto DtoFromMovie(Movie movie) + { + var dto = new MovieDto(); + + dto.Id = movie.Id; + dto.Title = movie.Title; + dto.Rating = movie.Rating; + dto.Description = movie.Description; + dto.RuntimeMins = movie.RuntimeMins; + dto.CreatedAt = movie.CreatedAt; + dto.UpdatedAt = movie.UpdatedAt; + + return dto; + } + + public static Movie MovieFromPostDto(MoviePostDto dto) + { + var movie = new Movie(); + + movie.Title = dto.Title; + movie.Rating = dto.Rating; + movie.Description = dto.Description; + movie.RuntimeMins = dto.RuntimeMins; + + return movie; + } + + public static Movie MovieFromPutDto(MoviePutDto dto, Movie oldMovie) + { + var updated = oldMovie; + + if (dto.Title is not null) updated.Title = dto.Title; + if (dto.Rating is not null) updated.Rating = dto.Rating; + if (dto.Description is not null) updated.Description = dto.Description; + if (dto.RuntimeMins != 0) updated.RuntimeMins = dto.RuntimeMins; + + return updated; + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Factories/ResponseFactory.cs b/api-cinema-challenge/api-cinema-challenge/Factories/ResponseFactory.cs new file mode 100644 index 0000000..0934372 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Factories/ResponseFactory.cs @@ -0,0 +1,15 @@ +namespace api_cinema_challenge.Factories +{ + public static class ResponseFactory + { + public static Object Failure() + { + return new { status = "failure"}; + } + + public static Object Success() + { + return new { status = "success" }; + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Factories/ScreeningFactory.cs b/api-cinema-challenge/api-cinema-challenge/Factories/ScreeningFactory.cs new file mode 100644 index 0000000..8eaf28b --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Factories/ScreeningFactory.cs @@ -0,0 +1,36 @@ +using api_cinema_challenge.DTOs.Screening; +using api_cinema_challenge.Models; + +namespace api_cinema_challenge.Factories +{ + public static class ScreeningFactory + { + public static Screening ScreeningFromPostDto(ScreeningPostDto dtp, int movieId) + { + var screening = new Screening(); + + screening.MovieId = movieId; + screening.ScreenNumber = dtp.ScreenNumber; + screening.Capacity = dtp.Capacity; + screening.StartsAt = dtp.StartsAt; + screening.CreatedAt = DateTime.UtcNow; + screening.UpdatedAt = DateTime.UtcNow; + + return screening; + } + + public static ScreeningDto DtoFromScreening(Screening screening) + { + var dto = new ScreeningDto(); + + dto.Id = screening.Id; + dto.ScreenNumber = screening.ScreenNumber; + dto.Capacity = screening.Capacity; + dto.StartsAt = screening.StartsAt; + dto.CreatedAt = DateTime.UtcNow; + dto.UpdatedAt = DateTime.UtcNow; + + return dto; + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Factories/TicketFactory.cs b/api-cinema-challenge/api-cinema-challenge/Factories/TicketFactory.cs new file mode 100644 index 0000000..2f4f061 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Factories/TicketFactory.cs @@ -0,0 +1,34 @@ +using api_cinema_challenge.DTOs.Ticket; +using api_cinema_challenge.Models; +using Microsoft.AspNetCore.StaticAssets; + +namespace api_cinema_challenge.Factories +{ + public static class TicketFactory + { + public static Ticket TicketFromPostDto(TicketPostDto dto, int customerId, int screeningId) + { + var ticket = new Ticket(); + + ticket.CustomerId = customerId; + ticket.ScreeningId = screeningId; + ticket.NumSeats = dto.NumSeats; + ticket.CreatedAt = DateTime.UtcNow; + ticket.UpdatedAt = DateTime.UtcNow; + + return ticket; + } + + public static TicketDto DtoFromTicket(Ticket ticket) + { + var dto = new TicketDto(); + + dto.Id = ticket.Id; + dto.NumSeats = ticket.NumSeats; + dto.CreatedAt = ticket.CreatedAt; + dto.UpdatedAt = ticket.UpdatedAt; + + return dto; + } + } +} 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 0000000..cbee739 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/ApplicationUser.cs @@ -0,0 +1,11 @@ +using api_cinema_challenge.Enums; +using Microsoft.AspNetCore.Identity; +using System.Data; + +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 0000000..7f73d7a --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Customer.cs @@ -0,0 +1,13 @@ +namespace api_cinema_challenge.Models +{ + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } + public string Email { get; set; } + public string Phone { get; set; } + public ICollection Tickets { get; set; } = new List(); + 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 0000000..43f5e4a --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Movie.cs @@ -0,0 +1,14 @@ +namespace api_cinema_challenge.Models +{ + public class Movie + { + 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 ICollection Screenings { get; set; } = new List(); + public DateTime CreatedAt { get; set; } + 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 0000000..5f12a8e --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Screening.cs @@ -0,0 +1,15 @@ +namespace api_cinema_challenge.Models +{ + 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 ICollection Tickets { get; set; } = new List(); + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { 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 0000000..9e93413 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Models/Ticket.cs @@ -0,0 +1,14 @@ +namespace api_cinema_challenge.Models +{ + public class Ticket + { + public int Id { get; set; } + public int CustomerId { get; set; } + public Customer Customer { get; set; } + public int ScreeningId { get; set; } + public Screening Screening { get; set; } + public int NumSeats { 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 new file mode 100644 index 0000000..9e7a457 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Program.cs @@ -0,0 +1,160 @@ +using api_cinema_challenge.Data; +using api_cinema_challenge.Endpoints; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repository; +using api_cinema_challenge.Repository.Interfaces; +using api_cinema_challenge.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using System.Diagnostics; +using System.Text; +using System.Text.Json.Serialization; + +var builder = WebApplication.CreateBuilder(args); + +// Add services +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); + +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(options => { + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnectionString")) + .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)); + options.LogTo(message => Debug.WriteLine(message)); + options.EnableSensitiveDataLogging(); +}); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +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) + ), + }; + }); + +// policy-based authorization for Admin and User roles +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("Admin", policy => + policy.RequireRole("Admin")); + options.AddPolicy("User", policy => + policy.RequireRole("User")); +}); + + +// Build the app +var app = builder.Build(); + +// Configure the HTTP request pipeline +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint("/swagger/v1/swagger.json", "Test API v1"); + options.RoutePrefix = "swagger"; + }); + +} +app.UseSwagger(); +app.UseSwaggerUI(options => +{ + options.SwaggerEndpoint("/swagger/v1/swagger.json", "Test API v1"); + options.RoutePrefix = "swagger"; +}); + +app.UseHttpsRedirection(); +app.UseStatusCodePages(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.ConfigureCustomerEndpoint(); +app.ConfigureMovieEndpoint(); + +app.MapControllers(); +app.Run(); diff --git a/exercise.wwwapi/Properties/launchSettings.json b/api-cinema-challenge/api-cinema-challenge/Properties/launchSettings.json similarity index 73% rename from exercise.wwwapi/Properties/launchSettings.json rename to api-cinema-challenge/api-cinema-challenge/Properties/launchSettings.json index a8c9a3c..88dd35e 100644 --- a/exercise.wwwapi/Properties/launchSettings.json +++ b/api-cinema-challenge/api-cinema-challenge/Properties/launchSettings.json @@ -1,41 +1,41 @@ -{ - "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:43231", - "sslPort": 44362 - } - }, - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "http://localhost:5005", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "https://localhost:7136;http://localhost:5005", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:52799", + "sslPort": 44373 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5059", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7195;http://localhost:5059", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/CustomerRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/CustomerRepository.cs new file mode 100644 index 0000000..48cce0b --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repository/CustomerRepository.cs @@ -0,0 +1,67 @@ +using api_cinema_challenge.Data; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repository.Interfaces; +using Microsoft.EntityFrameworkCore; + +namespace api_cinema_challenge.Repository +{ + public class CustomerRepository : ICustomerRepository + { + private CinemaContext _db; + + public CustomerRepository(CinemaContext db) + { + _db = db; + } + + public async Task CreateCustomer(Customer customer) + { + var exists = _db.Customers.Where(c => c.Id == customer.Id).Any(); + if (exists) return null; + + await _db.Customers.AddAsync(customer); + await _db.SaveChangesAsync(); + + return customer; + } + + public async Task DeleteCustomer(int id) + { + var exists = await _db.Customers.AnyAsync(x => x.Id == id); + if (!exists) + { + return null; + } + + var entity = await _db.Customers.FirstAsync(x => x.Id == id); + _db.Customers.Remove(entity); + await _db.SaveChangesAsync(); + + return entity; + } + + public async Task> GetAllAsync() + { + return await _db.Customers.ToListAsync(); + } + + public async Task GetByIdAsync(int id) + { + var entity = await _db.Customers.Where(c => c.Id == id).FirstOrDefaultAsync(); + if (entity is null) + { + return null; + } + + return entity; + } + + public async Task UpdateCustomer(Customer customer) + { + _db.Customers.Update(customer); + await _db.SaveChangesAsync(); + + return customer; + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/Interfaces/ICustomerRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/Interfaces/ICustomerRepository.cs new file mode 100644 index 0000000..016b4b8 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repository/Interfaces/ICustomerRepository.cs @@ -0,0 +1,13 @@ +using api_cinema_challenge.Models; + +namespace api_cinema_challenge.Repository.Interfaces +{ + public interface ICustomerRepository + { + public Task GetByIdAsync(int id); + public Task> GetAllAsync(); + public Task CreateCustomer(Customer customer); + public Task UpdateCustomer(Customer customer); + public Task DeleteCustomer(int id); + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/Interfaces/IMovieRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/Interfaces/IMovieRepository.cs new file mode 100644 index 0000000..767f7e5 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repository/Interfaces/IMovieRepository.cs @@ -0,0 +1,13 @@ +using api_cinema_challenge.Models; + +namespace api_cinema_challenge.Repository.Interfaces +{ + public interface IMovieRepository + { + public Task GetByIdAsync(int id); + public Task> GetAllAsync(); + public Task CreateMovie(Movie movie); + public Task UpdateMovie(Movie customer); + public Task DeleteMovie(int id); + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/Interfaces/IScreeningRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/Interfaces/IScreeningRepository.cs new file mode 100644 index 0000000..78f8009 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repository/Interfaces/IScreeningRepository.cs @@ -0,0 +1,10 @@ +using api_cinema_challenge.Models; + +namespace api_cinema_challenge.Repository.Interfaces +{ + public interface IScreeningRepository + { + public Task> GetByIdAsync(int movieId); + public Task CreateScreening(Screening screening); + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/Interfaces/ITicketRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/Interfaces/ITicketRepository.cs new file mode 100644 index 0000000..49483f0 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repository/Interfaces/ITicketRepository.cs @@ -0,0 +1,10 @@ +using api_cinema_challenge.Models; + +namespace api_cinema_challenge.Repository.Interfaces +{ + public interface ITicketRepository + { + public Task> GetByIdAsync(int customerId, int screeningId); + public Task CreateTicket(Ticket ticket); + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/MovieRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/MovieRepository.cs new file mode 100644 index 0000000..24d7fa3 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repository/MovieRepository.cs @@ -0,0 +1,41 @@ +using api_cinema_challenge.Data; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repository.Interfaces; + +namespace api_cinema_challenge.Repository +{ + public class MovieRepository : IMovieRepository + { + private CinemaContext _db; + + public MovieRepository(CinemaContext db) + { + _db = db; + } + + public Task CreateMovie(Movie movie) + { + throw new NotImplementedException(); + } + + public Task DeleteMovie(int id) + { + throw new NotImplementedException(); + } + + public Task> GetAllAsync() + { + throw new NotImplementedException(); + } + + public Task GetByIdAsync(int id) + { + throw new NotImplementedException(); + } + + public Task UpdateMovie(Movie customer) + { + throw new NotImplementedException(); + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/ScreeningRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/ScreeningRepository.cs new file mode 100644 index 0000000..1a577fb --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repository/ScreeningRepository.cs @@ -0,0 +1,48 @@ +using api_cinema_challenge.Data; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repository.Interfaces; +using Microsoft.EntityFrameworkCore; +using System.Net.Sockets; + +namespace api_cinema_challenge.Repository +{ + public class ScreeningRepository : IScreeningRepository + { + private CinemaContext _db; + + public ScreeningRepository(CinemaContext db) + { + _db = db; + } + + public async Task CreateScreening(Screening screening) + { + var exists = await _db.Screenings + .Where(s => s.Id == screening.Id) + .AnyAsync(); + + if (exists) return null; + + await _db.Screenings.AddAsync(screening); + await _db.SaveChangesAsync(); + + return screening; + } + + public async Task> GetByIdAsync(int movieId) + { + bool exists = await _db.Screenings + .Where(s => s.MovieId == movieId) + .AnyAsync(); + + if (!exists) + return null; + + var screenings = await _db.Screenings + .Where(s => s.MovieId == movieId) + .ToListAsync(); + + return screenings; + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/Repository/TicketRepository.cs b/api-cinema-challenge/api-cinema-challenge/Repository/TicketRepository.cs new file mode 100644 index 0000000..a69c6d7 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Repository/TicketRepository.cs @@ -0,0 +1,51 @@ +using api_cinema_challenge.Data; +using api_cinema_challenge.Models; +using api_cinema_challenge.Repository.Interfaces; +using Microsoft.EntityFrameworkCore; +using System.Net.Sockets; + +namespace api_cinema_challenge.Repository +{ + public class TicketRepository : ITicketRepository + { + private CinemaContext _db; + + public TicketRepository(CinemaContext db) + { + _db = db; + } + + public async Task CreateTicket(Ticket ticket) + { + var exists = await _db.Tickets + .Where(t => t.CustomerId == ticket.CustomerId) + .Where(t => t.ScreeningId == ticket.ScreeningId) + .AnyAsync(); + + if (exists) return null; + + await _db.Tickets.AddAsync(ticket); + await _db.SaveChangesAsync(); + + return ticket; + } + + public async Task> GetByIdAsync(int customerId, int screeningId) + { + bool exists = await _db.Tickets + .Where(t => t.CustomerId == customerId) + .Where(t => t.ScreeningId == screeningId) + .AnyAsync(); + + if (!exists) + return null; + + var tickets = await _db.Tickets + .Where(t => t.CustomerId == customerId) + .Where(t => t.ScreeningId==screeningId) + .ToListAsync(); + + return tickets; + } + } +} 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 0000000..ed9f8dc --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Services/TokenService.cs @@ -0,0 +1,82 @@ +namespace api_cinema_challenge.Services; + +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; + +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 + ); + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/TestYourEndPoints.http b/api-cinema-challenge/api-cinema-challenge/TestYourEndPoints.http new file mode 100644 index 0000000..4743b98 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/TestYourEndPoints.http @@ -0,0 +1 @@ +# For more info on HTTP files go to https://aka.ms/vs/httpfile diff --git a/api-cinema-challenge/api-cinema-challenge/Utils/Utility.cs b/api-cinema-challenge/api-cinema-challenge/Utils/Utility.cs new file mode 100644 index 0000000..66eed50 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/Utils/Utility.cs @@ -0,0 +1,26 @@ +using System.Text.Json; + +namespace api_cinema_challenge.Utils +{ + public static class Utility + { + public static async Task ValidateFromRequest(HttpRequest request) + { + T? entity; + try + { + entity = await request.ReadFromJsonAsync(); + } + catch (JsonException ex) + { + return default; + } + catch (Exception ex) + { + return default; + } + + return entity; + } + } +} diff --git a/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj new file mode 100644 index 0000000..dc9602e --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/api-cinema-challenge.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + enable + enable + api_cinema_challenge + 8499e9e9-9306-422f-a58d-332dfc8c5416 + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/api-cinema-challenge/api-cinema-challenge/appsettings.example.json b/api-cinema-challenge/api-cinema-challenge/appsettings.example.json new file mode 100644 index 0000000..b9175fe --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/appsettings.example.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnectionString": "Host=HOST; Database=DATABASE; Username=USERNAME; Password=PASSWORD;" + } +} \ No newline at end of file diff --git a/api-cinema-challenge/api-cinema-challenge/jwt appsettings.txt b/api-cinema-challenge/api-cinema-challenge/jwt appsettings.txt new file mode 100644 index 0000000..17673d9 --- /dev/null +++ b/api-cinema-challenge/api-cinema-challenge/jwt appsettings.txt @@ -0,0 +1,11 @@ +"SiteSettings": { + "AdminEmail": "example@test.com", + "AdminPassword": "administrator" +}, + +"JwtTokenSettings": { + "ValidIssuer": "ExampleIssuer", + "ValidAudience": "ExampleAudience", + "SymmetricSecurityKey": "v89h3bh89vh9ve8hc89nv98nn899cnccn998ev80vi809jberh89b", + "JwtRegisteredClaimNamesSub": "rbveer3h535nn3n35nyny5umbbt" +}, \ No newline at end of file diff --git a/exercise.wwwapi/Program.cs b/exercise.wwwapi/Program.cs deleted file mode 100644 index 47f22ef..0000000 --- a/exercise.wwwapi/Program.cs +++ /dev/null @@ -1,20 +0,0 @@ -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container. -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseHttpsRedirection(); - - -app.Run(); \ No newline at end of file diff --git a/exercise.wwwapi/appsettings.example.json b/exercise.wwwapi/appsettings.example.json deleted file mode 100644 index 7a9d42d..0000000 --- a/exercise.wwwapi/appsettings.example.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*", - "JwtTokenSettings": { - "ValidIssuer": "YourCompanyServer", - "ValidAudience": "YourProductAudience", - "SymmetricSecurityKey": "SOME_RANDOM_SECRET", - "JwtRegisteredClaimNamesSub": "SOME_RANDOM_CODE" - } -} diff --git a/exercise.wwwapi/exercise.wwwapi.csproj b/exercise.wwwapi/exercise.wwwapi.csproj deleted file mode 100644 index 56929a8..0000000 --- a/exercise.wwwapi/exercise.wwwapi.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - net9.0 - enable - enable - true - - - - - - - - diff --git a/exercise.wwwapi/exercise.wwwapi.http b/exercise.wwwapi/exercise.wwwapi.http deleted file mode 100644 index f9bfc79..0000000 --- a/exercise.wwwapi/exercise.wwwapi.http +++ /dev/null @@ -1,6 +0,0 @@ -@exercise.wwwapi_HostAddress = http://localhost:5005 - -GET {{exercise.wwwapi_HostAddress}}/weatherforecast/ -Accept: application/json - -###