├── images └── swagger.png ├── src └── Supermarket.API │ ├── GlobalUsings.cs │ ├── Domain │ ├── Repositories │ │ ├── IUnitOfWork.cs │ │ ├── ICategoryRepository.cs │ │ └── IProductRepository.cs │ ├── Models │ │ ├── Category.cs │ │ ├── Queries │ │ │ ├── QueryResult.cs │ │ │ ├── ProductsQuery.cs │ │ │ └── Query.cs │ │ ├── Product.cs │ │ └── UnitOfMeasurement.cs │ └── Services │ │ ├── IProductService.cs │ │ ├── ICategoryService.cs │ │ └── Communication │ │ └── Response.cs │ ├── Infrastructure │ └── CacheKeys.cs │ ├── Resources │ ├── ProductsQueryResource.cs │ ├── CategoryResource.cs │ ├── QueryResource.cs │ ├── QueryResultResource.cs │ ├── SaveCategoryResource.cs │ ├── ProductResource.cs │ ├── SaveProductResource.cs │ └── ErrorResource.cs │ ├── appsettings.Development.json │ ├── appsettings.json │ ├── Controllers │ ├── BaseApiController.cs │ ├── Config │ │ └── InvalidModelStateResponseFactory.cs │ ├── CategoriesController.cs │ └── ProductsController.cs │ ├── Persistence │ ├── Repositories │ │ ├── BaseRepository.cs │ │ ├── UnitOfWork.cs │ │ ├── CategoryRepository.cs │ │ └── ProductRepository.cs │ └── Contexts │ │ ├── AppDbContext.cs │ │ ├── Configurations │ │ ├── CategoryConfiguration.cs │ │ └── ProductConfiguration.cs │ │ └── SeedData.cs │ ├── Extensions │ ├── ModelStateExtensions.cs │ ├── EnumExtensions.cs │ └── MiddlewareExtensions.cs │ ├── .dockerignore │ ├── Mapping │ ├── ResourceToModelProfile.cs │ └── ModelToResourceProfile.cs │ ├── Dockerfile │ ├── Properties │ └── launchSettings.json │ ├── Supermarket.API.csproj │ ├── Program.cs │ ├── Supermarket.API.sln │ ├── Startup.cs │ └── Services │ ├── CategoryService.cs │ └── ProductService.cs ├── LICENSE ├── .all-contributorsrc ├── .gitignore └── README.md /images/swagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/evgomes/supermarket-api/HEAD/images/swagger.png -------------------------------------------------------------------------------- /src/Supermarket.API/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Supermarket.API.Domain.Models; 2 | global using Supermarket.API.Domain.Models.Queries; -------------------------------------------------------------------------------- /src/Supermarket.API/Domain/Repositories/IUnitOfWork.cs: -------------------------------------------------------------------------------- 1 | namespace Supermarket.API.Domain.Repositories 2 | { 3 | public interface IUnitOfWork 4 | { 5 | Task CompleteAsync(); 6 | } 7 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Infrastructure/CacheKeys.cs: -------------------------------------------------------------------------------- 1 | namespace Supermarket.API.Infrastructure 2 | { 3 | public enum CacheKeys : byte 4 | { 5 | CategoriesList, 6 | ProductsList, 7 | } 8 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Resources/ProductsQueryResource.cs: -------------------------------------------------------------------------------- 1 | namespace Supermarket.API.Resources 2 | { 3 | public record ProductsQueryResource : QueryResource 4 | { 5 | public int? CategoryId { get; init; } 6 | } 7 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Resources/CategoryResource.cs: -------------------------------------------------------------------------------- 1 | namespace Supermarket.API.Resources 2 | { 3 | public record CategoryResource 4 | { 5 | public required int Id { get; init; } 6 | public required string Name { get; init; } 7 | } 8 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Resources/QueryResource.cs: -------------------------------------------------------------------------------- 1 | namespace Supermarket.API.Resources 2 | { 3 | public record QueryResource 4 | { 5 | public required int Page { get; init; } 6 | public required int ItemsPerPage { get; init; } 7 | } 8 | } -------------------------------------------------------------------------------- /src/Supermarket.API/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Information", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Supermarket.API/Domain/Models/Category.cs: -------------------------------------------------------------------------------- 1 | namespace Supermarket.API.Domain.Models 2 | { 3 | public class Category 4 | { 5 | public int Id { get; set; } 6 | public string Name { get; set; } = null!; 7 | public List Products { get; set; } = new(); 8 | } 9 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Resources/QueryResultResource.cs: -------------------------------------------------------------------------------- 1 | namespace Supermarket.API.Resources 2 | { 3 | public record QueryResultResource 4 | { 5 | public required int TotalItems { get; init; } = 0; 6 | public required List Items { get; init; } = []; 7 | } 8 | } -------------------------------------------------------------------------------- /src/Supermarket.API/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Warning" 5 | } 6 | }, 7 | "AllowedHosts": "*", 8 | "ConnectionStrings": { 9 | "memory": "data-in-memory", 10 | "default": "data source=default.sqlite3" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Supermarket.API/Resources/SaveCategoryResource.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Supermarket.API.Resources 4 | { 5 | public record SaveCategoryResource 6 | { 7 | [Required] 8 | [MaxLength(30)] 9 | public string? Name { get; init; } 10 | } 11 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Controllers/BaseApiController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace Supermarket.API.Controllers 4 | { 5 | [Route("/api/[controller]")] 6 | [Produces("application/json")] 7 | [ApiController] 8 | public class BaseApiController : ControllerBase 9 | { 10 | } 11 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Domain/Models/Queries/QueryResult.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Supermarket.API.Domain.Models.Queries 4 | { 5 | public class QueryResult 6 | { 7 | public List Items { get; set; } = new List(); 8 | public int TotalItems { get; set; } = 0; 9 | } 10 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Domain/Repositories/ICategoryRepository.cs: -------------------------------------------------------------------------------- 1 | namespace Supermarket.API.Domain.Repositories 2 | { 3 | public interface ICategoryRepository 4 | { 5 | Task> ListAsync(); 6 | Task AddAsync(Category category); 7 | Task FindByIdAsync(int id); 8 | void Update(Category category); 9 | void Remove(Category category); 10 | } 11 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Domain/Models/Queries/ProductsQuery.cs: -------------------------------------------------------------------------------- 1 | namespace Supermarket.API.Domain.Models.Queries 2 | { 3 | public class ProductsQuery : Query 4 | { 5 | public int? CategoryId { get; set; } 6 | 7 | public ProductsQuery(int? categoryId, int page, int itemsPerPage) : base(page, itemsPerPage) 8 | { 9 | CategoryId = categoryId; 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Domain/Models/Product.cs: -------------------------------------------------------------------------------- 1 | namespace Supermarket.API.Domain.Models 2 | { 3 | public class Product 4 | { 5 | public int Id { get; set; } 6 | public string Name { get; set; } = null!; 7 | public short QuantityInPackage { get; set; } 8 | public UnitOfMeasurement UnitOfMeasurement { get; set; } 9 | 10 | public int CategoryId { get; set; } 11 | public Category? Category { get; set; } 12 | } 13 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Persistence/Repositories/BaseRepository.cs: -------------------------------------------------------------------------------- 1 | using Supermarket.API.Persistence.Contexts; 2 | 3 | namespace Supermarket.API.Persistence.Repositories 4 | { 5 | public abstract class BaseRepository 6 | { 7 | protected readonly AppDbContext _context; 8 | 9 | public BaseRepository(AppDbContext context) 10 | { 11 | _context = context; 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Domain/Repositories/IProductRepository.cs: -------------------------------------------------------------------------------- 1 | namespace Supermarket.API.Domain.Repositories 2 | { 3 | public interface IProductRepository 4 | { 5 | Task> ListAsync(ProductsQuery query); 6 | Task AddAsync(Product product); 7 | Task FindByIdAsync(int id); 8 | void Update(Product product); 9 | void Remove(Product product); 10 | } 11 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Resources/ProductResource.cs: -------------------------------------------------------------------------------- 1 | namespace Supermarket.API.Resources 2 | { 3 | public record ProductResource 4 | { 5 | public required int Id { get; init; } 6 | public required string Name { get; init; } 7 | public required int QuantityInPackage { get; init; } 8 | public required string UnitOfMeasurement { get; init; } 9 | public CategoryResource? Category { get; init; } 10 | } 11 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Domain/Services/IProductService.cs: -------------------------------------------------------------------------------- 1 | using Supermarket.API.Domain.Services.Communication; 2 | 3 | namespace Supermarket.API.Domain.Services 4 | { 5 | public interface IProductService 6 | { 7 | Task> ListAsync(ProductsQuery query); 8 | Task> SaveAsync(Product product); 9 | Task> UpdateAsync(int id, Product product); 10 | Task> DeleteAsync(int id); 11 | } 12 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Extensions/ModelStateExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.ModelBinding; 2 | 3 | namespace Supermarket.API.Extensions 4 | { 5 | public static class ModelStateExtensions 6 | { 7 | public static List GetErrorMessages(this ModelStateDictionary dictionary) 8 | => dictionary 9 | .SelectMany(m => m.Value!.Errors) 10 | .Select(m => m.ErrorMessage) 11 | .ToList(); 12 | } 13 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Domain/Services/ICategoryService.cs: -------------------------------------------------------------------------------- 1 | using Supermarket.API.Domain.Services.Communication; 2 | 3 | namespace Supermarket.API.Domain.Services 4 | { 5 | public interface ICategoryService 6 | { 7 | Task> ListAsync(); 8 | Task> SaveAsync(Category category); 9 | Task> UpdateAsync(int id, Category category); 10 | Task> DeleteAsync(int id); 11 | } 12 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Persistence/Repositories/UnitOfWork.cs: -------------------------------------------------------------------------------- 1 | using Supermarket.API.Domain.Repositories; 2 | using Supermarket.API.Persistence.Contexts; 3 | 4 | namespace Supermarket.API.Persistence.Repositories 5 | { 6 | public class UnitOfWork(AppDbContext context) : IUnitOfWork 7 | { 8 | private readonly AppDbContext _context = context; 9 | 10 | public async Task CompleteAsync() 11 | { 12 | await _context.SaveChangesAsync(); 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Domain/Models/UnitOfMeasurement.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | 3 | namespace Supermarket.API.Domain.Models 4 | { 5 | public enum UnitOfMeasurement : byte 6 | { 7 | [Description("UN")] 8 | Unity = 1, 9 | 10 | [Description("MG")] 11 | Milligram = 2, 12 | 13 | [Description("G")] 14 | Gram = 3, 15 | 16 | [Description("KG")] 17 | Kilogram = 4, 18 | 19 | [Description("L")] 20 | Liter = 5 21 | } 22 | } -------------------------------------------------------------------------------- /src/Supermarket.API/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md 26 | !**/.gitignore 27 | !.git/HEAD 28 | !.git/config 29 | !.git/packed-refs 30 | !.git/refs/heads/** -------------------------------------------------------------------------------- /src/Supermarket.API/Mapping/ResourceToModelProfile.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using Supermarket.API.Resources; 3 | 4 | namespace Supermarket.API.Mapping 5 | { 6 | public class ResourceToModelProfile : Profile 7 | { 8 | public ResourceToModelProfile() 9 | { 10 | CreateMap(); 11 | 12 | CreateMap() 13 | .ForMember(src => src.UnitOfMeasurement, opt => opt.MapFrom(src => (UnitOfMeasurement)src.UnitOfMeasurement)); 14 | 15 | CreateMap(); 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Resources/SaveProductResource.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Supermarket.API.Resources 4 | { 5 | public record SaveProductResource 6 | { 7 | [Required] 8 | [MaxLength(50)] 9 | public string? Name { get; init; } 10 | 11 | [Required] 12 | [Range(0, 100)] 13 | public short QuantityInPackage { get; init; } 14 | 15 | [Required] 16 | [Range(1, 5)] 17 | public int UnitOfMeasurement { get; init; } 18 | 19 | [Required] 20 | public int CategoryId { get; init; } 21 | } 22 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Persistence/Contexts/AppDbContext.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | namespace Supermarket.API.Persistence.Contexts 5 | { 6 | public class AppDbContext(DbContextOptions options) : DbContext(options) 7 | { 8 | public DbSet Categories { get; set; } 9 | public DbSet Products { get; set; } 10 | 11 | protected override void OnModelCreating(ModelBuilder modelBuilder) 12 | { 13 | base.OnModelCreating(modelBuilder); 14 | modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Controllers/Config/InvalidModelStateResponseFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Supermarket.API.Extensions; 3 | using Supermarket.API.Resources; 4 | 5 | namespace Supermarket.API.Controllers.Config 6 | { 7 | public static class InvalidModelStateResponseFactory 8 | { 9 | public static IActionResult ProduceErrorResponse(ActionContext context) 10 | { 11 | var errors = context.ModelState.GetErrorMessages(); 12 | var response = new ErrorResource(messages: errors); 13 | 14 | return new BadRequestObjectResult(response); 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Domain/Models/Queries/Query.cs: -------------------------------------------------------------------------------- 1 | namespace Supermarket.API.Domain.Models.Queries 2 | { 3 | public class Query 4 | { 5 | public int Page { get; protected set; } 6 | public int ItemsPerPage { get; protected set; } 7 | 8 | public Query(int page, int itemsPerPage) 9 | { 10 | Page = page; 11 | ItemsPerPage = itemsPerPage; 12 | 13 | if (Page <= 0) 14 | { 15 | Page = 1; 16 | } 17 | 18 | if (ItemsPerPage <= 0) 19 | { 20 | ItemsPerPage = 10; 21 | } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Resources/ErrorResource.cs: -------------------------------------------------------------------------------- 1 | namespace Supermarket.API.Resources 2 | { 3 | public record ErrorResource 4 | { 5 | public bool Success => false; 6 | public List Messages { get; private set; } 7 | 8 | public ErrorResource(List messages) 9 | { 10 | Messages = messages ?? []; 11 | } 12 | 13 | public ErrorResource(string message) 14 | { 15 | Messages = []; 16 | 17 | if (!string.IsNullOrWhiteSpace(message)) 18 | { 19 | this.Messages.Add(message); 20 | } 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Domain/Services/Communication/Response.cs: -------------------------------------------------------------------------------- 1 | namespace Supermarket.API.Domain.Services.Communication 2 | { 3 | public record Response 4 | { 5 | public bool Success { get; init; } 6 | public string? Message { get; init; } 7 | public T? Resource { get; init; } 8 | 9 | public Response(T resource) 10 | { 11 | Success = true; 12 | Message = null; 13 | Resource = resource; 14 | } 15 | 16 | public Response(string message) 17 | { 18 | Success = false; 19 | Message = message; 20 | Resource = default; 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Mapping/ModelToResourceProfile.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using Supermarket.API.Extensions; 3 | using Supermarket.API.Resources; 4 | 5 | namespace Supermarket.API.Mapping 6 | { 7 | public class ModelToResourceProfile : Profile 8 | { 9 | public ModelToResourceProfile() 10 | { 11 | CreateMap(); 12 | 13 | CreateMap() 14 | .ForMember(src => src.UnitOfMeasurement, 15 | opt => opt.MapFrom(src => src.UnitOfMeasurement.ToDescriptionString())); 16 | 17 | CreateMap, QueryResultResource>(); 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base 2 | USER app 3 | WORKDIR /app 4 | EXPOSE 8080 5 | 6 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build 7 | ARG BUILD_CONFIGURATION=Release 8 | WORKDIR /src 9 | COPY ["Supermarket.API.csproj", "."] 10 | RUN dotnet restore "./././Supermarket.API.csproj" 11 | COPY . . 12 | WORKDIR "/src/." 13 | RUN dotnet build "./Supermarket.API.csproj" -c $BUILD_CONFIGURATION -o /app/build 14 | 15 | FROM build AS publish 16 | ARG BUILD_CONFIGURATION=Release 17 | RUN dotnet publish "./Supermarket.API.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false 18 | 19 | FROM base AS final 20 | WORKDIR /app 21 | COPY --from=publish /app/publish . 22 | ENTRYPOINT ["dotnet", "Supermarket.API.dll"] -------------------------------------------------------------------------------- /src/Supermarket.API/Persistence/Contexts/Configurations/CategoryConfiguration.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 3 | 4 | namespace Supermarket.API.Persistence.Contexts.Configurations 5 | { 6 | public class CategoryConfiguration : IEntityTypeConfiguration 7 | { 8 | public void Configure(EntityTypeBuilder builder) 9 | { 10 | builder.ToTable("Categories"); 11 | builder.HasKey(p => p.Id); 12 | builder.Property(p => p.Id).IsRequired().ValueGeneratedOnAdd(); 13 | builder.Property(p => p.Name).IsRequired().HasMaxLength(30); 14 | builder.HasMany(p => p.Products).WithOne(p => p.Category).HasForeignKey(p => p.CategoryId); 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Extensions/EnumExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | 3 | namespace Supermarket.API.Extensions 4 | { 5 | public static class EnumExtensions 6 | { 7 | public static string ToDescriptionString(this TEnum? value) where TEnum : Enum 8 | { 9 | if(value == null) 10 | { 11 | throw new ArgumentNullException(nameof(value)); 12 | } 13 | 14 | var valueAsString = value.ToString(); 15 | var valueType = value.GetType(); 16 | var fieldInfo = valueType.GetField(valueAsString)!; 17 | var attributes = (DescriptionAttribute[])fieldInfo.GetCustomAttributes(typeof(DescriptionAttribute), false); 18 | 19 | return attributes?[0].Description ?? valueAsString; 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Persistence/Contexts/Configurations/ProductConfiguration.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 3 | 4 | namespace Supermarket.API.Persistence.Contexts.Configurations 5 | { 6 | public class ProductConfiguration : IEntityTypeConfiguration 7 | { 8 | public void Configure(EntityTypeBuilder builder) 9 | { 10 | builder.ToTable("Products"); 11 | builder.HasKey(p => p.Id); 12 | builder.Property(p => p.Id).IsRequired().ValueGeneratedOnAdd(); 13 | builder.Property(p => p.Name).IsRequired().HasMaxLength(50); 14 | builder.Property(p => p.QuantityInPackage).IsRequired(); 15 | builder.Property(p => p.UnitOfMeasurement).IsRequired(); 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "IIS Express": { 4 | "commandName": "IISExpress", 5 | "launchBrowser": true, 6 | "launchUrl": "swagger", 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Development" 9 | } 10 | }, 11 | "Supermarket.API": { 12 | "commandName": "Project", 13 | "launchBrowser": true, 14 | "launchUrl": "swagger", 15 | "environmentVariables": { 16 | "ASPNETCORE_ENVIRONMENT": "Development" 17 | }, 18 | "applicationUrl": "http://localhost:5000" 19 | }, 20 | "Docker": { 21 | "commandName": "Docker", 22 | "launchBrowser": true, 23 | "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger", 24 | "environmentVariables": { 25 | "ASPNETCORE_HTTP_PORTS": "8080" 26 | }, 27 | "publishAllPorts": true, 28 | "useSSL": false, 29 | "httpPort": 5000 30 | } 31 | }, 32 | "$schema": "http://json.schemastore.org/launchsettings.json" 33 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Supermarket.API.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | True 8 | bin\Debug\net8.0\Supermarket.API.xml 9 | 1591 10 | 33e0b0f9-1ed6-4781-8054-163663c835a0 11 | Linux 12 | . 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Evandro Gayer Gomes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/Supermarket.API/Program.cs: -------------------------------------------------------------------------------- 1 | using Supermarket.API.Persistence.Contexts; 2 | 3 | namespace Supermarket.API 4 | { 5 | public class Program 6 | { 7 | public static async Task Main(string[] args) 8 | { 9 | var host = CreateHostBuilder(args).Build(); 10 | using var scope = host.Services.CreateScope(); 11 | var services = scope.ServiceProvider; 12 | try 13 | { 14 | var context = services.GetRequiredService(); 15 | await SeedData.Seed(context); 16 | } 17 | catch (Exception ex) 18 | { 19 | var logger = services.GetRequiredService>(); 20 | logger.LogError(ex, "Could not seed data."); 21 | } 22 | 23 | await host.RunAsync(); 24 | } 25 | 26 | public static IHostBuilder CreateHostBuilder(string[] args) => 27 | Host.CreateDefaultBuilder(args) 28 | .ConfigureWebHostDefaults(webBuilder => 29 | { 30 | webBuilder.UseStartup(); 31 | }); 32 | } 33 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Persistence/Repositories/CategoryRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Supermarket.API.Domain.Repositories; 3 | using Supermarket.API.Persistence.Contexts; 4 | 5 | namespace Supermarket.API.Persistence.Repositories 6 | { 7 | public class CategoryRepository(AppDbContext context) : BaseRepository(context), ICategoryRepository 8 | { 9 | 10 | // "AsNoTracking" tells Entity Framework that it is not necessary to track changes for listed entities. This makes code run faster. 11 | public async Task> ListAsync() 12 | => await _context.Categories.AsNoTracking().ToListAsync(); 13 | 14 | public async Task AddAsync(Category category) 15 | => await _context.Categories.AddAsync(category); 16 | 17 | public async Task FindByIdAsync(int id) 18 | => await _context.Categories.FindAsync(id); 19 | 20 | public void Update(Category category) 21 | { 22 | _context.Categories.Update(category); 23 | } 24 | 25 | public void Remove(Category category) 26 | { 27 | _context.Categories.Remove(category); 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Supermarket.API.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.29519.181 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Supermarket.API", "Supermarket.API.csproj", "{22399ADE-AE88-44EE-81F2-BCB865F5B1D2}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {22399ADE-AE88-44EE-81F2-BCB865F5B1D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {22399ADE-AE88-44EE-81F2-BCB865F5B1D2}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {22399ADE-AE88-44EE-81F2-BCB865F5B1D2}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {22399ADE-AE88-44EE-81F2-BCB865F5B1D2}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {49AAD24B-548D-4F96-B3E2-09370FE25ADC} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /src/Supermarket.API/Persistence/Contexts/SeedData.cs: -------------------------------------------------------------------------------- 1 | namespace Supermarket.API.Persistence.Contexts 2 | { 3 | public static class SeedData 4 | { 5 | public static async Task Seed(AppDbContext context) 6 | { 7 | var products = new List 8 | { 9 | new() { 10 | Id = 100, 11 | Name = "Apple", 12 | QuantityInPackage = 1, 13 | UnitOfMeasurement = UnitOfMeasurement.Unity, 14 | CategoryId = 100 15 | }, 16 | new() { 17 | Id = 101, 18 | Name = "Milk", 19 | QuantityInPackage = 2, 20 | UnitOfMeasurement = UnitOfMeasurement.Liter, 21 | CategoryId = 101, 22 | } 23 | }; 24 | 25 | var categories = new List 26 | { 27 | new() { Id = 100, Name = "Fruits and Vegetables" }, // Id set manually due to in-memory provider 28 | new() { Id = 101, Name = "Dairy" } 29 | }; 30 | 31 | context.Products.AddRange(products); 32 | context.Categories.AddRange(categories); 33 | 34 | await context.SaveChangesAsync(); 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Extensions/MiddlewareExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.OpenApi.Models; 2 | using System.Reflection; 3 | 4 | namespace Supermarket.API.Extensions 5 | { 6 | public static class MiddlewareExtensions 7 | { 8 | public static IServiceCollection AddCustomSwagger(this IServiceCollection services) 9 | { 10 | services.AddSwaggerGen(cfg => 11 | { 12 | cfg.SwaggerDoc("v1", new OpenApiInfo 13 | { 14 | Title = "Supermarket API", 15 | Version = "v5", 16 | Description = "Simple RESTful API built with ASP.NET Core to show how to create RESTful services using a service-oriented architecture.", 17 | Contact = new OpenApiContact 18 | { 19 | Name = "Evandro Gayer Gomes", 20 | Url = new Uri("https://evgomes.github.io/") 21 | }, 22 | License = new OpenApiLicense 23 | { 24 | Name = "MIT", 25 | }, 26 | }); 27 | 28 | var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; 29 | var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); 30 | cfg.IncludeXmlComments(xmlPath); 31 | }); 32 | return services; 33 | } 34 | 35 | public static IApplicationBuilder UseCustomSwagger(this IApplicationBuilder app) 36 | { 37 | app.UseSwagger().UseSwaggerUI(options => 38 | { 39 | options.SwaggerEndpoint("/swagger/v1/swagger.json", "Supermarket API"); 40 | options.DocumentTitle = "Supermarket API"; 41 | }); 42 | return app; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Supermarket.API/Startup.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Supermarket.API.Controllers.Config; 3 | using Supermarket.API.Domain.Repositories; 4 | using Supermarket.API.Domain.Services; 5 | using Supermarket.API.Extensions; 6 | using Supermarket.API.Persistence.Contexts; 7 | using Supermarket.API.Persistence.Repositories; 8 | using Supermarket.API.Services; 9 | 10 | namespace Supermarket.API 11 | { 12 | public class Startup 13 | { 14 | private readonly IConfiguration Configuration; 15 | 16 | public Startup(IConfiguration configuration) => Configuration = configuration; 17 | 18 | public void ConfigureServices(IServiceCollection services) 19 | { 20 | services.AddMemoryCache(); 21 | 22 | services.AddCustomSwagger(); 23 | 24 | services.Configure(options => options.LowercaseUrls = true); 25 | 26 | services.AddControllers().ConfigureApiBehaviorOptions(options => 27 | { 28 | // Adds a custom error response factory when ModelState is invalid 29 | options.InvalidModelStateResponseFactory = InvalidModelStateResponseFactory.ProduceErrorResponse; 30 | }); 31 | 32 | services.AddDbContext(options => 33 | { 34 | options.UseInMemoryDatabase(Configuration.GetConnectionString("memory") ?? "data-in-memory"); 35 | }); 36 | 37 | services.AddScoped(); 38 | services.AddScoped(); 39 | services.AddScoped(); 40 | 41 | services.AddScoped(); 42 | services.AddScoped(); 43 | 44 | services.AddAutoMapper(typeof(Startup)); 45 | } 46 | 47 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 48 | { 49 | if (env.IsDevelopment()) 50 | { 51 | app.UseDeveloperExceptionPage(); 52 | } 53 | 54 | app.UseCustomSwagger(); 55 | 56 | app.UseRouting(); 57 | 58 | app.UseAuthorization(); 59 | 60 | app.UseEndpoints(endpoints => 61 | { 62 | endpoints.MapControllers(); 63 | }); 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Persistence/Repositories/ProductRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Supermarket.API.Domain.Repositories; 3 | using Supermarket.API.Persistence.Contexts; 4 | 5 | namespace Supermarket.API.Persistence.Repositories 6 | { 7 | public class ProductRepository(AppDbContext context) : BaseRepository(context), IProductRepository 8 | { 9 | public async Task> ListAsync(ProductsQuery query) 10 | { 11 | IQueryable queryable = _context.Products 12 | .Include(p => p.Category) 13 | .AsNoTracking(); 14 | 15 | // AsNoTracking tells EF Core it doesn't need to track changes on listed entities. Disabling entity 16 | // tracking makes the code a little faster 17 | if (query.CategoryId.HasValue && query.CategoryId > 0) 18 | { 19 | queryable = queryable.Where(p => p.CategoryId == query.CategoryId); 20 | } 21 | 22 | // Here I count all items present in the database for the given query, to return as part of the pagination data. 23 | int totalItems = await queryable.CountAsync(); 24 | 25 | // Here I apply a simple calculation to skip a given number of items, according to the current page and amount of items per page, 26 | // and them I return only the amount of desired items. The methods "Skip" and "Take" do the trick here. 27 | List products = await queryable.Skip((query.Page - 1) * query.ItemsPerPage) 28 | .Take(query.ItemsPerPage) 29 | .ToListAsync(); 30 | 31 | // Finally I return a query result, containing all items and the amount of items in the database (necessary for client-side calculations ). 32 | return new QueryResult 33 | { 34 | Items = products, 35 | TotalItems = totalItems, 36 | }; 37 | } 38 | 39 | public async Task FindByIdAsync(int id) 40 | => await _context.Products.Include(p => p.Category).FirstOrDefaultAsync(p => p.Id == id); // Since Include changes the method's return type, we can't use FindAsync 41 | 42 | public async Task AddAsync(Product product) 43 | => await _context.Products.AddAsync(product); 44 | 45 | public void Update(Product product) 46 | { 47 | _context.Products.Update(product); 48 | } 49 | 50 | public void Remove(Product product) 51 | { 52 | _context.Products.Remove(product); 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /src/Supermarket.API/Controllers/CategoriesController.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Supermarket.API.Domain.Services; 4 | using Supermarket.API.Resources; 5 | 6 | namespace Supermarket.API.Controllers 7 | { 8 | public class CategoriesController : BaseApiController 9 | { 10 | private readonly ICategoryService _categoryService; 11 | private readonly IMapper _mapper; 12 | 13 | public CategoriesController(ICategoryService categoryService, IMapper mapper) 14 | { 15 | _categoryService = categoryService; 16 | _mapper = mapper; 17 | } 18 | 19 | /// 20 | /// Lists all categories. 21 | /// 22 | /// List os categories. 23 | [HttpGet] 24 | [ProducesResponseType(typeof(IEnumerable), 200)] 25 | public async Task> ListAsync() 26 | { 27 | var categories = await _categoryService.ListAsync(); 28 | return _mapper.Map>(categories); 29 | } 30 | 31 | /// 32 | /// Saves a new category. 33 | /// 34 | /// Category data. 35 | /// Response for the request. 36 | [HttpPost] 37 | [ProducesResponseType(typeof(CategoryResource), 201)] 38 | [ProducesResponseType(typeof(ErrorResource), 400)] 39 | public async Task PostAsync([FromBody] SaveCategoryResource resource) 40 | { 41 | var category = _mapper.Map(resource); 42 | var result = await _categoryService.SaveAsync(category); 43 | 44 | if (!result.Success) 45 | { 46 | return BadRequest(new ErrorResource(result.Message!)); 47 | } 48 | 49 | var categoryResource = _mapper.Map(result.Resource!); 50 | return Ok(categoryResource); 51 | } 52 | 53 | /// 54 | /// Updates an existing category according to an identifier. 55 | /// 56 | /// Category identifier. 57 | /// Updated category data. 58 | /// Response for the request. 59 | [HttpPut("{id}")] 60 | [ProducesResponseType(typeof(CategoryResource), 200)] 61 | [ProducesResponseType(typeof(ErrorResource), 400)] 62 | public async Task PutAsync(int id, [FromBody] SaveCategoryResource resource) 63 | { 64 | var category = _mapper.Map(resource); 65 | var result = await _categoryService.UpdateAsync(id, category); 66 | 67 | if (!result.Success) 68 | { 69 | return BadRequest(new ErrorResource(result.Message!)); 70 | } 71 | 72 | var categoryResource = _mapper.Map(result.Resource!); 73 | return Ok(categoryResource); 74 | } 75 | 76 | /// 77 | /// Deletes a given category according to an identifier. 78 | /// 79 | /// Category identifier. 80 | /// Response for the request. 81 | [HttpDelete("{id}")] 82 | [ProducesResponseType(typeof(CategoryResource), 200)] 83 | [ProducesResponseType(typeof(ErrorResource), 400)] 84 | public async Task DeleteAsync(int id) 85 | { 86 | var result = await _categoryService.DeleteAsync(id); 87 | 88 | if (!result.Success) 89 | { 90 | return BadRequest(new ErrorResource(result.Message!)); 91 | } 92 | 93 | var categoryResource = _mapper.Map(result.Resource!); 94 | return Ok(categoryResource); 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "mattbarry", 10 | "name": "Matt Barry", 11 | "avatar_url": "https://avatars.githubusercontent.com/u/1567119?v=4", 12 | "profile": "https://github.com/mattbarry", 13 | "contributions": [ 14 | "code" 15 | ] 16 | }, 17 | { 18 | "login": "hippieZhou", 19 | "name": "hippie", 20 | "avatar_url": "https://avatars.githubusercontent.com/u/13598361?v=4", 21 | "profile": "https://hippiezhou.fun", 22 | "contributions": [ 23 | "code" 24 | ] 25 | }, 26 | { 27 | "login": "NoobInTraining", 28 | "name": "NoobInTraining", 29 | "avatar_url": "https://avatars.githubusercontent.com/u/23185961?v=4", 30 | "profile": "https://github.com/NoobInTraining", 31 | "contributions": [ 32 | "code" 33 | ] 34 | }, 35 | { 36 | "login": "mahmmoudkinawy", 37 | "name": "Ma'hmmoud Kinawy", 38 | "avatar_url": "https://avatars.githubusercontent.com/u/57391128?v=4", 39 | "profile": "https://www.linkedin.com/in/mahmmoud-kinawy-7928b218a/", 40 | "contributions": [ 41 | "code" 42 | ] 43 | }, 44 | { 45 | "login": "AhsanRazaUK", 46 | "name": "Ahsan Raza", 47 | "avatar_url": "https://avatars.githubusercontent.com/u/22678337?v=4", 48 | "profile": "https://www.linkedin.com/in/arazauk/", 49 | "contributions": [ 50 | "review" 51 | ] 52 | }, 53 | { 54 | "login": "dundich", 55 | "name": "dundich", 56 | "avatar_url": "https://avatars.githubusercontent.com/u/1078713?v=4", 57 | "profile": "https://github.com/dundich", 58 | "contributions": [ 59 | "ideas" 60 | ] 61 | }, 62 | { 63 | "login": "thedon-chris", 64 | "name": "thedon_chris", 65 | "avatar_url": "https://avatars.githubusercontent.com/u/30728737?v=4", 66 | "profile": "https://github.com/thedon-chris", 67 | "contributions": [ 68 | "bug" 69 | ] 70 | }, 71 | { 72 | "login": "subhanisyed17", 73 | "name": "subhanisyed17", 74 | "avatar_url": "https://avatars.githubusercontent.com/u/46715997?v=4", 75 | "profile": "https://github.com/subhanisyed17", 76 | "contributions": [ 77 | "bug" 78 | ] 79 | }, 80 | { 81 | "login": "eric-wilson", 82 | "name": "Eric Wilson", 83 | "avatar_url": "https://avatars.githubusercontent.com/u/3632968?v=4", 84 | "profile": "https://www.geekcafe.com", 85 | "contributions": [ 86 | "question" 87 | ] 88 | }, 89 | { 90 | "login": "Pham-Tuan-Phat", 91 | "name": "Phạm Tuấn Phát", 92 | "avatar_url": "https://avatars.githubusercontent.com/u/61822642?v=4", 93 | "profile": "https://github.com/Pham-Tuan-Phat", 94 | "contributions": [ 95 | "ideas" 96 | ] 97 | }, 98 | { 99 | "login": "miki-nis", 100 | "name": "miki-nis", 101 | "avatar_url": "https://avatars.githubusercontent.com/u/12809735?v=4", 102 | "profile": "https://github.com/miki-nis", 103 | "contributions": [ 104 | "bug" 105 | ] 106 | } 107 | ], 108 | "contributorsPerLine": 7, 109 | "projectName": "supermarket-api", 110 | "projectOwner": "evgomes", 111 | "repoType": "github", 112 | "repoHost": "https://github.com", 113 | "skipCi": true 114 | } 115 | -------------------------------------------------------------------------------- /src/Supermarket.API/Services/CategoryService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Caching.Memory; 2 | using Supermarket.API.Domain.Repositories; 3 | using Supermarket.API.Domain.Services; 4 | using Supermarket.API.Domain.Services.Communication; 5 | using Supermarket.API.Infrastructure; 6 | 7 | namespace Supermarket.API.Services 8 | { 9 | public class CategoryService : ICategoryService 10 | { 11 | private readonly ICategoryRepository _categoryRepository; 12 | private readonly IUnitOfWork _unitOfWork; 13 | private readonly IMemoryCache _cache; 14 | private readonly ILogger _logger; 15 | 16 | public CategoryService 17 | ( 18 | ICategoryRepository categoryRepository, 19 | IUnitOfWork unitOfWork, 20 | IMemoryCache cache, 21 | ILogger logger 22 | ) 23 | { 24 | _categoryRepository = categoryRepository; 25 | _unitOfWork = unitOfWork; 26 | _cache = cache; 27 | _logger = logger; 28 | } 29 | 30 | public async Task> ListAsync() 31 | { 32 | // Here I try to get the categories list from the memory cache. If there is no data in cache, the anonymous method will be 33 | // called, setting the cache to expire one minute ahead and returning the Task that lists the categories from the repository. 34 | var categories = await _cache.GetOrCreateAsync(CacheKeys.CategoriesList, (entry) => 35 | { 36 | entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1); 37 | return _categoryRepository.ListAsync(); 38 | }); 39 | 40 | return categories ?? new List(); 41 | } 42 | 43 | public async Task> SaveAsync(Category category) 44 | { 45 | try 46 | { 47 | await _categoryRepository.AddAsync(category); 48 | await _unitOfWork.CompleteAsync(); 49 | 50 | return new Response(category); 51 | } 52 | catch (Exception ex) 53 | { 54 | _logger.LogError(ex, "Could not save category."); 55 | return new Response($"An error occurred when saving the category: {ex.Message}"); 56 | } 57 | } 58 | 59 | public async Task> UpdateAsync(int id, Category category) 60 | { 61 | var existingCategory = await _categoryRepository.FindByIdAsync(id); 62 | if (existingCategory == null) 63 | { 64 | return new Response("Category not found."); 65 | } 66 | 67 | existingCategory.Name = category.Name; 68 | 69 | try 70 | { 71 | await _unitOfWork.CompleteAsync(); 72 | return new Response(existingCategory); 73 | } 74 | catch (Exception ex) 75 | { 76 | _logger.LogError(ex, "Could not update category with ID {id}.", id); 77 | return new Response($"An error occurred when updating the category: {ex.Message}"); 78 | } 79 | } 80 | 81 | public async Task> DeleteAsync(int id) 82 | { 83 | var existingCategory = await _categoryRepository.FindByIdAsync(id); 84 | if (existingCategory == null) 85 | { 86 | return new Response("Category not found."); 87 | } 88 | 89 | try 90 | { 91 | _categoryRepository.Remove(existingCategory); 92 | await _unitOfWork.CompleteAsync(); 93 | 94 | return new Response(existingCategory); 95 | } 96 | catch (Exception ex) 97 | { 98 | _logger.LogError(ex, "Could not delete category with ID {id}.", id); 99 | return new Response($"An error occurred when deleting the category: {ex.Message}"); 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Supermarket.API/Controllers/ProductsController.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Supermarket.API.Domain.Services; 4 | using Supermarket.API.Resources; 5 | 6 | namespace Supermarket.API.Controllers 7 | { 8 | public class ProductsController : BaseApiController 9 | { 10 | private readonly IProductService _productService; 11 | private readonly IMapper _mapper; 12 | 13 | public ProductsController(IProductService productService, IMapper mapper) 14 | { 15 | _productService = productService; 16 | _mapper = mapper; 17 | } 18 | 19 | /// 20 | /// Lists all existing products according to query filters. 21 | /// 22 | /// List of products. 23 | [HttpGet] 24 | [ProducesResponseType(typeof(QueryResultResource), 200)] 25 | public async Task> ListAsync([FromQuery] ProductsQueryResource query) 26 | { 27 | var productsQuery = _mapper.Map(query); 28 | var queryResult = await _productService.ListAsync(productsQuery); 29 | 30 | return _mapper.Map>(queryResult); 31 | } 32 | 33 | /// 34 | /// Saves a new product. 35 | /// 36 | /// Product data. 37 | /// Response for the request. 38 | [HttpPost] 39 | [ProducesResponseType(typeof(ProductResource), 201)] 40 | [ProducesResponseType(typeof(ErrorResource), 400)] 41 | public async Task PostAsync([FromBody] SaveProductResource resource) 42 | { 43 | var product = _mapper.Map(resource); 44 | var result = await _productService.SaveAsync(product); 45 | 46 | if (!result.Success) 47 | { 48 | return BadRequest(new ErrorResource(result.Message!)); 49 | } 50 | 51 | var productResource = _mapper.Map(result.Resource!); 52 | return Ok(productResource); 53 | } 54 | 55 | /// 56 | /// Updates an existing product according to an identifier. 57 | /// 58 | /// Product identifier. 59 | /// Product data. 60 | /// Response for the request. 61 | [HttpPut("{id}")] 62 | [ProducesResponseType(typeof(ProductResource), 201)] 63 | [ProducesResponseType(typeof(ErrorResource), 400)] 64 | public async Task PutAsync(int id, [FromBody] SaveProductResource resource) 65 | { 66 | var product = _mapper.Map(resource); 67 | var result = await _productService.UpdateAsync(id, product); 68 | 69 | if (!result.Success) 70 | { 71 | return BadRequest(new ErrorResource(result.Message!)); 72 | } 73 | 74 | var productResource = _mapper.Map(result.Resource!); 75 | return Ok(productResource); 76 | } 77 | 78 | /// 79 | /// Deletes a given product according to an identifier. 80 | /// 81 | /// Product identifier. 82 | /// Response for the request. 83 | [HttpDelete("{id}")] 84 | [ProducesResponseType(typeof(ProductResource), 200)] 85 | [ProducesResponseType(typeof(ErrorResource), 400)] 86 | public async Task DeleteAsync(int id) 87 | { 88 | var result = await _productService.DeleteAsync(id); 89 | 90 | if (!result.Success) 91 | { 92 | return BadRequest(new ErrorResource(result.Message!)); 93 | } 94 | 95 | var categoryResource = _mapper.Map(result.Resource!); 96 | return Ok(categoryResource); 97 | } 98 | } 99 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | build/ 21 | bld/ 22 | [Bb]in/ 23 | [Oo]bj/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | artifacts/ 46 | 47 | *_i.c 48 | *_p.c 49 | *_i.h 50 | *.ilk 51 | *.meta 52 | *.obj 53 | *.pch 54 | *.pdb 55 | *.pgc 56 | *.pgd 57 | *.rsp 58 | *.sbr 59 | *.tlb 60 | *.tli 61 | *.tlh 62 | *.tmp 63 | *.tmp_proj 64 | *.log 65 | *.vspscc 66 | *.vssscc 67 | .builds 68 | *.pidb 69 | *.svclog 70 | *.scc 71 | 72 | # Chutzpah Test files 73 | _Chutzpah* 74 | 75 | # Visual C++ cache files 76 | ipch/ 77 | *.aps 78 | *.ncb 79 | *.opendb 80 | *.opensdf 81 | *.sdf 82 | *.cachefile 83 | 84 | # Visual Studio profiler 85 | *.psess 86 | *.vsp 87 | *.vspx 88 | *.sap 89 | 90 | # TFS 2012 Local Workspace 91 | $tf/ 92 | 93 | # Guidance Automation Toolkit 94 | *.gpState 95 | 96 | # ReSharper is a .NET coding add-in 97 | _ReSharper*/ 98 | *.[Rr]e[Ss]harper 99 | *.DotSettings.user 100 | 101 | # JustCode is a .NET coding add-in 102 | .JustCode 103 | 104 | # TeamCity is a build add-in 105 | _TeamCity* 106 | 107 | # DotCover is a Code Coverage Tool 108 | *.dotCover 109 | 110 | # NCrunch 111 | _NCrunch_* 112 | .*crunch*.local.xml 113 | nCrunchTemp_* 114 | 115 | # MightyMoose 116 | *.mm.* 117 | AutoTest.Net/ 118 | 119 | # Web workbench (sass) 120 | .sass-cache/ 121 | 122 | # Installshield output folder 123 | [Ee]xpress/ 124 | 125 | # DocProject is a documentation generator add-in 126 | DocProject/buildhelp/ 127 | DocProject/Help/*.HxT 128 | DocProject/Help/*.HxC 129 | DocProject/Help/*.hhc 130 | DocProject/Help/*.hhk 131 | DocProject/Help/*.hhp 132 | DocProject/Help/Html2 133 | DocProject/Help/html 134 | 135 | # Click-Once directory 136 | publish/ 137 | 138 | # Publish Web Output 139 | *.[Pp]ublish.xml 140 | *.azurePubxml 141 | # TODO: Comment the next line if you want to checkin your web deploy settings 142 | # but database connection strings (with potential passwords) will be unencrypted 143 | *.pubxml 144 | *.publishproj 145 | 146 | # NuGet Packages 147 | *.nupkg 148 | # The packages folder can be ignored because of Package Restore 149 | **/packages/* 150 | # except build/, which is used as an MSBuild target. 151 | !**/packages/build/ 152 | # Uncomment if necessary however generally it will be regenerated when needed 153 | #!**/packages/repositories.config 154 | 155 | # Microsoft Azure Build Output 156 | csx/ 157 | *.build.csdef 158 | 159 | # Microsoft Azure Emulator 160 | ecf/ 161 | rcf/ 162 | 163 | # Microsoft Azure ApplicationInsights config file 164 | ApplicationInsights.config 165 | 166 | # Windows Store app package directory 167 | AppPackages/ 168 | BundleArtifacts/ 169 | 170 | # Visual Studio cache files 171 | # files ending in .cache can be ignored 172 | *.[Cc]ache 173 | # but keep track of directories ending in .cache 174 | !*.[Cc]ache/ 175 | 176 | # Others 177 | ClientBin/ 178 | ~$* 179 | *~ 180 | *.dbmdl 181 | *.dbproj.schemaview 182 | *.pfx 183 | *.publishsettings 184 | node_modules/ 185 | orleans.codegen.cs 186 | 187 | # RIA/Silverlight projects 188 | Generated_Code/ 189 | 190 | # Backup & report files from converting an old project file 191 | # to a newer Visual Studio version. Backup files are not needed, 192 | # because we have git ;-) 193 | _UpgradeReport_Files/ 194 | Backup*/ 195 | UpgradeLog*.XML 196 | UpgradeLog*.htm 197 | 198 | # SQL Server files 199 | *.mdf 200 | *.ldf 201 | 202 | # Business Intelligence projects 203 | *.rdl.data 204 | *.bim.layout 205 | *.bim_*.settings 206 | 207 | # Microsoft Fakes 208 | FakesAssemblies/ 209 | 210 | # GhostDoc plugin setting file 211 | *.GhostDoc.xml 212 | 213 | # Node.js Tools for Visual Studio 214 | .ntvs_analysis.dat 215 | 216 | # Visual Studio 6 build log 217 | *.plg 218 | 219 | # Visual Studio 6 workspace options file 220 | *.opt 221 | 222 | # Visual Studio LightSwitch build output 223 | **/*.HTMLClient/GeneratedArtifacts 224 | **/*.DesktopClient/GeneratedArtifacts 225 | **/*.DesktopClient/ModelManifest.xml 226 | **/*.Server/GeneratedArtifacts 227 | **/*.Server/ModelManifest.xml 228 | _Pvt_Extensions 229 | 230 | # Paket dependency manager 231 | .paket/paket.exe 232 | 233 | # FAKE - F# Make 234 | .fake/ 235 | 236 | .vscode/ -------------------------------------------------------------------------------- /src/Supermarket.API/Services/ProductService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Caching.Memory; 2 | using Supermarket.API.Domain.Repositories; 3 | using Supermarket.API.Domain.Services; 4 | using Supermarket.API.Domain.Services.Communication; 5 | using Supermarket.API.Infrastructure; 6 | 7 | namespace Supermarket.API.Services 8 | { 9 | public class ProductService : IProductService 10 | { 11 | private readonly IProductRepository _productRepository; 12 | private readonly ICategoryRepository _categoryRepository; 13 | private readonly IUnitOfWork _unitOfWork; 14 | private readonly IMemoryCache _cache; 15 | private readonly ILogger _logger; 16 | 17 | public ProductService 18 | ( 19 | IProductRepository productRepository, 20 | ICategoryRepository categoryRepository, 21 | IUnitOfWork unitOfWork, 22 | IMemoryCache cache, 23 | ILogger logger 24 | ) 25 | { 26 | _productRepository = productRepository; 27 | _categoryRepository = categoryRepository; 28 | _unitOfWork = unitOfWork; 29 | _cache = cache; 30 | _logger = logger; 31 | } 32 | 33 | public async Task> ListAsync(ProductsQuery query) 34 | { 35 | // Here I list the query result from cache if they exist, but now the data can vary according to the category ID, page and amount of 36 | // items per page. I have to compose a cache to avoid returning wrong data. 37 | string cacheKey = GetCacheKeyForProductsQuery(query); 38 | 39 | var products = await _cache.GetOrCreateAsync(cacheKey, (entry) => 40 | { 41 | entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1); 42 | return _productRepository.ListAsync(query); 43 | }); 44 | 45 | return products!; 46 | } 47 | 48 | public async Task> SaveAsync(Product product) 49 | { 50 | try 51 | { 52 | /* 53 | Notice here we have to check if the category ID is valid before adding the product, to avoid errors. 54 | You can create a method into the CategoryService class to return the category and inject the service here if you prefer, but 55 | it doesn't matter given the API scope. 56 | */ 57 | var existingCategory = await _categoryRepository.FindByIdAsync(product.CategoryId); 58 | if (existingCategory == null) 59 | return new Response("Invalid category."); 60 | 61 | await _productRepository.AddAsync(product); 62 | await _unitOfWork.CompleteAsync(); 63 | 64 | return new Response(product); 65 | } 66 | catch (Exception ex) 67 | { 68 | _logger.LogError(ex, "Could not save product."); 69 | return new Response($"An error occurred when saving the product: {ex.Message}"); 70 | } 71 | } 72 | 73 | public async Task> UpdateAsync(int id, Product product) 74 | { 75 | var existingProduct = await _productRepository.FindByIdAsync(id); 76 | 77 | if (existingProduct == null) 78 | return new Response("Product not found."); 79 | 80 | var existingCategory = await _categoryRepository.FindByIdAsync(product.CategoryId); 81 | if (existingCategory == null) 82 | return new Response("Invalid category."); 83 | 84 | existingProduct.Name = product.Name; 85 | existingProduct.UnitOfMeasurement = product.UnitOfMeasurement; 86 | existingProduct.QuantityInPackage = product.QuantityInPackage; 87 | existingProduct.CategoryId = product.CategoryId; 88 | 89 | try 90 | { 91 | _productRepository.Update(existingProduct); 92 | await _unitOfWork.CompleteAsync(); 93 | 94 | return new Response(existingProduct); 95 | } 96 | catch (Exception ex) 97 | { 98 | _logger.LogError(ex, "Could not update product with ID {id}.", id); 99 | return new Response($"An error occurred when updating the product: {ex.Message}"); 100 | } 101 | } 102 | 103 | public async Task> DeleteAsync(int id) 104 | { 105 | var existingProduct = await _productRepository.FindByIdAsync(id); 106 | 107 | if (existingProduct == null) 108 | return new Response("Product not found."); 109 | 110 | try 111 | { 112 | _productRepository.Remove(existingProduct); 113 | await _unitOfWork.CompleteAsync(); 114 | 115 | return new Response(existingProduct); 116 | } 117 | catch (Exception ex) 118 | { 119 | _logger.LogError(ex, "Could not delete product with ID {id}.", id); 120 | return new Response($"An error occurred when deleting the product: {ex.Message}"); 121 | } 122 | } 123 | 124 | private static string GetCacheKeyForProductsQuery(ProductsQuery query) 125 | => $"{CacheKeys.ProductsList}_{query.CategoryId}_{query.Page}_{query.ItemsPerPage}"; 126 | } 127 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Supermarket API 2 | 3 | 4 | 5 | [![All Contributors](https://img.shields.io/badge/all_contributors-11-orange.svg?style=flat-square)](#contributors-) 6 | 7 | 8 | 9 | Simple RESTful API built with ASP.NET Core and .NET 8 to show how to create RESTful services using a decoupled, maintainable architecture. 10 | 11 | ## Changes list 12 | 13 | Many changes were made to the code presented at the tutorial published on [Medium](https://medium.com/free-code-camp/an-awesome-guide-on-how-to-build-restful-apis-with-asp-net-core-87b818123e28) and [freeCodeCamp](https://www.freecodecamp.org/news/an-awesome-guide-on-how-to-build-restful-apis-with-asp-net-core-87b818123e28/), to make the API code cleaner and to add functionalities that developers may find useful. 14 | 15 | If you want to download the original code showed on the tutorial, download the [1.0.0](https://github.com/evgomes/supermarket-api/releases/tag/1.0.0) tag. 16 | 17 | - 2.1.0 _[February 9, 2024]_ 18 | 19 | - Updated .NET version to .NET 8. 20 | - Updated libraries to match the most recent .NET version. 21 | - Added Docker support. 22 | - Refactored code to use expression body methods, primary constructors, and new object and collection initialization. 23 | - Added `required` constraint to resources. 24 | 25 | - 2.0.0 _[July 5, 2023]_ 26 | 27 | - Updated .NET version to .NET 7. 28 | - Updated AutoMapper, Entity Framework Core, and Swashbuckle dependencies to match .NET 7. 29 | - Enabled implicit usings and nullable types. 30 | - Added global usings and removed implicit namespaces from the source code. 31 | - Renamed the `UnitOfMeasurement` enum type to make it follow the official naming convention. 32 | - Removed `CategoryResponse` and `ProductReponse` types to use a generic `Response` record type instead. 33 | - Changed API resources to use record types instead of classes, and to initialize values in an immutable way using `init`. 34 | - Added configuration to make all API routes lower-case. 35 | - Refactored services to include logging using the standar .NET logging provider and to make code cleaner. 36 | 37 | - 1.4.0 _[November 26, 2021]_ 38 | 39 | - Updated .NET version to .NET 5 (see [#11](https://github.com/evgomes/supermarket-api/pull/11)) 40 | - Updated AutoMapper, Entity Framework Core, and Swashbuckle dependencies to match .NET 5. 41 | - Created `BaseApiController` class to standardize routes and to automatically apply data annotations validation by using the `ApiController` attribute. 42 | - Refactored logic to seed database data and to apply entity type configuration for application models. 43 | 44 | - 1.3.0 _[December 15, 2019]_ 45 | 46 | - Updated ASP.NET Core version to 3.1, fixed issues related to InMemoryProvider, updated Swagger (see [#5](https://github.com/evgomes/supermarket-api/pull/5)). 47 | - Fixed paging calculation mistake, updated descriptions, updated "launchSettings.json" to open Swagger on running the application. 48 | 49 | - 1.2.1 _[August 11, 2019]_ 50 | 51 | - Changed `BaseResponse` to use generics as a way to simplify responses (see [#3](https://github.com/evgomes/supermarket-api/pull/3)). 52 | 53 | - 1.2.0 _[July 15, 2019]_ 54 | 55 | - Changed `/api/products` endpoint to allow pagination (see [#1](https://github.com/evgomes/supermarket-api/issues/1)). 56 | 57 | - 1.1.0 _[June 18, 2019]_ 58 | 59 | - Added Swagger documentation through [Swashbuckle](https://github.com/domaindrivendev/Swashbuckle). 60 | - Added cache through native [IMemoryCache](https://docs.microsoft.com/en-us/aspnet/core/performance/caching/memory?view=aspnetcore-2.2). 61 | - Changed products listing to allow filtering by category ID, to show how to perform specific queries with EF Core. 62 | - Changed ModelState validation to use _ApiController_ attribute and _InvalidResponseFactory_ in _Startup_. 63 | 64 | - 1.0.0 _[February 4, 2019]_ 65 | 66 | - First version of the example API, presented in the tutorial on [Medium](https://medium.com/free-code-camp/an-awesome-guide-on-how-to-build-restful-apis-with-asp-net-core-87b818123e28) and [freeCodeCamp](https://www.freecodecamp.org/news/an-awesome-guide-on-how-to-build-restful-apis-with-asp-net-core-87b818123e28/). 67 | 68 | ## Frameworks and Libraries 69 | 70 | - [ASP.NET Core 7](https://docs.microsoft.com/en-us/aspnet/core/?view=aspnetcore-7.0). 71 | - [Entity Framework Core](https://docs.microsoft.com/en-us/ef/core/) (for data access). 72 | - [Entity Framework In-Memory Provider](https://docs.microsoft.com/en-us/ef/core/miscellaneous/testing/in-memory) (for testing purposes). 73 | - [AutoMapper](https://automapper.org/) (for mapping resources and models). 74 | - [Swashbuckle](https://github.com/domaindrivendev/Swashbuckle) (API documentation). 75 | 76 | ## How to Test 77 | 78 | First, download and install the [.NET Core SDK](https://dotnet.microsoft.com/en-us/download). 79 | 80 | If you have Docker and Visual Studio installed on your machine, you can open the solution file using Visual Studio and run the project using the Docker profile. 81 | 82 | If not, open the terminal or command prompt at the API root path (`/src/Supermarket.API/`) and run the following commands, in sequence: 83 | 84 | ``` 85 | dotnet restore 86 | dotnet run 87 | ``` 88 | 89 | Navigate to `http://localhost:5000/api/categories` to check if the API is working. If you see a HTTPS security error, just add an exception to see the results. 90 | 91 | Navigate to `http://localhost:5000/swagger` to check the API documentation and to test all API endpoints. 92 | 93 | ![API Documentation](https://raw.githubusercontent.com/evgomes/supermarket-api/master/images/swagger.png) 94 | 95 | ## Contributors ✨ 96 | 97 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 |

Matt Barry

💻

hippie

💻

NoobInTraining

💻

Ma'hmmoud Kinawy

💻

Ahsan Raza

👀

dundich

🤔

thedon_chris

🐛

subhanisyed17

🐛

Eric Wilson

💬

Phạm Tuấn Phát

🤔

miki-nis

🐛
119 | 120 | 121 | 122 | 123 | 124 | 125 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 126 | --------------------------------------------------------------------------------