diff --git a/README.md b/README.md index aa062f3..8479b6a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,21 @@ # C# Entity Framework Intro -1. Fork this repository -2. Clone your fork to your machine -3. Open the ef.intro.sln in Visual Studio - +## Completed +Completed everything but super extensions. To run the project, make sure to +update / create an appsettings.json with a connection string. E.g. + + +"ConnectionStrings": { + "DefaultConnectionString": "Host=your; Database=values; Username=goes; Password=here;" +} + + +Then run migrations and update the database. Test the endpoints in scalar or swagger. +(I recommend scalar) + +1. `add-migration init` +1. `update-database init` +1. Start the project ## Setup diff --git a/exercise.webapi/DTO/Author.cs b/exercise.webapi/DTO/Author.cs new file mode 100644 index 0000000..85ae7e8 --- /dev/null +++ b/exercise.webapi/DTO/Author.cs @@ -0,0 +1,7 @@ +namespace exercise.webapi.DTO +{ + public record AuthorPost(string FirstName, string LastName, string Email); + public record AuthorPut(string? FirstName, string? LastName, string? Email); + public record AuthorInternal(int Id, string FirstName, string LastName, string Email); + public record AuthorView(int Id, string FirstName, string LastName, string Email, IEnumerable Books); +} diff --git a/exercise.webapi/DTO/Book.cs b/exercise.webapi/DTO/Book.cs new file mode 100644 index 0000000..a3da65c --- /dev/null +++ b/exercise.webapi/DTO/Book.cs @@ -0,0 +1,10 @@ +namespace exercise.webapi.DTO +{ + public record BookPost(string Title, int AuthorId, int PublisherId); + public record BookPut(string? Title); + public record BookView(int Id, string Title, IEnumerable Authors, PublisherInternal Publisher, CheckoutInternal? Checkout); + public record BookInternal(int Id, string Title); + public record BookInternalPublisher(int Id, string Title, PublisherInternal Publisher); + public record BookInternalAuthor(int Id, string Title, IEnumerable Authors); + public record BookInternalAuthorPublisher(int Id, string Title, IEnumerable Authors, PublisherInternal Publisher); +} diff --git a/exercise.webapi/DTO/Checkout.cs b/exercise.webapi/DTO/Checkout.cs new file mode 100644 index 0000000..d965711 --- /dev/null +++ b/exercise.webapi/DTO/Checkout.cs @@ -0,0 +1,6 @@ +namespace exercise.webapi.DTO +{ + public record CheckoutPost(int BookId); + public record CheckoutView(int Id, DateTime CheckoutTime, DateTime? ReturnTime, DateTime ExpectedReturnTime, BookInternalAuthorPublisher Book); + public record CheckoutInternal(int Id, DateTime CheckoutTime, DateTime? ReturnTime, DateTime ExpectedReturnTime); +} diff --git a/exercise.webapi/DTO/Publisher.cs b/exercise.webapi/DTO/Publisher.cs new file mode 100644 index 0000000..54b09cd --- /dev/null +++ b/exercise.webapi/DTO/Publisher.cs @@ -0,0 +1,7 @@ +namespace exercise.webapi.DTO +{ + public record PublisherPost(string Name); + public record PublisherPut(string? Name); + public record PublisherView(int Id, string Name, IEnumerable Books); + public record PublisherInternal(int Id, string Name); +} diff --git a/exercise.webapi/Data/DataContext.cs b/exercise.webapi/Data/DataContext.cs index b6be7a9..1230de6 100644 --- a/exercise.webapi/Data/DataContext.cs +++ b/exercise.webapi/Data/DataContext.cs @@ -1,32 +1,33 @@ using exercise.webapi.Models; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using System.Collections.Generic; using System.Reflection.Emit; namespace exercise.webapi.Data { - public class DataContext : DbContext + public class DataContext(DbContextOptions options) : DbContext(options) { - - public DataContext(DbContextOptions options) : base(options) - { - - } - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - base.OnConfiguring(optionsBuilder); - optionsBuilder.UseInMemoryDatabase("Library"); - } - protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.Entity().HasMany(x => x.Authors).WithMany(x => x.Books).UsingEntity(); + modelBuilder.Entity().HasOne(x => x.Publisher).WithMany(x => x.Books).HasForeignKey(x => x.PublisherId).OnDelete(DeleteBehavior.SetNull); + modelBuilder.Entity().HasKey(x => new { x.AuthorId, x.BookId }); + modelBuilder.Entity().HasOne(x => x.Book).WithMany(x => x.Checkouts).HasForeignKey(x => x.BookId).OnDelete(DeleteBehavior.SetNull); + Seeder seeder = new Seeder(); modelBuilder.Entity().HasData(seeder.Authors); + modelBuilder.Entity().HasData(seeder.Publishers); modelBuilder.Entity().HasData(seeder.Books); - + modelBuilder.Entity().HasData(seeder.AuthorBooks); + modelBuilder.Entity().HasData(seeder.Checkouts); } public DbSet Authors { get; set; } public DbSet Books { get; set; } + public DbSet AuthorBooks { get; set; } + public DbSet Publishers { get; set; } + public DbSet Checkouts { get; set; } + } } diff --git a/exercise.webapi/Data/Seeder.cs b/exercise.webapi/Data/Seeder.cs index 955e3c8..6ee255c 100644 --- a/exercise.webapi/Data/Seeder.cs +++ b/exercise.webapi/Data/Seeder.cs @@ -79,13 +79,17 @@ public class Seeder private List _authors = new List(); private List _books = new List(); + private List _publishers = new List(); + private List _authorBooks = new List(); + private List _checkouts = new List(); public Seeder() { Random authorRandom = new Random(); Random bookRandom = new Random(); - + Random publisherRandom = new Random(); + Random checkoutRandom = new Random(); for (int x = 1; x < 250; x++) @@ -98,20 +102,76 @@ public Seeder() _authors.Add(author); } + for (int y = 1; y < 250; y++) + { + Publisher publisher = new Publisher(); + publisher.Id = y; + publisher.Name = $"{_lastnames[publisherRandom.Next(_lastnames.Count)]} {_thirdword[bookRandom.Next(_thirdword.Count)]}"; + _publishers.Add(publisher); + } + + HashSet> authorBookSet = new HashSet>(); for (int y = 1; y < 250; y++) { Book book = new Book(); book.Id = y; book.Title = $"{_firstword[bookRandom.Next(_firstword.Count)]} {_secondword[bookRandom.Next(_secondword.Count)]} {_thirdword[bookRandom.Next(_thirdword.Count)]}"; - book.AuthorId = _authors[authorRandom.Next(_authors.Count)].Id; - //book.Author = authors[book.AuthorId-1]; + book.PublisherId = _publishers[publisherRandom.Next(_publishers.Count)].Id; + AuthorBook authorBook = new AuthorBook(); + var ids = new Tuple(book.Id, _authors[authorRandom.Next(_authors.Count)].Id); + // Horrible stuff, cant be bothered to do better. + while (authorBookSet.Contains(ids)) ids = new Tuple(book.Id, _authors[authorRandom.Next(_authors.Count)].Id); + authorBookSet.Add(ids); + authorBook.BookId = ids.Item1; + authorBook.AuthorId = ids.Item2; + _authorBooks.Add(authorBook); _books.Add(book); } + for (int y = 1; y < 250; y++) + { + AuthorBook authorBook = new AuthorBook(); + var ids = new Tuple(_books[bookRandom.Next(_books.Count)].Id, _authors[authorRandom.Next(_authors.Count)].Id); + if (authorBookSet.Contains(ids)) continue; + authorBookSet.Add(ids); + authorBook.BookId = ids.Item1; + authorBook.AuthorId = ids.Item2; + _authorBooks.Add(authorBook); + } + HashSet checkedOutBooks = new HashSet(); + for (int y = 1; y < 100; y++) + { + Checkout checkout = new Checkout(); + var bookId = _books[bookRandom.Next(_books.Count)].Id; + if (checkedOutBooks.Contains(bookId)) continue; + bool isReturned = checkoutRandom.NextDouble() < .7; + DateTime checkoutTime = DateTime.UtcNow.AddDays(-checkoutRandom.Next(180)); + if (isReturned) + { + bool isOnTime = checkoutRandom.NextDouble() < .65; + if (isOnTime) + { + checkout.ReturnTime = checkoutTime.AddDays(checkoutRandom.Next(14)); + } else + { + checkout.ReturnTime = checkoutTime.AddDays(checkoutRandom.Next(15, 60)); + } + } else + { + checkedOutBooks.Add(bookId); + } + checkout.Id = y; + checkout.CheckoutTime = checkoutTime; + checkout.BookId = bookId; + _checkouts.Add(checkout); + } } public List Authors { get { return _authors; } } public List Books { get { return _books; } } + public List Publishers { get { return _publishers; } } + public List AuthorBooks { get { return _authorBooks; } } + public List Checkouts { get { return _checkouts; } } } } diff --git a/exercise.webapi/Endpoints/AuthorEndpoints.cs b/exercise.webapi/Endpoints/AuthorEndpoints.cs new file mode 100644 index 0000000..5f35317 --- /dev/null +++ b/exercise.webapi/Endpoints/AuthorEndpoints.cs @@ -0,0 +1,164 @@ +using exercise.webapi.DTO; +using exercise.webapi.Exceptions; +using exercise.webapi.Models; +using exercise.webapi.Repository; + +namespace exercise.webapi.Endpoints +{ + public static class AuthorEndpoints + { + public static string Path { get; private set; } = "authors"; + + public static void ConfigureAuthorsEndpoints(this WebApplication app) + { + var group = app.MapGroup(Path); + + group.MapPost("/", CreateAuthor); + group.MapGet("/", GetAuthors); + group.MapGet("/{id}", GetAuthor); + group.MapPut("/{id}", UpdateAuthor); + group.MapDelete("/{id}", DeleteAuthor); + } + public static async Task CreateAuthor(IRepository repository, AuthorPost entity) + { + try + { + Author author = await repository.Add(new Author + { + FirstName = entity.FirstName, + LastName = entity.LastName, + Email = entity.Email, + }); + return TypedResults.Ok(new AuthorView( + author.Id, + author.FirstName, + author.LastName, + author.Email, + author.Books.Select(b => new BookInternalPublisher( + b.Id, + b.Title, + new PublisherInternal( + b.Publisher.Id, + b.Publisher.Name + ) + )) + )); + } + catch (IdNotFoundException ex) + { + return TypedResults.NotFound(new { ex.Message }); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + public static async Task GetAuthors(IRepository repository) + { + try + { + IEnumerable authors = await repository.GetAll(); + return TypedResults.Ok(authors.Select(a => + { + return new AuthorView( + a.Id, + a.FirstName, + a.LastName, + a.Email, + a.Books.Select(b => new BookInternalPublisher( + b.Id, + b.Title, + new PublisherInternal( + b.Publisher.Id, + b.Publisher.Name + ) + ))); + })); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + + public static async Task GetAuthor(IRepository repository, int id) + { + try + { + Author author = await repository.Get(id); + return TypedResults.Ok(new AuthorView( + author.Id, + author.FirstName, + author.LastName, + author.Email, + author.Books.Select(b => new BookInternalPublisher( + b.Id, + b.Title, + new PublisherInternal( + b.Publisher.Id, + b.Publisher.Name + ) + )) + )); + } + catch (IdNotFoundException ex) + { + return TypedResults.NotFound(new { ex.Message }); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + public static async Task UpdateAuthor(IRepository bookRepository, IRepository authorRepository, int id, AuthorPut entity) + { + try + { + Author author = await bookRepository.Get(id); + if (entity.FirstName != null) author.FirstName = entity.FirstName; + if (entity.LastName != null) author.LastName = entity.LastName; + if (entity.Email != null) author.Email = entity.Email; + + author = await bookRepository.Update(author); + return TypedResults.Ok(new AuthorView( + author.Id, + author.FirstName, + author.LastName, + author.Email, + author.Books.Select(b => new BookInternalPublisher( + b.Id, + b.Title, + new PublisherInternal( + b.Publisher.Id, + b.Publisher.Name + ) + )) + )); + } + catch (IdNotFoundException ex) + { + return TypedResults.NotFound(new { ex.Message }); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + public static async Task DeleteAuthor(IRepository repository, int id) + { + try + { + Author author = await repository.Delete(id); + return TypedResults.Ok(new { Message = $"Deleted Author with FirstName = {author.FirstName}" }); + } + catch (IdNotFoundException ex) + { + return TypedResults.NotFound(new { ex.Message }); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + } +} diff --git a/exercise.webapi/Endpoints/BookApi.cs b/exercise.webapi/Endpoints/BookApi.cs deleted file mode 100644 index 6758215..0000000 --- a/exercise.webapi/Endpoints/BookApi.cs +++ /dev/null @@ -1,20 +0,0 @@ -using exercise.webapi.Models; -using exercise.webapi.Repository; -using static System.Reflection.Metadata.BlobBuilder; - -namespace exercise.webapi.Endpoints -{ - public static class BookApi - { - public static void ConfigureBooksApi(this WebApplication app) - { - app.MapGet("/books", GetBooks); - } - - private static async Task GetBooks(IBookRepository bookRepository) - { - var books = await bookRepository.GetAllBooks(); - return TypedResults.Ok(books); - } - } -} diff --git a/exercise.webapi/Endpoints/BookEndpoints.cs b/exercise.webapi/Endpoints/BookEndpoints.cs new file mode 100644 index 0000000..91fd600 --- /dev/null +++ b/exercise.webapi/Endpoints/BookEndpoints.cs @@ -0,0 +1,325 @@ +using exercise.webapi.DTO; +using exercise.webapi.Exceptions; +using exercise.webapi.Models; +using exercise.webapi.Repository; +using Microsoft.AspNetCore.Mvc; + +namespace exercise.webapi.Endpoints +{ + public static class BookEndpoints + { + public static string Path { get; private set; } = "books"; + + public static void ConfigureBooksEndpoints(this WebApplication app) + { + var group = app.MapGroup(Path); + + group.MapPost("/", CreateBook); + group.MapGet("/", GetBooks); + group.MapGet("/{id}", GetBook); + group.MapPut("/{id}", UpdateBook); + group.MapDelete("/{id}", DeleteBook); + group.MapPost("/author/add/{id}", AddAuthor); + group.MapPost("/author/remove/{id}", RemoveAuthor); + } + + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public static async Task CreateBook(IRepository bookRepository, IRepository authorRepository, IRepository publisherRepository, BookPost entity) + { + try + { + Author author = await authorRepository.Get(entity.AuthorId); + Publisher publisher = await publisherRepository.Get(entity.PublisherId); + Book book = await bookRepository.Add(new Book + { + Title = entity.Title, + PublisherId = entity.PublisherId, + }); + book.Authors.Add(author); + await bookRepository.Update(book); + return TypedResults.Created($"/{Path}/{book.Id}", new BookView( + book.Id, + book.Title, + [new AuthorInternal( + author.Id, + author.FirstName, + author.LastName, + author.Email + )], + new PublisherInternal( + publisher.Id, + publisher.Name + ), + null + )); + } + catch (IdNotFoundException ex) + { + return TypedResults.NotFound(new {ex.Message}); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public static async Task GetBooks(IRepository repository, string? isCheckedOut, string? isOverdue) + { + try + { + IEnumerable books = await repository.GetAll(); + if (!string.IsNullOrEmpty(isCheckedOut)) + { + bool value = false; + if (!bool.TryParse(isCheckedOut, out value)) + { + return TypedResults.BadRequest(new { Message = "The query param 'isCheckedOut' should be true / false" }); + } + if (value) + { + books = books.Where(book => book.Checkouts.Count != 0 && book.Checkouts.Any(checkout => checkout.ReturnTime == null)); + } else + { + books = books.Where(book => book.Checkouts.Count == 0 || book.Checkouts.All(checkout => checkout.ReturnTime != null)); + } + } + if (!string.IsNullOrEmpty(isOverdue)) + { + bool value = false; + if (!bool.TryParse(isOverdue, out value)) + { + return TypedResults.BadRequest(new { Message = "The query param 'isOverdue' should be true / false" }); + } + if (value) + { + books = books.Where(book => book.Checkouts.Count != 0 && book.Checkouts.Any(checkout => checkout.ReturnTime == null && checkout.ExpectedReturnTime < DateTime.UtcNow)); + } else + { + books = books.Where(book => book.Checkouts.Count == 0 || book.Checkouts.All(checkout => + (checkout.ReturnTime == null && checkout.ExpectedReturnTime > DateTime.UtcNow) + || (checkout.ReturnTime != null && checkout.ExpectedReturnTime > checkout.ReturnTime) + )); + } + } + return TypedResults.Ok(books.Select(b => + { + Checkout? checkout = b.Checkouts.OrderByDescending(c => c.CheckoutTime).FirstOrDefault(); + + return new BookView( + b.Id, + b.Title, + b.Authors.Select(author => new AuthorInternal( + author.Id, + author.FirstName, + author.LastName, + author.Email + )), + new PublisherInternal( + b.Publisher.Id, + b.Publisher.Name + ), + checkout == null ? null : new CheckoutInternal( + checkout.Id, + checkout.CheckoutTime, + checkout.ReturnTime, + checkout.ExpectedReturnTime + ) + ); + })); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public static async Task GetBook(IRepository repository, int id) + { + try + { + Book book = await repository.Get(id); + Checkout? checkout = book.Checkouts.OrderByDescending(c => c.CheckoutTime).FirstOrDefault(); + return TypedResults.Ok(new BookView( + book.Id, + book.Title, + book.Authors.Select(author => new AuthorInternal( + author.Id, + author.FirstName, + author.LastName, + author.Email + )), + new PublisherInternal( + book.Publisher.Id, + book.Publisher.Name + ), + checkout == null ? null : new CheckoutInternal( + checkout.Id, + checkout.CheckoutTime, + checkout.ReturnTime, + checkout.ExpectedReturnTime + ) + )); + } + catch (IdNotFoundException ex) + { + return TypedResults.NotFound(new { ex.Message }); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public static async Task UpdateBook(IRepository repository, int id, BookPut entity) + { + try + { + Book book = await repository.Get(id); + if (entity.Title != null) book.Title = entity.Title; + + book = await repository.Update(book); + Checkout? checkout = book.Checkouts.OrderByDescending(c => c.CheckoutTime).FirstOrDefault(); + return TypedResults.Ok(new BookView( + book.Id, + book.Title, + book.Authors.Select(author => new AuthorInternal( + author.Id, + author.FirstName, + author.LastName, + author.Email + )), + new PublisherInternal( + book.Publisher.Id, + book.Publisher.Name + ), + checkout == null ? null : new CheckoutInternal( + checkout.Id, + checkout.CheckoutTime, + checkout.ReturnTime, + checkout.ExpectedReturnTime + ) + )); + } + catch (IdNotFoundException ex) + { + return TypedResults.NotFound(new { ex.Message }); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public static async Task DeleteBook(IRepository repository, int id) + { + try + { + Book book = await repository.Delete(id); + return TypedResults.Ok(new { Message = $"Deleted Book with Title = {book.Title}" }); + } + catch (IdNotFoundException ex) + { + return TypedResults.NotFound(new { ex.Message }); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + public static async Task AddAuthor(IRepository bookRepository, IRepository authorRepository, int id, int authorId) + { + try + { + Book book = await bookRepository.Get(id); + if (book.Authors.Any(a => a.Id == authorId)) return TypedResults.BadRequest(new { Message = $"Author with ID = {authorId} is already registered to this book!" }); + Author author = await authorRepository.Get(authorId); + book.Authors.Add(author); + book = await bookRepository.Update(book); + Checkout? checkout = book.Checkouts.OrderByDescending(c => c.CheckoutTime).FirstOrDefault(); + + return TypedResults.Ok(new BookView( + book.Id, + book.Title, + book.Authors.Select(a => new AuthorInternal( + a.Id, + a.FirstName, + a.LastName, + a.Email + )), + new PublisherInternal( + book.Publisher.Id, + book.Publisher.Name + ), + checkout == null ? null : new CheckoutInternal( + checkout.Id, + checkout.CheckoutTime, + checkout.ReturnTime, + checkout.ExpectedReturnTime + ) + )); + } + catch (IdNotFoundException ex) + { + return TypedResults.NotFound(new { ex.Message }); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + public static async Task RemoveAuthor(IRepository bookRepository, IRepository authorRepository, int id, int authorId) + { + try + { + Book book = await bookRepository.Get(id); + if (book.Authors.Count <= 1) return TypedResults.BadRequest(new { Message = $"Book must have at least one author!" }); + Author author = await authorRepository.Get(authorId); + book.Authors.Remove(author); + book = await bookRepository.Update(book); + Checkout? checkout = book.Checkouts.OrderByDescending(c => c.CheckoutTime).FirstOrDefault(); + + return TypedResults.Ok(new BookView( + book.Id, + book.Title, + book.Authors.Select(a => new AuthorInternal( + a.Id, + a.FirstName, + a.LastName, + a.Email + )), + new PublisherInternal( + book.Publisher.Id, + book.Publisher.Name + ), + checkout == null ? null : new CheckoutInternal( + checkout.Id, + checkout.CheckoutTime, + checkout.ReturnTime, + checkout.ExpectedReturnTime + ) + )); + } + catch (IdNotFoundException ex) + { + return TypedResults.NotFound(new { ex.Message }); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + } +} diff --git a/exercise.webapi/Endpoints/CheckoutEndpoints.cs b/exercise.webapi/Endpoints/CheckoutEndpoints.cs new file mode 100644 index 0000000..4d8b98f --- /dev/null +++ b/exercise.webapi/Endpoints/CheckoutEndpoints.cs @@ -0,0 +1,187 @@ + +using exercise.webapi.DTO; +using exercise.webapi.Exceptions; +using exercise.webapi.Models; +using exercise.webapi.Repository; +using Microsoft.AspNetCore.Mvc; + +namespace exercise.webapi.Endpoints +{ + public static class CheckoutEndpoints + { + public static string Path { get; private set; } = "checkouts"; + + public static void ConfigureCheckoutEndpoints(this WebApplication app) + { + var group = app.MapGroup(Path); + + group.MapGet("/", GetCheckouts); + group.MapGet("/{id}", GetCheckout); + group.MapPost("/checkout", CheckoutBook); + group.MapPost("/return", ReturnBook); + } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + private static async Task GetCheckouts(IRepository repository) + { + try + { + IEnumerable checkouts = await repository.GetAll(); + return TypedResults.Ok(checkouts.Select(checkout => + { + return new CheckoutView( + checkout.Id, + checkout.CheckoutTime, + checkout.ReturnTime, + checkout.ExpectedReturnTime, + new BookInternalAuthorPublisher( + checkout.Book.Id, + checkout.Book.Title, + checkout.Book.Authors.Select(author => new AuthorInternal( + author.Id, + author.FirstName, + author.LastName, + author.Email + )), + new PublisherInternal( + checkout.Book.Publisher.Id, + checkout.Book.Publisher.Name + ) + ) + ); + })); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + private static async Task GetCheckout(IRepository repository, int id) + { + try + { + Checkout checkout = await repository.Get(id); + return TypedResults.Ok(new CheckoutView( + checkout.Id, + checkout.CheckoutTime, + checkout.ReturnTime, + checkout.ExpectedReturnTime, + new BookInternalAuthorPublisher( + checkout.Book.Id, + checkout.Book.Title, + checkout.Book.Authors.Select(author => new AuthorInternal( + author.Id, + author.FirstName, + author.LastName, + author.Email + )), + new PublisherInternal( + checkout.Book.Publisher.Id, + checkout.Book.Publisher.Name + ) + ))); + } + catch (IdNotFoundException ex) + { + return TypedResults.NotFound(new { ex.Message }); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + private static async Task CheckoutBook(IRepository checkoutRepository, IRepository bookRepository, CheckoutPost entity) + { + try + { + Book book = await bookRepository.Get(entity.BookId); + if (book.Checkouts.Any(checkout => checkout.ReturnTime == null)) return TypedResults.Conflict(new { Message = "That book is already checked out!" }); + Checkout checkout = await checkoutRepository.Add(new Checkout + { + BookId = book.Id, + }); + return TypedResults.Created($"/{Path}/{checkout.Id}", new CheckoutView( + checkout.Id, + checkout.CheckoutTime, + checkout.ReturnTime, + checkout.ExpectedReturnTime, + new BookInternalAuthorPublisher( + checkout.Book.Id, + checkout.Book.Title, + checkout.Book.Authors.Select(author => new AuthorInternal( + author.Id, + author.FirstName, + author.LastName, + author.Email + )), + new PublisherInternal( + checkout.Book.Publisher.Id, + checkout.Book.Publisher.Name + ) + ))); + } + catch (IdNotFoundException ex) + { + return TypedResults.NotFound(new { ex.Message }); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + private static async Task ReturnBook(IRepository checkoutRepository, IRepository bookRepository, CheckoutPost entity) + { + try + { + Book book = await bookRepository.Get(entity.BookId); + if (book.Checkouts.All(checkout => checkout.ReturnTime != null)) return TypedResults.Conflict(new { Message = "That book is not checked out!" }); + Checkout? checkout = book.Checkouts.Where(c => c.ReturnTime == null).FirstOrDefault(); + if (checkout == null) + { + return TypedResults.Problem("This should never happen but okay..."); + } + checkout.ReturnTime = DateTime.UtcNow; + checkout = await checkoutRepository.Update(checkout); + return TypedResults.Ok(new CheckoutView( + checkout.Id, + checkout.CheckoutTime, + checkout.ReturnTime, + checkout.ExpectedReturnTime, + new BookInternalAuthorPublisher( + checkout.Book.Id, + checkout.Book.Title, + checkout.Book.Authors.Select(author => new AuthorInternal( + author.Id, + author.FirstName, + author.LastName, + author.Email + )), + new PublisherInternal( + checkout.Book.Publisher.Id, + checkout.Book.Publisher.Name + ) + ))); + } + catch (IdNotFoundException ex) + { + return TypedResults.NotFound(new { ex.Message }); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + } +} diff --git a/exercise.webapi/Endpoints/PublisherEndpoints.cs b/exercise.webapi/Endpoints/PublisherEndpoints.cs new file mode 100644 index 0000000..039fa13 --- /dev/null +++ b/exercise.webapi/Endpoints/PublisherEndpoints.cs @@ -0,0 +1,75 @@ +using exercise.webapi.DTO; +using exercise.webapi.Exceptions; +using exercise.webapi.Models; +using exercise.webapi.Repository; + +namespace exercise.webapi.Endpoints +{ + public static class PublisherEndpoints + { + public static string Path { get; private set; } = "publishers"; + + public static void ConfigurePublisherEndpoints(this WebApplication app) + { + var group = app.MapGroup(Path); + + group.MapGet("/", GetPublishers); + group.MapGet("/{id}", GetPublisher); + } + public static async Task GetPublishers(IRepository repository) + { + try + { + IEnumerable publishers = await repository.GetAll(); + return TypedResults.Ok(publishers.Select(publisher => new PublisherView( + publisher.Id, + publisher.Name, + publisher.Books.Select(book => new BookInternalAuthor( + book.Id, + book.Title, + book.Authors.Select(author => new AuthorInternal( + author.Id, + author.FirstName, + author.LastName, + author.Email + )) + )) + ))); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + + public static async Task GetPublisher(IRepository repository, int id) + { + try + { + Publisher publisher = await repository.Get(id); + return TypedResults.Ok(new PublisherView( + publisher.Id, + publisher.Name, + publisher.Books.Select(book => new BookInternalAuthor( + book.Id, + book.Title, + book.Authors.Select(author => new AuthorInternal( + author.Id, + author.FirstName, + author.LastName, + author.Email + )) + )) + )); + } + catch (IdNotFoundException ex) + { + return TypedResults.NotFound(new { ex.Message }); + } + catch (Exception ex) + { + return TypedResults.Problem(ex.Message); + } + } + } +} diff --git a/exercise.webapi/Exceptions/IdNotFoundException.cs b/exercise.webapi/Exceptions/IdNotFoundException.cs new file mode 100644 index 0000000..82f7793 --- /dev/null +++ b/exercise.webapi/Exceptions/IdNotFoundException.cs @@ -0,0 +1,11 @@ +namespace exercise.webapi.Exceptions +{ + public class IdNotFoundException : ArgumentException + { + public IdNotFoundException() { } + + public IdNotFoundException(string? message) : base(message) { } + + public IdNotFoundException(string? message, Exception? innerException) : base(message, innerException) { } + } +} diff --git a/exercise.webapi/Models/Author.cs b/exercise.webapi/Models/Author.cs index 9f47878..99b00b6 100644 --- a/exercise.webapi/Models/Author.cs +++ b/exercise.webapi/Models/Author.cs @@ -9,7 +9,7 @@ public class Author public string LastName { get; set; } public string Email { get; set; } - [JsonIgnore] // Todo: replace this with DTO approach - public ICollection Books { get; set; } = new List(); + public List Books { get; set; } = []; + public List AuthorBooks { get; set; } = []; } } diff --git a/exercise.webapi/Models/AuthorBook.cs b/exercise.webapi/Models/AuthorBook.cs new file mode 100644 index 0000000..6de7c30 --- /dev/null +++ b/exercise.webapi/Models/AuthorBook.cs @@ -0,0 +1,10 @@ +namespace exercise.webapi.Models +{ + public class AuthorBook + { + public int AuthorId { get; set; } + public Author Author { get; set; } + public int BookId { get; set; } + public Book Book { get; set; } + } +} diff --git a/exercise.webapi/Models/Book.cs b/exercise.webapi/Models/Book.cs index 9534929..4fc1dee 100644 --- a/exercise.webapi/Models/Book.cs +++ b/exercise.webapi/Models/Book.cs @@ -7,7 +7,12 @@ public class Book public int Id { get; set; } public string Title { get; set; } - public int AuthorId { get; set; } - public Author Author { get; set; } + public List Authors { get; set; } = []; + public List AuthorBooks { get; set; } = []; + + public int PublisherId { get; set; } + public Publisher Publisher { get; set; } + + public List Checkouts { get; set; } } } diff --git a/exercise.webapi/Models/Checkout.cs b/exercise.webapi/Models/Checkout.cs new file mode 100644 index 0000000..814a9ac --- /dev/null +++ b/exercise.webapi/Models/Checkout.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace exercise.webapi.Models +{ + public class Checkout + { + public int Id { get; set; } + public int BookId { get; set; } + public Book Book { get; set; } + public DateTime CheckoutTime { get; set; } = DateTime.UtcNow; + public DateTime? ReturnTime { get; set; } + + [NotMapped] + public DateTime ExpectedReturnTime { get { return CheckoutTime.AddDays(14); } } + } +} diff --git a/exercise.webapi/Models/Publisher.cs b/exercise.webapi/Models/Publisher.cs new file mode 100644 index 0000000..65596c4 --- /dev/null +++ b/exercise.webapi/Models/Publisher.cs @@ -0,0 +1,10 @@ +namespace exercise.webapi.Models +{ + public class Publisher + { + public int Id { get; set; } + public string Name { get; set; } + + public List Books { get; set; } = []; + } +} diff --git a/exercise.webapi/Program.cs b/exercise.webapi/Program.cs index 43dec56..d6306d5 100644 --- a/exercise.webapi/Program.cs +++ b/exercise.webapi/Program.cs @@ -1,32 +1,50 @@ using exercise.webapi.Data; using exercise.webapi.Endpoints; +using exercise.webapi.Models; using exercise.webapi.Repository; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Scalar.AspNetCore; 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(); -builder.Services.AddDbContext(opt => opt.UseInMemoryDatabase("Library")); -builder.Services.AddScoped(); +builder.Services.AddOpenApi(); -var app = builder.Build(); +var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .Build(); -using (var dbContext = new DataContext(new DbContextOptions())) +builder.Services.AddDbContext(options => { - dbContext.Database.EnsureCreated(); -} + var connectionString = configuration.GetConnectionString("DefaultConnectionString"); + options.UseNpgsql(connectionString); + + options.ConfigureWarnings(warnings => + warnings.Ignore(RelationalEventId.PendingModelChangesWarning)); +}); + + +builder.Services.AddScoped, BookRepository>(); +builder.Services.AddScoped, AuthorRepository>(); +builder.Services.AddScoped, PublisherRepository>(); +builder.Services.AddScoped, CheckoutRepository>(); + +var app = builder.Build(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { - app.UseSwagger(); - app.UseSwaggerUI(); + app.MapOpenApi(); + app.MapScalarApiReference(); + app.MapSwagger(); } app.UseHttpsRedirection(); -app.ConfigureBooksApi(); +app.ConfigureBooksEndpoints(); +app.ConfigureAuthorsEndpoints(); +app.ConfigureCheckoutEndpoints(); +app.ConfigurePublisherEndpoints(); app.Run(); diff --git a/exercise.webapi/Properties/launchSettings.json b/exercise.webapi/Properties/launchSettings.json index 27b9975..60aac02 100644 --- a/exercise.webapi/Properties/launchSettings.json +++ b/exercise.webapi/Properties/launchSettings.json @@ -1,41 +1,24 @@ { - "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:7277", - "sslPort": 44338 - } - }, "profiles": { "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "http://localhost:5201", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "launchUrl": "scalar/v1", + "applicationUrl": "http://localhost:5201", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } }, "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", + "launchBrowser": false, + "launchUrl": "scalar/v1", "applicationUrl": "https://localhost:7054;http://localhost:5201", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } } } } diff --git a/exercise.webapi/Repository/AuthorRepository.cs b/exercise.webapi/Repository/AuthorRepository.cs new file mode 100644 index 0000000..d203135 --- /dev/null +++ b/exercise.webapi/Repository/AuthorRepository.cs @@ -0,0 +1,52 @@ +using exercise.webapi.Data; +using exercise.webapi.DTO; +using exercise.webapi.Exceptions; +using exercise.webapi.Models; +using Microsoft.EntityFrameworkCore; + +namespace exercise.webapi.Repository +{ + public class AuthorRepository(DataContext db) : IRepository + { + private DataContext _db = db; + + public async Task Add(Author entity) + { + await _db.AddAsync(entity); + await _db.SaveChangesAsync(); + return entity; + } + + public async Task Delete(int id) + { + Author author = await Get(id); + _db.Remove(author); + await _db.SaveChangesAsync(); + return author; + } + + public async Task Get(int id) + { + Author? author = await _db.Authors.Include(a => a.Books).ThenInclude(b => b.Publisher).SingleOrDefaultAsync(x => x.Id == id); + if (author == null) throw new IdNotFoundException("The provided author Id does not exist!"); + return author; + } + + public async Task> GetAll() + { + return await _db.Authors.Include(a => a.Books).ThenInclude(b => b.Publisher).ToListAsync(); + } + + public async Task Update(Author entity) + { + if (_db.Entry(entity).State == EntityState.Detached) + { + _db.Authors.Attach(entity); + _db.Entry(entity).State = EntityState.Modified; + } + + await _db.SaveChangesAsync(); + return entity; + } + } +} diff --git a/exercise.webapi/Repository/BookRepository.cs b/exercise.webapi/Repository/BookRepository.cs index 1f5e64a..3635071 100644 --- a/exercise.webapi/Repository/BookRepository.cs +++ b/exercise.webapi/Repository/BookRepository.cs @@ -1,10 +1,12 @@ using exercise.webapi.Data; +using exercise.webapi.DTO; +using exercise.webapi.Exceptions; using exercise.webapi.Models; using Microsoft.EntityFrameworkCore; namespace exercise.webapi.Repository { - public class BookRepository: IBookRepository + public class BookRepository : IRepository { DataContext _db; @@ -13,10 +15,43 @@ public BookRepository(DataContext db) _db = db; } - public async Task> GetAllBooks() + public async Task Add(Book entity) { - return await _db.Books.Include(b => b.Author).ToListAsync(); + await _db.Books.AddAsync(entity); + await _db.SaveChangesAsync(); + return entity; + } + + public async Task Delete(int id) + { + Book book = await Get(id); + _db.Books.Remove(book); + await _db.SaveChangesAsync(); + return book; + } + + public async Task Get(int id) + { + Book? book = await _db.Books.Include(b => b.Authors).Include(b => b.Publisher).Include(b => b.Checkouts).SingleOrDefaultAsync(x => x.Id == id); + if (book == null) throw new IdNotFoundException("The provided book Id does not exist!"); + return book; + } + + public async Task> GetAll() + { + return await _db.Books.Include(book => book.Authors).Include(b => b.Publisher).Include(b => b.Checkouts).ToListAsync(); + } + + public async Task Update(Book entity) + { + if (_db.Entry(entity).State == EntityState.Detached) + { + _db.Books.Attach(entity); + _db.Entry(entity).State = EntityState.Modified; + } + await _db.SaveChangesAsync(); + return entity; } } } diff --git a/exercise.webapi/Repository/CheckoutRepository.cs b/exercise.webapi/Repository/CheckoutRepository.cs new file mode 100644 index 0000000..15b1b48 --- /dev/null +++ b/exercise.webapi/Repository/CheckoutRepository.cs @@ -0,0 +1,52 @@ +using exercise.webapi.Data; +using exercise.webapi.DTO; +using exercise.webapi.Exceptions; +using exercise.webapi.Models; +using Microsoft.EntityFrameworkCore; + +namespace exercise.webapi.Repository +{ + public class CheckoutRepository(DataContext db) : IRepository + { + DataContext _db = db; + + public async Task Add(Checkout entity) + { + await _db.Checkouts.AddAsync(entity); + await _db.SaveChangesAsync(); + return entity; + } + + public async Task Delete(int id) + { + Checkout checkout = await Get(id); + _db.Checkouts.Remove(checkout); + await _db.SaveChangesAsync(); + return checkout; + } + + public async Task Get(int id) + { + Checkout? checkout = await _db.Checkouts.Include(p => p.Book).ThenInclude(b => b.Authors).Include(p => p.Book).ThenInclude(b => b.Publisher).SingleOrDefaultAsync(x => x.Id == id); + if (checkout == null) throw new IdNotFoundException("The provided checkout Id does not exist!"); + return checkout; + } + + public async Task> GetAll() + { + return await _db.Checkouts.Include(checkout => checkout.Book).ThenInclude(b => b.Authors).Include(p => p.Book).ThenInclude(b => b.Publisher).ToListAsync(); + } + + public async Task Update(Checkout entity) + { + if (_db.Entry(entity).State == EntityState.Detached) + { + _db.Checkouts.Attach(entity); + _db.Entry(entity).State = EntityState.Modified; + } + + await _db.SaveChangesAsync(); + return entity; + } + } +} diff --git a/exercise.webapi/Repository/IBookRepository.cs b/exercise.webapi/Repository/IBookRepository.cs deleted file mode 100644 index f860016..0000000 --- a/exercise.webapi/Repository/IBookRepository.cs +++ /dev/null @@ -1,9 +0,0 @@ -using exercise.webapi.Models; - -namespace exercise.webapi.Repository -{ - public interface IBookRepository - { - public Task> GetAllBooks(); - } -} diff --git a/exercise.webapi/Repository/IRepository.cs b/exercise.webapi/Repository/IRepository.cs new file mode 100644 index 0000000..37c2cb6 --- /dev/null +++ b/exercise.webapi/Repository/IRepository.cs @@ -0,0 +1,11 @@ +namespace exercise.webapi.Repository +{ + public interface IRepository where T : class + { + Task Get(int id); + Task> GetAll(); + Task Add(T entity); + Task Update(T entity); + Task Delete(int id); + } +} diff --git a/exercise.webapi/Repository/PublisherRepository.cs b/exercise.webapi/Repository/PublisherRepository.cs new file mode 100644 index 0000000..1018413 --- /dev/null +++ b/exercise.webapi/Repository/PublisherRepository.cs @@ -0,0 +1,52 @@ +using exercise.webapi.Data; +using exercise.webapi.DTO; +using exercise.webapi.Exceptions; +using exercise.webapi.Models; +using Microsoft.EntityFrameworkCore; + +namespace exercise.webapi.Repository +{ + public class PublisherRepository(DataContext db) : IRepository + { + DataContext _db = db; + + public async Task Add(Publisher entity) + { + await _db.Publishers.AddAsync(entity); + await _db.SaveChangesAsync(); + return entity; + } + + public async Task Delete(int id) + { + Publisher publisher = await Get(id); + _db.Publishers.Remove(publisher); + await _db.SaveChangesAsync(); + return publisher; + } + + public async Task Get(int id) + { + Publisher? publisher = await _db.Publishers.Include(b => b.Books).ThenInclude(book => book.Authors).SingleOrDefaultAsync(x => x.Id == id); + if (publisher == null) throw new IdNotFoundException("The provided publisher Id does not exist!"); + return publisher; + } + + public async Task> GetAll() + { + return await _db.Publishers.Include(publisher => publisher.Books).ThenInclude(book => book.Authors).ToListAsync(); + } + + public async Task Update(Publisher entity) + { + if (_db.Entry(entity).State == EntityState.Detached) + { + _db.Publishers.Attach(entity); + _db.Entry(entity).State = EntityState.Modified; + } + + await _db.SaveChangesAsync(); + return entity; + } + } +} diff --git a/exercise.webapi/exercise.webapi.csproj b/exercise.webapi/exercise.webapi.csproj index 0ff4269..5ca671c 100644 --- a/exercise.webapi/exercise.webapi.csproj +++ b/exercise.webapi/exercise.webapi.csproj @@ -8,10 +8,16 @@ - - + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + - +