├── .gitignore ├── BookCovers.API ├── BookCovers.API.csproj ├── Controllers │ └── BookCoversController.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── appsettings.Development.json └── appsettings.json ├── Books.Api ├── ArrayModelBinder.cs ├── Books.Api.csproj ├── BooksProfile.cs ├── Contexts │ └── BooksContext.cs ├── Controllers │ └── BooksController.cs ├── Entities │ ├── Author.cs │ └── Book.cs ├── ExternalModels │ └── BookCover.cs ├── Models │ ├── Book.cs │ ├── BookCover.cs │ ├── BookForCreation.cs │ └── BookWithCovers.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── Services │ ├── BooksRepository.cs │ └── IBooksRepository.cs ├── appsettings.Development.json └── appsettings.json ├── Books.Legacy ├── Books.Legacy.csproj └── ComplicatedPageCalculator.cs ├── Books.sln ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 |  2 | # Created by https://www.gitignore.io/api/aspnetcore 3 | 4 | ### ASPNETCore ### 5 | ## Ignore Visual Studio temporary files, build results, and 6 | ## files generated by popular Visual Studio add-ons. 7 | 8 | # User-specific files 9 | *.suo 10 | *.user 11 | *.userosscache 12 | *.sln.docstates 13 | 14 | # User-specific files (MonoDevelop/Xamarin Studio) 15 | *.userprefs 16 | 17 | # Build results 18 | [Dd]ebug/ 19 | [Dd]ebugPublic/ 20 | [Rr]elease/ 21 | [Rr]eleases/ 22 | x64/ 23 | x86/ 24 | bld/ 25 | [Bb]in/ 26 | [Oo]bj/ 27 | [Ll]og/ 28 | 29 | # Visual Studio 2015 cache/options directory 30 | .vs/ 31 | # Uncomment if you have tasks that create the project's static files in wwwroot 32 | #wwwroot/ 33 | 34 | # MSTest test Results 35 | [Tt]est[Rr]esult*/ 36 | [Bb]uild[Ll]og.* 37 | 38 | # NUNIT 39 | *.VisualState.xml 40 | TestResult.xml 41 | 42 | # Build Results of an ATL Project 43 | [Dd]ebugPS/ 44 | [Rr]eleasePS/ 45 | dlldata.c 46 | 47 | # DNX 48 | project.lock.json 49 | project.fragment.lock.json 50 | artifacts/ 51 | 52 | *_i.c 53 | *_p.c 54 | *_i.h 55 | *.ilk 56 | *.meta 57 | *.obj 58 | *.pch 59 | *.pdb 60 | *.pgc 61 | *.pgd 62 | *.rsp 63 | *.sbr 64 | *.tlb 65 | *.tli 66 | *.tlh 67 | *.tmp 68 | *.tmp_proj 69 | *.log 70 | *.vspscc 71 | *.vssscc 72 | .builds 73 | *.pidb 74 | *.svclog 75 | *.scc 76 | 77 | # Chutzpah Test files 78 | _Chutzpah* 79 | 80 | # Visual C++ cache files 81 | ipch/ 82 | *.aps 83 | *.ncb 84 | *.opendb 85 | *.opensdf 86 | *.sdf 87 | *.cachefile 88 | *.VC.db 89 | *.VC.VC.opendb 90 | 91 | # Visual Studio profiler 92 | *.psess 93 | *.vsp 94 | *.vspx 95 | *.sap 96 | 97 | # TFS 2012 Local Workspace 98 | $tf/ 99 | 100 | # Guidance Automation Toolkit 101 | *.gpState 102 | 103 | # ReSharper is a .NET coding add-in 104 | _ReSharper*/ 105 | *.[Rr]e[Ss]harper 106 | *.DotSettings.user 107 | 108 | # JustCode is a .NET coding add-in 109 | .JustCode 110 | 111 | # TeamCity is a build add-in 112 | _TeamCity* 113 | 114 | # DotCover is a Code Coverage Tool 115 | *.dotCover 116 | 117 | # Visual Studio code coverage results 118 | *.coverage 119 | *.coveragexml 120 | 121 | # NCrunch 122 | _NCrunch_* 123 | .*crunch*.local.xml 124 | nCrunchTemp_* 125 | 126 | # MightyMoose 127 | *.mm.* 128 | AutoTest.Net/ 129 | 130 | # Web workbench (sass) 131 | .sass-cache/ 132 | 133 | # Installshield output folder 134 | [Ee]xpress/ 135 | 136 | # DocProject is a documentation generator add-in 137 | DocProject/buildhelp/ 138 | DocProject/Help/*.HxT 139 | DocProject/Help/*.HxC 140 | DocProject/Help/*.hhc 141 | DocProject/Help/*.hhk 142 | DocProject/Help/*.hhp 143 | DocProject/Help/Html2 144 | DocProject/Help/html 145 | 146 | # Click-Once directory 147 | publish/ 148 | 149 | # Publish Web Output 150 | *.[Pp]ublish.xml 151 | *.azurePubxml 152 | # TODO: Comment the next line if you want to checkin your web deploy settings 153 | # but database connection strings (with potential passwords) will be unencrypted 154 | *.pubxml 155 | *.publishproj 156 | 157 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 158 | # checkin your Azure Web App publish settings, but sensitive information contained 159 | # in these scripts will be unencrypted 160 | PublishScripts/ 161 | 162 | # NuGet Packages 163 | *.nupkg 164 | # The packages folder can be ignored because of Package Restore 165 | **/packages/* 166 | # except build/, which is used as an MSBuild target. 167 | !**/packages/build/ 168 | # Uncomment if necessary however generally it will be regenerated when needed 169 | #!**/packages/repositories.config 170 | # NuGet v3's project.json files produces more ignoreable files 171 | *.nuget.props 172 | *.nuget.targets 173 | 174 | # Microsoft Azure Build Output 175 | csx/ 176 | *.build.csdef 177 | 178 | # Microsoft Azure Emulator 179 | ecf/ 180 | rcf/ 181 | 182 | # Windows Store app package directories and files 183 | AppPackages/ 184 | BundleArtifacts/ 185 | Package.StoreAssociation.xml 186 | _pkginfo.txt 187 | 188 | # Visual Studio cache files 189 | # files ending in .cache can be ignored 190 | *.[Cc]ache 191 | # but keep track of directories ending in .cache 192 | !*.[Cc]ache/ 193 | 194 | # Others 195 | ClientBin/ 196 | ~$* 197 | *~ 198 | *.dbmdl 199 | *.dbproj.schemaview 200 | *.jfm 201 | *.pfx 202 | *.publishsettings 203 | node_modules/ 204 | orleans.codegen.cs 205 | 206 | # Since there are multiple workflows, uncomment next line to ignore bower_components 207 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 208 | #bower_components/ 209 | 210 | # RIA/Silverlight projects 211 | Generated_Code/ 212 | 213 | # Backup & report files from converting an old project file 214 | # to a newer Visual Studio version. Backup files are not needed, 215 | # because we have git ;-) 216 | _UpgradeReport_Files/ 217 | Backup*/ 218 | UpgradeLog*.XML 219 | UpgradeLog*.htm 220 | 221 | # SQL Server files 222 | *.mdf 223 | *.ldf 224 | 225 | # Business Intelligence projects 226 | *.rdl.data 227 | *.bim.layout 228 | *.bim_*.settings 229 | 230 | # Microsoft Fakes 231 | FakesAssemblies/ 232 | 233 | # GhostDoc plugin setting file 234 | *.GhostDoc.xml 235 | 236 | # Node.js Tools for Visual Studio 237 | .ntvs_analysis.dat 238 | 239 | # Visual Studio 6 build log 240 | *.plg 241 | 242 | # Visual Studio 6 workspace options file 243 | *.opt 244 | 245 | # Visual Studio LightSwitch build output 246 | **/*.HTMLClient/GeneratedArtifacts 247 | **/*.DesktopClient/GeneratedArtifacts 248 | **/*.DesktopClient/ModelManifest.xml 249 | **/*.Server/GeneratedArtifacts 250 | **/*.Server/ModelManifest.xml 251 | _Pvt_Extensions 252 | 253 | # Paket dependency manager 254 | .paket/paket.exe 255 | paket-files/ 256 | 257 | # FAKE - F# Make 258 | .fake/ 259 | 260 | # JetBrains Rider 261 | .idea/ 262 | *.sln.iml 263 | 264 | # CodeRush 265 | .cr/ 266 | 267 | # Python Tools for Visual Studio (PTVS) 268 | __pycache__/ 269 | *.pyc 270 | 271 | # Cake - Uncomment if you are using it 272 | # tools/ 273 | 274 | 275 | # End of https://www.gitignore.io/api/aspnetcore -------------------------------------------------------------------------------- /BookCovers.API/BookCovers.API.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /BookCovers.API/Controllers/BookCoversController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace BookCovers.API.Controllers 4 | { 5 | [Route("api/bookcovers")] 6 | [ApiController] 7 | public class BookCoversController : ControllerBase 8 | { 9 | [HttpGet("{name}")] 10 | public async Task GetBookCover(string name, bool returnFault = false) 11 | { 12 | // if returnFault is true, wait 100ms and 13 | // return an Internal Server Error 14 | if (returnFault) 15 | { 16 | await Task.Delay(100); 17 | return new StatusCodeResult(500); 18 | } 19 | 20 | // generate a "book cover" (byte array) between 2 and 10MB 21 | var random = new Random(); 22 | int fakeCoverBytes = random.Next(2097152, 10485760); 23 | byte[] fakeCover = new byte[fakeCoverBytes]; 24 | random.NextBytes(fakeCover); 25 | 26 | return Ok(new 27 | { 28 | Name = name, 29 | Content = fakeCover 30 | }); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /BookCovers.API/Program.cs: -------------------------------------------------------------------------------- 1 | var builder = WebApplication.CreateBuilder(args); 2 | 3 | // Add services to the container. 4 | 5 | builder.Services.AddControllers(); 6 | 7 | var app = builder.Build(); 8 | 9 | // Configure the HTTP request pipeline. 10 | 11 | app.UseAuthorization(); 12 | 13 | app.MapControllers(); 14 | 15 | app.Run(); 16 | -------------------------------------------------------------------------------- /BookCovers.API/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:52644", 8 | "sslPort": 44339 9 | } 10 | }, 11 | "profiles": { 12 | "IIS Express": { 13 | "commandName": "IISExpress", 14 | "launchBrowser": true, 15 | "launchUrl": "api/values", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "BookCovers.API": { 21 | "commandName": "Project", 22 | "launchBrowser": false, 23 | "launchUrl": "api/values", 24 | "applicationUrl": "http://localhost:52644", 25 | "environmentVariables": { 26 | "ASPNETCORE_ENVIRONMENT": "Development" 27 | } 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /BookCovers.API/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "System": "Information", 6 | "Microsoft": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /BookCovers.API/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Warning" 5 | } 6 | }, 7 | "AllowedHosts": "*" 8 | } 9 | -------------------------------------------------------------------------------- /Books.Api/ArrayModelBinder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.ModelBinding; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.ComponentModel; 5 | using System.Linq; 6 | using System.Reflection; 7 | using System.Threading.Tasks; 8 | 9 | namespace Books.Api 10 | { 11 | public class ArrayModelBinder : IModelBinder 12 | { 13 | public Task BindModelAsync(ModelBindingContext bindingContext) 14 | { 15 | // Our binder works only on enumerable types 16 | if (!bindingContext.ModelMetadata.IsEnumerableType) 17 | { 18 | bindingContext.Result = ModelBindingResult.Failed(); 19 | return Task.CompletedTask; 20 | } 21 | 22 | // Get the inputted value through the value provider 23 | var value = bindingContext.ValueProvider 24 | .GetValue(bindingContext.ModelName).ToString(); 25 | 26 | // If that value is null or whitespace, we return null 27 | if (string.IsNullOrWhiteSpace(value)) 28 | { 29 | bindingContext.Result = ModelBindingResult.Success(null); 30 | return Task.CompletedTask; 31 | } 32 | 33 | // The value isn't null or whitespace, 34 | // and the type of the model is enumerable. 35 | // Get the enumerable's type, and a converter 36 | var elementType = bindingContext.ModelType.GetTypeInfo().GenericTypeArguments[0]; 37 | var converter = TypeDescriptor.GetConverter(elementType); 38 | 39 | // Convert each item in the value list to the enumerable type 40 | var values = value.Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries) 41 | .Select(x => converter.ConvertFromString(x.Trim())) 42 | .ToArray(); 43 | 44 | // Create an array of that type, and set it as the Model value 45 | var typedValues = Array.CreateInstance(elementType, values.Length); 46 | values.CopyTo(typedValues, 0); 47 | bindingContext.Model = typedValues; 48 | 49 | // return a successful result, passing in the Model 50 | bindingContext.Result = ModelBindingResult.Success(bindingContext.Model); 51 | return Task.CompletedTask; 52 | } 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /Books.Api/Books.Api.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /Books.Api/BooksProfile.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | 7 | namespace Books.Api 8 | { 9 | public class BooksProfile : Profile 10 | { 11 | public BooksProfile() 12 | { 13 | CreateMap() 14 | .ForMember(dest => dest.Author, opt => opt.MapFrom(src => 15 | $"{src.Author.FirstName} {src.Author.LastName}")); 16 | 17 | CreateMap(); 18 | 19 | CreateMap() 20 | .ForMember(dest => dest.Author, opt => opt.MapFrom(src => 21 | $"{src.Author.FirstName} {src.Author.LastName}")); 22 | 23 | CreateMap, Models.BookWithCovers>() 24 | .ForMember(dest => dest.BookCovers, opt => opt.MapFrom(src => 25 | src)); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Books.Api/Contexts/BooksContext.cs: -------------------------------------------------------------------------------- 1 | using Books.Api.Entities; 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | namespace Books.Api.Contexts 5 | { 6 | public class BooksContext : DbContext 7 | { 8 | public DbSet Books { get; set; } = null!; 9 | 10 | public BooksContext(DbContextOptions options) 11 | : base(options) 12 | { 13 | } 14 | 15 | protected override void OnModelCreating(ModelBuilder modelBuilder) 16 | { 17 | // seed the database with dummy data 18 | modelBuilder.Entity().HasData( 19 | new Author() 20 | { 21 | Id = Guid.Parse("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 22 | FirstName = "George", 23 | LastName = "RR Martin" 24 | }, 25 | new Author() 26 | { 27 | Id = Guid.Parse("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 28 | FirstName = "Stephen", 29 | LastName = "Fry" 30 | }, 31 | new Author() 32 | { 33 | Id = Guid.Parse("24810dfc-2d94-4cc7-aab5-cdf98b83f0c9"), 34 | FirstName = "James", 35 | LastName = "Elroy" 36 | }, 37 | new Author() 38 | { 39 | Id = Guid.Parse("2902b665-1190-4c70-9915-b9c2d7680450"), 40 | FirstName = "Douglas", 41 | LastName = "Adams" 42 | } 43 | ); 44 | 45 | // seed the database with dummy data 46 | modelBuilder.Entity().HasData( 47 | new Book 48 | { 49 | Id = Guid.Parse("5b1c2b4d-48c7-402a-80c3-cc796ad49c6b"), 50 | AuthorId = Guid.Parse("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 51 | Title = "The Winds of Winter", 52 | Description = "The book that seems impossible to write." 53 | }, 54 | new Book 55 | { 56 | Id = Guid.Parse("d8663e5e-7494-4f81-8739-6e0de1bea7ee"), 57 | AuthorId = Guid.Parse("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 58 | Title = "A Game of Thrones", 59 | Description = "A Game of Thrones is the first novel in A Song of Ice and Fire, a series of fantasy novels by American author George R. R. ... In the novel, recounting events from various points of view, Martin introduces the plot-lines of the noble houses of Westeros, the Wall, and the Targaryens." 60 | }, 61 | new Book 62 | { 63 | Id = Guid.Parse("d173e20d-159e-4127-9ce9-b0ac2564ad97"), 64 | AuthorId = Guid.Parse("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 65 | Title = "Mythos", 66 | Description = "The Greek myths are amongst the best stories ever told, passed down through millennia and inspiring writers and artists as varied as Shakespeare, Michelangelo, James Joyce and Walt Disney. They are embedded deeply in the traditions, tales and cultural DNA of the West.You'll fall in love with Zeus, marvel at the birth of Athena, wince at Cronus and Gaia's revenge on Ouranos, weep with King Midas and hunt with the beautiful and ferocious Artemis. Spellbinding, informative and moving, Stephen Fry's Mythos perfectly captures these stories for the modern age - in all their rich and deeply human relevance." 67 | }, 68 | new Book 69 | { 70 | Id = Guid.Parse("493c3228-3444-4a49-9cc0-e8532edc59b2"), 71 | AuthorId = Guid.Parse("24810dfc-2d94-4cc7-aab5-cdf98b83f0c9"), 72 | Title = "American Tabloid", 73 | Description = "American Tabloid is a 1995 novel by James Ellroy that chronicles the events surrounding three rogue American law enforcement officers from November 22, 1958 through November 22, 1963. Each becomes entangled in a web of interconnecting associations between the FBI, the CIA, and the mafia, which eventually leads to their collective involvement in the John F. Kennedy assassination." 74 | }, 75 | new Book 76 | { 77 | Id = Guid.Parse("40ff5488-fdab-45b5-bc3a-14302d59869a"), 78 | AuthorId = Guid.Parse("2902b665-1190-4c70-9915-b9c2d7680450"), 79 | Title = "The Hitchhiker's Guide to the Galaxy", 80 | Description = "In The Hitchhiker's Guide to the Galaxy, the characters visit the legendary planet Magrathea, home to the now-collapsed planet-building industry, and meet Slartibartfast, a planetary coastline designer who was responsible for the fjords of Norway. Through archival recordings, he relates the story of a race of hyper-intelligent pan-dimensional beings who built a computer named Deep Thought to calculate the Answer to the Ultimate Question of Life, the Universe, and Everything." 81 | } 82 | ); 83 | 84 | base.OnModelCreating(modelBuilder); 85 | } 86 | } 87 | } -------------------------------------------------------------------------------- /Books.Api/Controllers/BooksController.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using Books.Api.Models; 3 | using Books.Api.Services; 4 | using Books.Legacy; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace Books.Api.Controllers 8 | { 9 | [Route("api/books")] 10 | [ApiController] 11 | public class BooksController : ControllerBase 12 | { 13 | private readonly IBooksRepository _booksRepository; 14 | private readonly IMapper _mapper; 15 | private readonly ILogger _logger; 16 | private readonly ComplicatedPageCalculator _complicatedPageCalculator; 17 | 18 | public BooksController(IBooksRepository booksRepository, 19 | IMapper mapper, ILogger logger, 20 | ComplicatedPageCalculator complicatedPageCalculator) 21 | { 22 | _booksRepository = booksRepository ?? 23 | throw new ArgumentNullException(nameof(booksRepository)); 24 | _mapper = mapper ?? 25 | throw new ArgumentNullException(nameof(mapper)); 26 | _logger = logger ?? 27 | throw new ArgumentNullException(nameof(logger)); 28 | _complicatedPageCalculator = complicatedPageCalculator ?? 29 | throw new ArgumentNullException(nameof(complicatedPageCalculator)); 30 | } 31 | 32 | [HttpGet("{id}", Name = "GetBook")] 33 | public async Task> GetBookWithBookCovers(Guid id) 34 | { 35 | var bookEntity = await _booksRepository.GetBookAsync(id); 36 | 37 | if (bookEntity == null) 38 | { 39 | return NotFound(); 40 | } 41 | 42 | // get bookcovers 43 | var bookCovers = await _booksRepository.GetBookCoversAsync(id); 44 | 45 | // map book & covers into one BookWithCovers 46 | var mappedBook = _mapper.Map(bookEntity); 47 | return Ok(_mapper.Map(bookCovers, mappedBook)); 48 | } 49 | 50 | 51 | [HttpPost] 52 | public async Task> CreateBook([FromBody] BookForCreation book) 53 | { 54 | var bookEntity = _mapper.Map(book); 55 | _booksRepository.AddBook(bookEntity); 56 | 57 | await _booksRepository.SaveChangesAsync(); 58 | 59 | // Fetch (refetch) the book from the data store, including the author 60 | await _booksRepository.GetBookAsync(bookEntity.Id); 61 | 62 | return CreatedAtRoute("GetBook", 63 | new {id = bookEntity.Id}, 64 | _mapper.Map(bookEntity)); 65 | } 66 | 67 | #region IAsyncEnumerable sample right up to the database 68 | 69 | [HttpGet] 70 | public async IAsyncEnumerable YieldBooksOneByOneFromDatabase() 71 | { 72 | await foreach (var bookFromRepository in _booksRepository 73 | .GetBooksAsAsyncEnumerable()) 74 | { 75 | // add a delay to visually see the effect 76 | await Task.Delay(500); 77 | yield return _mapper.Map(bookFromRepository); 78 | } 79 | } 80 | 81 | #endregion 82 | 83 | #region IAsyncEnumerable sample 84 | //[HttpGet] 85 | //public IActionResult YieldBooksOneByOne() 86 | //{ 87 | // return Ok(GenerateBookStream()); 88 | //} 89 | 90 | static async IAsyncEnumerable GenerateBookStream() 91 | { 92 | for (int i = 1; i <= 100; i++) 93 | { 94 | // add a delay to visually see the effect 95 | await Task.Delay(100); 96 | yield return new Book() 97 | { 98 | Author = "A yielded author", 99 | Description = "Nothing to see here", 100 | Title = $"Book {i}", 101 | Id = Guid.NewGuid() 102 | }; 103 | } 104 | } 105 | #endregion 106 | 107 | #region Additional sample: GetBook with GetBookPages (code smell sample) 108 | 109 | // sample of bad code - don't await CPU-bound calls in ASP.NET Core, 110 | // call them in sync instead 111 | //[HttpGet("{id}", Name = "GetBook")] 112 | //public async Task GetBook(Guid id) 113 | //{ 114 | // var bookEntity = await _booksRepository.GetBookAsync(id); 115 | // 116 | // if (bookEntity == null) 117 | // { 118 | // return NotFound(); 119 | // } 120 | // 121 | // _logger.LogInformation($"ThreadId when entering GetBook: " + 122 | // $"{System.Threading.Thread.CurrentThread.ManagedThreadId}"); 123 | 124 | // var pages = await GetBookPages(id); 125 | 126 | // return Ok(_mapper.Map(bookEntity)); 127 | //} 128 | 129 | // private async Task GetBookPages(Guid id) 130 | // { 131 | // return await Task.Run(() => 132 | // { 133 | // _logger.LogInformation($"ThreadId when calculating the amount of pages: " + 134 | // $"{System.Threading.Thread.CurrentThread.ManagedThreadId}"); 135 | // 136 | // return _complicatedPageCalculator.CalculateBookPages(id); 137 | // }); 138 | // } 139 | 140 | #endregion 141 | 142 | #region Additional sample: call legacy CPU-bound code (GetBook with direct call into CalculateBookPages) 143 | 144 | // [HttpGet("{id}", Name = "GetBook")] 145 | // public async Task GetBook(Guid id) 146 | // { 147 | // var bookEntity = await _booksRepository.GetBookAsync(id); 148 | // 149 | // if (bookEntity == null) 150 | // { 151 | // return NotFound(); 152 | // } 153 | // 154 | // var pages = _complicatedPageCalculator.CalculateBookPages(id); 155 | // 156 | // return Ok(_mapper.Map(bookEntity)); 157 | // } 158 | 159 | #endregion 160 | 161 | } 162 | } -------------------------------------------------------------------------------- /Books.Api/Entities/Author.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel.DataAnnotations; 4 | using System.ComponentModel.DataAnnotations.Schema; 5 | using System.Linq; 6 | using System.Threading.Tasks; 7 | 8 | namespace Books.Api.Entities 9 | { 10 | [Table("Authors")] 11 | public class Author 12 | { 13 | [Key] 14 | public Guid Id { get; set; } 15 | 16 | [Required] 17 | [MaxLength(150)] 18 | public string FirstName { get; set; } = string.Empty; 19 | 20 | [Required] 21 | [MaxLength(150)] 22 | public string LastName { get; set; } = string.Empty; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Books.Api/Entities/Book.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | 4 | namespace Books.Api.Entities 5 | { 6 | [Table("Books")] 7 | public class Book 8 | { 9 | [Key] 10 | public Guid Id { get; set; } 11 | 12 | [Required] 13 | [MaxLength(150)] 14 | public string Title { get; set; } = string.Empty; 15 | 16 | [MaxLength(2500)] 17 | public string Description { get; set; } = string.Empty; 18 | 19 | public Guid AuthorId { get; set; } 20 | 21 | // as per https://docs.microsoft.com/en-us/ef/core/miscellaneous/nullable-reference-types 22 | public Author Author { get; set; } = null!; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Books.Api/ExternalModels/BookCover.cs: -------------------------------------------------------------------------------- 1 | namespace Books.Api.ExternalModels 2 | { 3 | public class BookCover 4 | { 5 | public string Name { get; set; } = string.Empty; 6 | public byte[] Content { get; set; } = null!; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Books.Api/Models/Book.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Books.Api.Models 4 | { 5 | public class Book 6 | { 7 | public Guid Id { get; set; } 8 | 9 | public string Author { get; set; } = string.Empty; 10 | 11 | public string Title { get; set; } = string.Empty; 12 | 13 | public string Description { get; set; } = string.Empty; 14 | 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Books.Api/Models/BookCover.cs: -------------------------------------------------------------------------------- 1 | namespace Books.Api.Models 2 | { 3 | public class BookCover 4 | { 5 | public string Name { get; set; } = string.Empty; 6 | 7 | // don't return the bytes for the demo to avoid 8 | // long waiting times 9 | // public byte[] Content { get; set; } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Books.Api/Models/BookForCreation.cs: -------------------------------------------------------------------------------- 1 | namespace Books.Api.Models 2 | { 3 | public class BookForCreation 4 | { 5 | public Guid AuthorId { get; set; } 6 | 7 | public string Title { get; set; } = string.Empty; 8 | 9 | public string Description { get; set; } = string.Empty; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Books.Api/Models/BookWithCovers.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Books.Api.Models 4 | { 5 | public class BookWithCovers : Book 6 | { 7 | public IEnumerable BookCovers { get; set; } = new List(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Books.Api/Program.cs: -------------------------------------------------------------------------------- 1 | using Books.Api.Contexts; 2 | using Books.Api.Services; 3 | using Books.Legacy; 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | var builder = WebApplication.CreateBuilder(args); 7 | 8 | // Add services to the container. 9 | 10 | builder.Services.AddControllers(); 11 | 12 | builder.Services.AddDbContext( 13 | dbContextOptions => dbContextOptions.UseSqlite( 14 | builder.Configuration["ConnectionStrings:BooksDBConnectionString"])); 15 | 16 | builder.Services.AddScoped(); 17 | 18 | builder.Services.AddTransient(); 19 | 20 | builder.Services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies()); 21 | 22 | builder.Services.AddHttpClient(); 23 | 24 | var app = builder.Build(); 25 | 26 | // Configure the HTTP request pipeline. 27 | 28 | app.UseAuthorization(); 29 | 30 | app.MapControllers(); 31 | 32 | app.Run(); 33 | -------------------------------------------------------------------------------- /Books.Api/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:59408", 7 | "sslPort": 44313 8 | } 9 | }, 10 | "$schema": "http://json.schemastore.org/launchsettings.json", 11 | "profiles": { 12 | "IIS Express": { 13 | "commandName": "IISExpress", 14 | "launchBrowser": true, 15 | "launchUrl": "api/books", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "Books.Api": { 21 | "commandName": "Project", 22 | "launchBrowser": true, 23 | "launchUrl": "api", 24 | "environmentVariables": { 25 | "ASPNETCORE_ENVIRONMENT": "Development" 26 | }, 27 | "applicationUrl": "http://localhost:59408" 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /Books.Api/Services/BooksRepository.cs: -------------------------------------------------------------------------------- 1 | using Books.Api.Contexts; 2 | using Books.Api.Entities; 3 | using Books.Api.ExternalModels; 4 | using Microsoft.EntityFrameworkCore; 5 | using System.Text.Json; 6 | 7 | namespace Books.Api.Services 8 | { 9 | public class BooksRepository : IBooksRepository 10 | { 11 | private readonly BooksContext _context; 12 | private readonly IHttpClientFactory _httpClientFactory; 13 | private readonly ILogger _logger; 14 | private CancellationTokenSource _cancellationTokenSource = null!; 15 | 16 | public BooksRepository(BooksContext context, 17 | IHttpClientFactory httpClientFactory, 18 | ILogger logger) 19 | { 20 | _context = context ?? throw new ArgumentNullException(nameof(context)); 21 | _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); 22 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 23 | } 24 | 25 | public async Task GetBookAsync(Guid id) 26 | { 27 | if (id == Guid.Empty) 28 | { 29 | throw new ArgumentNullException(nameof(id)); 30 | } 31 | 32 | return await _context.Books.Include(b => b.Author) 33 | .FirstOrDefaultAsync(b => b.Id == id); 34 | } 35 | 36 | public void AddBook(Book bookToAdd) 37 | { 38 | if (bookToAdd == null) 39 | { 40 | throw new ArgumentNullException(nameof(bookToAdd)); 41 | } 42 | 43 | _context.Add(bookToAdd); 44 | } 45 | 46 | public async Task SaveChangesAsync() 47 | { 48 | return (await _context.SaveChangesAsync() > 0); 49 | } 50 | 51 | public async Task> GetBookCoversAsync(Guid bookId) 52 | { 53 | var httpClient = _httpClientFactory.CreateClient(); 54 | var bookCovers = new List(); 55 | _cancellationTokenSource = new CancellationTokenSource(); 56 | 57 | // create a list of fake bookcovers 58 | var bookCoverUrls = new[] 59 | { 60 | $"http://localhost:52644/api/bookcovers/{bookId}-dummycover1", 61 | $"http://localhost:52644/api/bookcovers/{bookId}-dummycover2?returnFault=true", 62 | $"http://localhost:52644/api/bookcovers/{bookId}-dummycover3", 63 | $"http://localhost:52644/api/bookcovers/{bookId}-dummycover4", 64 | $"http://localhost:52644/api/bookcovers/{bookId}-dummycover5" 65 | }; 66 | 67 | // foreach + await will run them in order. We prefer parallel. 68 | 69 | // create the tasks 70 | var downloadBookCoverTasksQuery = 71 | from bookCoverUrl 72 | in bookCoverUrls 73 | select DownloadBookCoverAsync( 74 | httpClient, 75 | bookCoverUrl, 76 | _cancellationTokenSource.Token); 77 | 78 | // start the tasks 79 | var downloadBookCoverTasks = downloadBookCoverTasksQuery.ToList(); 80 | 81 | try 82 | { 83 | // get the results 84 | var results = await Task.WhenAll(downloadBookCoverTasks); 85 | // return results that aren't null 86 | return results.Where(r => r != null); 87 | } 88 | catch (OperationCanceledException operationCanceledException) 89 | { 90 | _logger.LogInformation($"{operationCanceledException.Message}"); 91 | foreach (var task in downloadBookCoverTasks) 92 | { 93 | _logger.LogInformation($"Task {task.Id} has status {task.Status}"); 94 | } 95 | 96 | return new List(); 97 | } 98 | catch (Exception exception) 99 | { 100 | _logger.LogError($"{exception.Message}"); 101 | throw; 102 | } 103 | } 104 | 105 | public async Task> GetBookCoversWithoutCancellationWithForEachAsync(Guid bookId) 106 | { 107 | var httpClient = _httpClientFactory.CreateClient(); 108 | var bookCovers = new List(); 109 | 110 | // create a list of fake bookcovers 111 | var bookCoverUrls = new[] 112 | { 113 | $"http://localhost:52644/api/bookcovers/{bookId}-dummycover1", 114 | $"http://localhost:52644/api/bookcovers/{bookId}-dummycover2?returnFault=true", 115 | $"http://localhost:52644/api/bookcovers/{bookId}-dummycover3", 116 | $"http://localhost:52644/api/bookcovers/{bookId}-dummycover4", 117 | $"http://localhost:52644/api/bookcovers/{bookId}-dummycover5" 118 | }; 119 | 120 | // foreach + await will run them in order. 121 | foreach (var bookCoverUrl in bookCoverUrls) 122 | { 123 | var cover = await DownloadBookCoverWithoutCancellationSupportAsync( 124 | httpClient, bookCoverUrl); 125 | 126 | if (cover != null) 127 | { 128 | bookCovers.Add(cover); 129 | } 130 | } 131 | 132 | return bookCovers; 133 | } 134 | 135 | public async Task> GetBookCoversWithoutCancellationAsync(Guid bookId) 136 | { 137 | var httpClient = _httpClientFactory.CreateClient(); 138 | var bookCovers = new List(); 139 | 140 | // create a list of fake bookcovers 141 | var bookCoverUrls = new[] 142 | { 143 | $"http://localhost:52644/api/bookcovers/{bookId}-dummycover1", 144 | $"http://localhost:52644/api/bookcovers/{bookId}-dummycover2?returnFault=true", 145 | $"http://localhost:52644/api/bookcovers/{bookId}-dummycover3", 146 | $"http://localhost:52644/api/bookcovers/{bookId}-dummycover4", 147 | $"http://localhost:52644/api/bookcovers/{bookId}-dummycover5" 148 | }; 149 | 150 | // foreach + await will run them in order. We prefer parallel. 151 | // create the tasks 152 | var downloadBookCoverTasksQuery = 153 | from bookCoverUrl 154 | in bookCoverUrls 155 | select DownloadBookCoverWithoutCancellationSupportAsync( 156 | httpClient, bookCoverUrl); 157 | 158 | // start the tasks 159 | var downloadBookCoverTasks = downloadBookCoverTasksQuery.ToList(); 160 | // get the results 161 | var results = await Task.WhenAll(downloadBookCoverTasks); 162 | // return results that aren't null 163 | return results.Where(r => r != null); 164 | } 165 | 166 | /// 167 | /// Download book cover without cancellation support 168 | /// 169 | private async Task DownloadBookCoverWithoutCancellationSupportAsync( 170 | HttpClient httpClient, 171 | string bookCoverUrl) 172 | { 173 | var response = await httpClient 174 | .GetAsync(bookCoverUrl); 175 | 176 | if (response.IsSuccessStatusCode) 177 | { 178 | var bookCover = JsonSerializer.Deserialize( 179 | await response.Content.ReadAsStringAsync(), 180 | new JsonSerializerOptions() 181 | { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); 182 | return bookCover; 183 | } 184 | 185 | return null; 186 | } 187 | 188 | /// 189 | /// Download book cover with cancellation support 190 | /// 191 | private async Task DownloadBookCoverAsync( 192 | HttpClient httpClient, 193 | string bookCoverUrl, 194 | CancellationToken cancellationToken) 195 | { 196 | var response = await httpClient 197 | .GetAsync(bookCoverUrl, cancellationToken); 198 | 199 | if (response.IsSuccessStatusCode) 200 | { 201 | var bookCover = JsonSerializer.Deserialize( 202 | await response.Content.ReadAsStringAsync(cancellationToken), 203 | new JsonSerializerOptions() 204 | { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); 205 | return bookCover; 206 | } 207 | 208 | _cancellationTokenSource.Cancel(); 209 | 210 | return null; 211 | } 212 | 213 | public IAsyncEnumerable GetBooksAsAsyncEnumerable() 214 | { 215 | return _context.Books.AsAsyncEnumerable(); 216 | } 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /Books.Api/Services/IBooksRepository.cs: -------------------------------------------------------------------------------- 1 | using Books.Api.Entities; 2 | using Books.Api.ExternalModels; 3 | 4 | namespace Books.Api.Services 5 | { 6 | public interface IBooksRepository 7 | { 8 | Task GetBookAsync(Guid id); 9 | void AddBook(Book bookToAdd); 10 | Task SaveChangesAsync(); 11 | Task> GetBookCoversWithoutCancellationWithForEachAsync(Guid bookId); 12 | Task> GetBookCoversWithoutCancellationAsync(Guid bookId); 13 | Task> GetBookCoversAsync(Guid bookId); 14 | IAsyncEnumerable GetBooksAsAsyncEnumerable(); 15 | 16 | } 17 | } -------------------------------------------------------------------------------- /Books.Api/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "System": "Information", 6 | "Microsoft": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Books.Api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Warning" 5 | } 6 | }, 7 | "AllowedHosts": "*", 8 | "ConnectionStrings": { 9 | "BooksDBConnectionString": "Data Source=Books.db" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Books.Legacy/Books.Legacy.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard21 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Books.Legacy/ComplicatedPageCalculator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Threading; 4 | 5 | namespace Books.Legacy 6 | { 7 | public class ComplicatedPageCalculator 8 | { 9 | /// 10 | /// Full CPU load for 5 seconds 11 | /// 12 | public int CalculateBookPages(Guid bookId) 13 | { 14 | var watch = new Stopwatch(); 15 | watch.Start(); 16 | while (true) 17 | { 18 | if (watch.ElapsedMilliseconds > 5000) 19 | { 20 | break; 21 | } 22 | } 23 | 24 | return 42; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Books.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.27428.2027 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Books.Api", "Books.Api\Books.Api.csproj", "{E3710F56-292E-4E9E-B01A-89F1ABDFA9EE}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Books.Legacy", "Books.Legacy\Books.Legacy.csproj", "{78AA6655-AECA-4B1A-BD3F-BE75C1C664CD}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BookCovers.API", "BookCovers.API\BookCovers.API.csproj", "{452A54A3-9213-4D97-9BD7-592302463242}" 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Release|Any CPU = Release|Any CPU 16 | EndGlobalSection 17 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 18 | {E3710F56-292E-4E9E-B01A-89F1ABDFA9EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 19 | {E3710F56-292E-4E9E-B01A-89F1ABDFA9EE}.Debug|Any CPU.Build.0 = Debug|Any CPU 20 | {E3710F56-292E-4E9E-B01A-89F1ABDFA9EE}.Release|Any CPU.ActiveCfg = Release|Any CPU 21 | {E3710F56-292E-4E9E-B01A-89F1ABDFA9EE}.Release|Any CPU.Build.0 = Release|Any CPU 22 | {78AA6655-AECA-4B1A-BD3F-BE75C1C664CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {78AA6655-AECA-4B1A-BD3F-BE75C1C664CD}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {78AA6655-AECA-4B1A-BD3F-BE75C1C664CD}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {78AA6655-AECA-4B1A-BD3F-BE75C1C664CD}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {452A54A3-9213-4D97-9BD7-592302463242}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {452A54A3-9213-4D97-9BD7-592302463242}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {452A54A3-9213-4D97-9BD7-592302463242}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {452A54A3-9213-4D97-9BD7-592302463242}.Release|Any CPU.Build.0 = Release|Any CPU 30 | EndGlobalSection 31 | GlobalSection(SolutionProperties) = preSolution 32 | HideSolutionNode = FALSE 33 | EndGlobalSection 34 | GlobalSection(ExtensibilityGlobals) = postSolution 35 | SolutionGuid = {9914921E-DFAC-4FC5-A641-EE317C79DCAD} 36 | EndGlobalSection 37 | EndGlobal 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Kevin Dockx 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Best Practices for Building Async APIs with ASP.NET Core 2 | Demo code for my "Best Practices for Building Async APIs with ASP.NET Core" session. Updated to .NET 6 with new samples, including IAsyncEnumerable sample (4/2022). 3 | 4 | [Slides can be found here](https://1drv.ms/p/s!ApreYKXHsE4LnZcWmakRejwdsAtH3w) 5 | --------------------------------------------------------------------------------