├── .gitignore ├── Finished sample ├── CourseLibrary.API │ ├── ActionConstraints │ │ └── RequestHeaderMatchesMediaTypeAttribute.cs │ ├── Controllers │ │ ├── AuthorCollectionsController.cs │ │ ├── AuthorsController.cs │ │ ├── CoursesController.cs │ │ └── RootController.cs │ ├── CourseLibrary.API.csproj │ ├── DbContexts │ │ └── CourseLibraryContext.cs │ ├── Entities │ │ ├── Author.cs │ │ └── Course.cs │ ├── Helpers │ │ ├── ArrayModelBinder.cs │ │ ├── DateTimeOffsetExtensions.cs │ │ ├── IEnumerableExtensions.cs │ │ ├── IQueryableExtensions.cs │ │ ├── ObjectExtensions.cs │ │ ├── PagedList.cs │ │ └── ResourceUriType.cs │ ├── Migrations │ │ ├── AddDateOfDeathToAuthor.Designer.cs │ │ ├── AddDateOfDeathToAuthor.cs │ │ ├── CourseLibraryContextModelSnapshot.cs │ │ ├── InitialMigration.Designer.cs │ │ └── InitialMigration.cs │ ├── Models │ │ ├── AuthorDto.cs │ │ ├── AuthorForCreationDto.cs │ │ ├── AuthorForCreationWithDateOfDeathDto.cs │ │ ├── AuthorFullDto.cs │ │ ├── CourseDto.cs │ │ ├── CourseForCreationDto.cs │ │ ├── CourseForManipulationDto.cs │ │ ├── CourseForUpdateDto.cs │ │ └── LinkDto.cs │ ├── Profiles │ │ ├── AuthorProfile.cs │ │ └── CourseProfile.cs │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── ResourceParameters │ │ └── AuthorsResourceParameters.cs │ ├── Services │ │ ├── CourseLibraryRepository.cs │ │ ├── ICourseLibraryRepository.cs │ │ ├── IPropertyCheckerService.cs │ │ ├── IPropertyMapping.cs │ │ ├── IPropertyMappingService.cs │ │ ├── PropertyCheckerService.cs │ │ ├── PropertyMapping.cs │ │ ├── PropertyMappingService.cs │ │ └── PropertyMappingValue.cs │ ├── StartupHelperExtensions.cs │ ├── ValidationAttributes │ │ └── CourseTitleMustBeDifferentFromDescriptionAttribute.cs │ ├── appsettings.Development.json │ ├── appsettings.json │ └── library.db └── CourseLibrary.sln ├── LICENSE ├── README.md └── Starter files ├── ASP.NET Core Web API Deep Dive.postman_collection.json ├── CourseLibrary.API ├── Controllers │ ├── AuthorsController.cs │ └── CoursesController.cs ├── CourseLibrary.API.csproj ├── DbContexts │ └── CourseLibraryContext.cs ├── Entities │ ├── Author.cs │ └── Course.cs ├── Helpers │ └── DateTimeOffsetExtensions.cs ├── Migrations │ ├── CourseLibraryContextModelSnapshot.cs │ ├── InitialMigration.Designer.cs │ └── InitialMigration.cs ├── Models │ ├── AuthorDto.cs │ ├── CourseDto.cs │ └── CourseForCreationDto.cs ├── Profiles │ ├── AuthorProfile.cs │ └── CourseProfile.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── Services │ ├── CourseLibraryRepository.cs │ └── ICourseLibraryRepository.cs ├── StartupHelperExtensions.cs ├── appsettings.Development.json ├── appsettings.json └── library.db └── CourseLibrary.sln /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # ASP.NET Scaffolding 66 | ScaffoldingReadMe.txt 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | *.sbr 86 | *.tlb 87 | *.tli 88 | *.tlh 89 | *.tmp 90 | *.tmp_proj 91 | *_wpftmp.csproj 92 | *.log 93 | *.tlog 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 298 | *.vbp 299 | 300 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 301 | *.dsw 302 | *.dsp 303 | 304 | # Visual Studio 6 technical files 305 | *.ncb 306 | *.aps 307 | 308 | # Visual Studio LightSwitch build output 309 | **/*.HTMLClient/GeneratedArtifacts 310 | **/*.DesktopClient/GeneratedArtifacts 311 | **/*.DesktopClient/ModelManifest.xml 312 | **/*.Server/GeneratedArtifacts 313 | **/*.Server/ModelManifest.xml 314 | _Pvt_Extensions 315 | 316 | # Paket dependency manager 317 | .paket/paket.exe 318 | paket-files/ 319 | 320 | # FAKE - F# Make 321 | .fake/ 322 | 323 | # CodeRush personal settings 324 | .cr/personal 325 | 326 | # Python Tools for Visual Studio (PTVS) 327 | __pycache__/ 328 | *.pyc 329 | 330 | # Cake - Uncomment if you are using it 331 | # tools/** 332 | # !tools/packages.config 333 | 334 | # Tabs Studio 335 | *.tss 336 | 337 | # Telerik's JustMock configuration file 338 | *.jmconfig 339 | 340 | # BizTalk build output 341 | *.btp.cs 342 | *.btm.cs 343 | *.odx.cs 344 | *.xsd.cs 345 | 346 | # OpenCover UI analysis results 347 | OpenCover/ 348 | 349 | # Azure Stream Analytics local run output 350 | ASALocalRun/ 351 | 352 | # MSBuild Binary and Structured Log 353 | *.binlog 354 | 355 | # NVidia Nsight GPU debugger configuration file 356 | *.nvuser 357 | 358 | # MFractors (Xamarin productivity tool) working folder 359 | .mfractor/ 360 | 361 | # Local History for Visual Studio 362 | .localhistory/ 363 | 364 | # Visual Studio History (VSHistory) files 365 | .vshistory/ 366 | 367 | # BeatPulse healthcheck temp database 368 | healthchecksdb 369 | 370 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 371 | MigrationBackup/ 372 | 373 | # Ionide (cross platform F# VS Code tools) working folder 374 | .ionide/ 375 | 376 | # Fody - auto-generated XML schema 377 | FodyWeavers.xsd 378 | 379 | # VS Code files for those working on multiple tools 380 | .vscode/* 381 | !.vscode/settings.json 382 | !.vscode/tasks.json 383 | !.vscode/launch.json 384 | !.vscode/extensions.json 385 | *.code-workspace 386 | 387 | # Local History for Visual Studio Code 388 | .history/ 389 | 390 | # Windows Installer files from build outputs 391 | *.cab 392 | *.msi 393 | *.msix 394 | *.msm 395 | *.msp 396 | 397 | # JetBrains Rider 398 | *.sln.iml 399 | 400 | # SQLite temporary files 401 | *.db-shm 402 | *.db-wal -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/ActionConstraints/RequestHeaderMatchesMediaTypeAttribute.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.ActionConstraints; 2 | using Microsoft.AspNetCore.Mvc.Formatters; 3 | using Microsoft.Net.Http.Headers; 4 | 5 | namespace CourseLibrary.API.ActionConstraints; 6 | 7 | [AttributeUsage(AttributeTargets.All, Inherited = true, AllowMultiple = true)] 8 | public class RequestHeaderMatchesMediaTypeAttribute : Attribute, IActionConstraint 9 | { 10 | private readonly string _requestHeaderToMatch; 11 | private readonly MediaTypeCollection _mediaTypes = new(); 12 | 13 | public RequestHeaderMatchesMediaTypeAttribute(string requestHeaderToMatch, 14 | string mediaType, params string[] otherMediaTypes) 15 | { 16 | _requestHeaderToMatch = requestHeaderToMatch 17 | ?? throw new ArgumentNullException(nameof(requestHeaderToMatch)); 18 | 19 | // check if the inputted media types are valid media types 20 | // and add them to the _mediaTypes collection 21 | 22 | if (MediaTypeHeaderValue.TryParse(mediaType, 23 | out var parsedMediaType)) 24 | { 25 | _mediaTypes.Add(parsedMediaType); 26 | } 27 | else 28 | { 29 | throw new ArgumentException(nameof(mediaType)); 30 | } 31 | 32 | foreach (var otherMediaType in otherMediaTypes) 33 | { 34 | if (MediaTypeHeaderValue.TryParse(otherMediaType, 35 | out var parsedOtherMediaType)) 36 | { 37 | _mediaTypes.Add(parsedOtherMediaType); 38 | } 39 | else 40 | { 41 | throw new ArgumentException(nameof(otherMediaTypes)); 42 | } 43 | } 44 | 45 | } 46 | 47 | public int Order { get; } 48 | 49 | public bool Accept(ActionConstraintContext context) 50 | { 51 | var requestHeaders = context.RouteContext.HttpContext.Request.Headers; 52 | if (!requestHeaders.ContainsKey(_requestHeaderToMatch)) 53 | { 54 | return false; 55 | } 56 | 57 | var parsedRequestMediaType = new MediaType(requestHeaders[_requestHeaderToMatch]); 58 | 59 | // if one of the media types matches, return true 60 | foreach (var mediaType in _mediaTypes) 61 | { 62 | var parsedMediaType = new MediaType(mediaType); 63 | if (parsedRequestMediaType.Equals(parsedMediaType)) 64 | { 65 | return true; 66 | } 67 | } 68 | return false; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Controllers/AuthorCollectionsController.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using CourseLibrary.API.Entities; 3 | using CourseLibrary.API.Helpers; 4 | using CourseLibrary.API.Models; 5 | using CourseLibrary.API.Services; 6 | using Microsoft.AspNetCore.Mvc; 7 | 8 | namespace CourseLibrary.API.Controllers; 9 | 10 | [ApiController] 11 | [Route("api/authorcollections")] 12 | public class AuthorCollectionsController : ControllerBase 13 | { 14 | private readonly ICourseLibraryRepository _courseLibraryRepository; 15 | private readonly IMapper _mapper; 16 | 17 | public AuthorCollectionsController( 18 | ICourseLibraryRepository courseLibraryRepository, 19 | IMapper mapper) 20 | { 21 | _courseLibraryRepository = courseLibraryRepository ?? 22 | throw new ArgumentNullException(nameof(courseLibraryRepository)); 23 | _mapper = mapper ?? 24 | throw new ArgumentNullException(nameof(mapper)); 25 | } 26 | 27 | // 1,2,3 28 | // key1=value1,key2=value2 29 | 30 | [HttpGet("({authorIds})", Name = "GetAuthorCollection")] 31 | public async Task>> 32 | GetAuthorCollection( 33 | [ModelBinder(BinderType = typeof(ArrayModelBinder))] 34 | [FromRoute] IEnumerable authorIds) 35 | { 36 | var authorEntities = await _courseLibraryRepository 37 | .GetAuthorsAsync(authorIds); 38 | 39 | // do we have all requested authors? 40 | if (authorIds.Count() != authorEntities.Count()) 41 | { 42 | return NotFound(); 43 | } 44 | 45 | // map 46 | var authorsToReturn = _mapper.Map>(authorEntities); 47 | return Ok(authorsToReturn); 48 | } 49 | 50 | 51 | [HttpPost] 52 | public async Task>> CreateAuthorCollection( 53 | IEnumerable authorCollection) 54 | { 55 | var authorEntities = _mapper.Map>(authorCollection); 56 | foreach (var author in authorEntities) 57 | { 58 | _courseLibraryRepository.AddAuthor(author); 59 | } 60 | await _courseLibraryRepository.SaveAsync(); 61 | 62 | var authorCollectionToReturn = _mapper.Map>( 63 | authorEntities); 64 | var authorIdsAsString = string.Join(",", 65 | authorCollectionToReturn.Select(a => a.Id)); 66 | 67 | return CreatedAtRoute("GetAuthorCollection", 68 | new { authorIds = authorIdsAsString }, 69 | authorCollectionToReturn); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Controllers/AuthorsController.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using CourseLibrary.API.ActionConstraints; 3 | using CourseLibrary.API.Helpers; 4 | using CourseLibrary.API.Models; 5 | using CourseLibrary.API.ResourceParameters; 6 | using CourseLibrary.API.Services; 7 | using Microsoft.AspNetCore.Mvc; 8 | using Microsoft.AspNetCore.Mvc.Infrastructure; 9 | using Microsoft.Net.Http.Headers; 10 | using System.Dynamic; 11 | using System.Text.Json; 12 | 13 | namespace CourseLibrary.API.Controllers; 14 | 15 | [ApiController] 16 | [Route("api/authors")] 17 | public class AuthorsController : ControllerBase 18 | { 19 | private readonly ICourseLibraryRepository _courseLibraryRepository; 20 | private readonly IMapper _mapper; 21 | private readonly IPropertyMappingService _propertyMappingService; 22 | private readonly IPropertyCheckerService _propertyCheckerService; 23 | private readonly ProblemDetailsFactory _problemDetailsFactory; 24 | 25 | public AuthorsController(ICourseLibraryRepository courseLibraryRepository, 26 | IMapper mapper, IPropertyMappingService propertyMappingService, 27 | IPropertyCheckerService propertyCheckerService, 28 | ProblemDetailsFactory problemDetailsFactory) 29 | { 30 | _courseLibraryRepository = courseLibraryRepository ?? 31 | throw new ArgumentNullException(nameof(courseLibraryRepository)); 32 | _mapper = mapper ?? 33 | throw new ArgumentNullException(nameof(mapper)); 34 | _propertyMappingService = propertyMappingService ?? 35 | throw new ArgumentNullException(nameof(propertyMappingService)); 36 | _propertyCheckerService = propertyCheckerService ?? 37 | throw new ArgumentNullException(nameof(propertyCheckerService)); 38 | _problemDetailsFactory = problemDetailsFactory ?? 39 | throw new ArgumentNullException(nameof(problemDetailsFactory)); 40 | } 41 | 42 | 43 | 44 | [HttpGet(Name = "GetAuthors")] 45 | [HttpHead] 46 | public async Task GetAuthors( 47 | [FromQuery] AuthorsResourceParameters authorsResourceParameters) 48 | { 49 | // throw new Exception("Test exception"); 50 | 51 | if (!_propertyMappingService 52 | .ValidMappingExistsFor( 53 | authorsResourceParameters.OrderBy)) 54 | { 55 | return BadRequest(); 56 | } 57 | 58 | if (!_propertyCheckerService.TypeHasProperties 59 | (authorsResourceParameters.Fields)) 60 | { 61 | return BadRequest( 62 | _problemDetailsFactory.CreateProblemDetails(HttpContext, 63 | statusCode: 400, 64 | detail: $"Not all requested data shaping fields exist on " + 65 | $"the resource: {authorsResourceParameters.Fields}")); 66 | } 67 | 68 | 69 | // get authors from repo 70 | var authorsFromRepo = await _courseLibraryRepository 71 | .GetAuthorsAsync(authorsResourceParameters); 72 | 73 | var paginationMetadata = new 74 | { 75 | totalCount = authorsFromRepo.TotalCount, 76 | pageSize = authorsFromRepo.PageSize, 77 | currentPage = authorsFromRepo.CurrentPage, 78 | totalPages = authorsFromRepo.TotalPages 79 | }; 80 | 81 | Response.Headers.Add("X-Pagination", 82 | JsonSerializer.Serialize(paginationMetadata)); 83 | 84 | // create links 85 | var links = CreateLinksForAuthors(authorsResourceParameters, 86 | authorsFromRepo.HasNext, 87 | authorsFromRepo.HasPrevious); 88 | 89 | var shapedAuthors = _mapper.Map>(authorsFromRepo) 90 | .ShapeData(authorsResourceParameters.Fields); 91 | 92 | var shapedAuthorsWithLinks = shapedAuthors.Select(author => 93 | { 94 | var authorAsDictionary = author as IDictionary; 95 | var authorLinks = CreateLinksForAuthor( 96 | (Guid)authorAsDictionary["Id"], 97 | null); 98 | authorAsDictionary.Add("links", authorLinks); 99 | return authorAsDictionary; 100 | }); 101 | 102 | var linkedCollectionResource = new 103 | { 104 | value = shapedAuthorsWithLinks, 105 | links = links 106 | }; 107 | 108 | // return them 109 | return Ok(linkedCollectionResource); 110 | } 111 | 112 | private IEnumerable CreateLinksForAuthors( 113 | AuthorsResourceParameters authorsResourceParameters, 114 | bool hasNext, bool hasPrevious) 115 | { 116 | var links = new List(); 117 | 118 | // self 119 | links.Add( 120 | new(CreateAuthorsResourceUri(authorsResourceParameters, 121 | ResourceUriType.Current), 122 | "self", 123 | "GET")); 124 | 125 | if (hasNext) 126 | { 127 | links.Add( 128 | new(CreateAuthorsResourceUri(authorsResourceParameters, 129 | ResourceUriType.NextPage), 130 | "nextPage", 131 | "GET")); 132 | } 133 | 134 | if (hasPrevious) 135 | { 136 | links.Add( 137 | new(CreateAuthorsResourceUri(authorsResourceParameters, 138 | ResourceUriType.PreviousPage), 139 | "previousPage", 140 | "GET")); 141 | } 142 | 143 | return links; 144 | } 145 | 146 | private string? CreateAuthorsResourceUri( 147 | AuthorsResourceParameters authorsResourceParameters, 148 | ResourceUriType type) 149 | { 150 | switch (type) 151 | { 152 | case ResourceUriType.PreviousPage: 153 | return Url.Link("GetAuthors", 154 | new 155 | { 156 | fields = authorsResourceParameters.Fields, 157 | orderBy = authorsResourceParameters.OrderBy, 158 | pageNumber = authorsResourceParameters.PageNumber - 1, 159 | pageSize = authorsResourceParameters.PageSize, 160 | mainCategory = authorsResourceParameters.MainCategory, 161 | searchQuery = authorsResourceParameters.SearchQuery 162 | }); 163 | case ResourceUriType.NextPage: 164 | return Url.Link("GetAuthors", 165 | new 166 | { 167 | fields = authorsResourceParameters.Fields, 168 | orderBy = authorsResourceParameters.OrderBy, 169 | pageNumber = authorsResourceParameters.PageNumber + 1, 170 | pageSize = authorsResourceParameters.PageSize, 171 | mainCategory = authorsResourceParameters.MainCategory, 172 | searchQuery = authorsResourceParameters.SearchQuery 173 | }); 174 | case ResourceUriType.Current: 175 | default: 176 | return Url.Link("GetAuthors", 177 | new 178 | { 179 | fields = authorsResourceParameters.Fields, 180 | orderBy = authorsResourceParameters.OrderBy, 181 | pageNumber = authorsResourceParameters.PageNumber, 182 | pageSize = authorsResourceParameters.PageSize, 183 | mainCategory = authorsResourceParameters.MainCategory, 184 | searchQuery = authorsResourceParameters.SearchQuery 185 | }); 186 | } 187 | } 188 | 189 | [RequestHeaderMatchesMediaType("Accept", 190 | "application/json", 191 | "application/vnd.marvin.author.friendly+json")] 192 | [Produces("application/json", 193 | "application/vnd.marvin.author.friendly+json")] 194 | [HttpGet("{authorId}", Name = "GetAuthor")] 195 | public async Task GetAuthorWithoutLinks(Guid authorId, 196 | string? fields) 197 | { 198 | if (!_propertyCheckerService.TypeHasProperties 199 | (fields)) 200 | { 201 | return BadRequest( 202 | _problemDetailsFactory.CreateProblemDetails(HttpContext, 203 | statusCode: 400, 204 | detail: $"Not all requested data shaping fields exist on " + 205 | $"the resource: {fields}")); 206 | } 207 | 208 | // get author from repo 209 | var authorFromRepo = await _courseLibraryRepository 210 | .GetAuthorAsync(authorId); 211 | 212 | if (authorFromRepo == null) 213 | { 214 | return NotFound(); 215 | } 216 | 217 | // friendly author 218 | var friendlyResourceToReturn = _mapper.Map(authorFromRepo) 219 | .ShapeData(fields); 220 | 221 | return Ok(friendlyResourceToReturn); 222 | } 223 | 224 | [RequestHeaderMatchesMediaType("Accept", 225 | "application/vnd.marvin.hateoas+json", 226 | "application/vnd.marvin.author.friendly.hateoas+json")] 227 | [Produces("application/vnd.marvin.hateoas+json", 228 | "application/vnd.marvin.author.friendly.hateoas+json")] 229 | [HttpGet("{authorId}")] 230 | public async Task GetAuthorWithLinks(Guid authorId, 231 | string? fields) 232 | { 233 | if (!_propertyCheckerService.TypeHasProperties 234 | (fields)) 235 | { 236 | return BadRequest( 237 | _problemDetailsFactory.CreateProblemDetails(HttpContext, 238 | statusCode: 400, 239 | detail: $"Not all requested data shaping fields exist on " + 240 | $"the resource: {fields}")); 241 | } 242 | 243 | // get author from repo 244 | var authorFromRepo = await _courseLibraryRepository 245 | .GetAuthorAsync(authorId); 246 | 247 | if (authorFromRepo == null) 248 | { 249 | return NotFound(); 250 | } 251 | IEnumerable links = CreateLinksForAuthor(authorId, fields); 252 | 253 | // friendly author 254 | var friendlyResourceToReturn = _mapper.Map(authorFromRepo) 255 | .ShapeData(fields) as IDictionary; 256 | 257 | friendlyResourceToReturn.Add("links", links); 258 | 259 | return Ok(friendlyResourceToReturn); 260 | } 261 | 262 | [RequestHeaderMatchesMediaType("Accept", 263 | "application/vnd.marvin.author.full+json")] 264 | [Produces("application/vnd.marvin.author.full+json")] 265 | [HttpGet("{authorId}", Name = "GetAuthor")] 266 | public async Task GetFullAuthorWithoutLinks(Guid authorId, 267 | string? fields) 268 | { 269 | if (!_propertyCheckerService.TypeHasProperties 270 | (fields)) 271 | { 272 | return BadRequest( 273 | _problemDetailsFactory.CreateProblemDetails(HttpContext, 274 | statusCode: 400, 275 | detail: $"Not all requested data shaping fields exist on " + 276 | $"the resource: {fields}")); 277 | } 278 | 279 | // get author from repo 280 | var authorFromRepo = await _courseLibraryRepository 281 | .GetAuthorAsync(authorId); 282 | 283 | if (authorFromRepo == null) 284 | { 285 | return NotFound(); 286 | } 287 | 288 | var fullResourceToReturn = _mapper.Map(authorFromRepo) 289 | .ShapeData(fields); 290 | 291 | return Ok(fullResourceToReturn); 292 | } 293 | 294 | [RequestHeaderMatchesMediaType("Accept", 295 | "application/vnd.marvin.author.full.hateoas+json")] 296 | [Produces("application/vnd.marvin.author.full.hateoas+json")] 297 | [HttpGet("{authorId}")] 298 | public async Task GetFullAuthorWithLinks(Guid authorId, 299 | string? fields) 300 | { 301 | if (!_propertyCheckerService.TypeHasProperties 302 | (fields)) 303 | { 304 | return BadRequest( 305 | _problemDetailsFactory.CreateProblemDetails(HttpContext, 306 | statusCode: 400, 307 | detail: $"Not all requested data shaping fields exist on " + 308 | $"the resource: {fields}")); 309 | } 310 | 311 | // get author from repo 312 | var authorFromRepo = await _courseLibraryRepository 313 | .GetAuthorAsync(authorId); 314 | 315 | if (authorFromRepo == null) 316 | { 317 | return NotFound(); 318 | } 319 | 320 | IEnumerable links = CreateLinksForAuthor(authorId, fields); 321 | 322 | var fullResourceToReturn = _mapper.Map(authorFromRepo) 323 | .ShapeData(fields) as IDictionary; 324 | 325 | fullResourceToReturn.Add("links", links); 326 | return Ok(fullResourceToReturn); 327 | } 328 | 329 | //[Produces("application/json", 330 | // "application/vnd.marvin.hateoas+json", 331 | // "application/vnd.marvin.author.full+json", 332 | // "application/vnd.marvin.author.full.hateoas+json", 333 | // "application/vnd.marvin.author.friendly+json", 334 | // "application/vnd.marvin.author.friendly.hateoas+json")] 335 | //[HttpGet("{authorId}", Name = "GetAuthor")] 336 | //public async Task GetAuthor(Guid authorId, 337 | // string? fields, 338 | // [FromHeader(Name = "Accept")] string? mediaType) 339 | //{ 340 | // // check if the inputted media type is a valid media type 341 | // if (!MediaTypeHeaderValue.TryParse(mediaType, 342 | // out var parsedMediaType)) 343 | // { 344 | // return BadRequest( 345 | // _problemDetailsFactory.CreateProblemDetails(HttpContext, 346 | // statusCode: 400, 347 | // detail: $"Accept header media type value is not a valid media type.")); 348 | // } 349 | 350 | // if (!_propertyCheckerService.TypeHasProperties 351 | // (fields)) 352 | // { 353 | // return BadRequest( 354 | // _problemDetailsFactory.CreateProblemDetails(HttpContext, 355 | // statusCode: 400, 356 | // detail: $"Not all requested data shaping fields exist on " + 357 | // $"the resource: {fields}")); 358 | // } 359 | 360 | // // get author from repo 361 | // var authorFromRepo = await _courseLibraryRepository 362 | // .GetAuthorAsync(authorId); 363 | 364 | // if (authorFromRepo == null) 365 | // { 366 | // return NotFound(); 367 | // } 368 | 369 | // var includeLinks = parsedMediaType.SubTypeWithoutSuffix 370 | // .EndsWith("hateoas", StringComparison.InvariantCultureIgnoreCase); 371 | // IEnumerable links = new List(); 372 | 373 | // if (includeLinks) 374 | // { 375 | // links = CreateLinksForAuthor(authorId, fields); 376 | // } 377 | 378 | // var primaryMediaType = includeLinks ? 379 | // parsedMediaType.SubTypeWithoutSuffix.Substring( 380 | // 0, parsedMediaType.SubTypeWithoutSuffix.Length - 8) 381 | // : parsedMediaType.SubTypeWithoutSuffix; 382 | 383 | // // full author 384 | // if (primaryMediaType == "vnd.marvin.author.full") 385 | // { 386 | // var fullResourceToReturn = _mapper.Map(authorFromRepo) 387 | // .ShapeData(fields) as IDictionary; 388 | 389 | // if (includeLinks) 390 | // { 391 | // fullResourceToReturn.Add("links", links); 392 | // } 393 | 394 | // return Ok(fullResourceToReturn); 395 | // } 396 | 397 | // // friendly author 398 | // var friendlyResourceToReturn = _mapper.Map(authorFromRepo) 399 | // .ShapeData(fields) as IDictionary; 400 | 401 | // if (includeLinks) 402 | // { 403 | // friendlyResourceToReturn.Add("links", links); 404 | // } 405 | 406 | // return Ok(friendlyResourceToReturn); 407 | //} 408 | 409 | private IEnumerable CreateLinksForAuthor(Guid authorId, 410 | string? fields) 411 | { 412 | var links = new List(); 413 | 414 | if (string.IsNullOrWhiteSpace(fields)) 415 | { 416 | links.Add( 417 | new(Url.Link("GetAuthor", new { authorId }), 418 | "self", 419 | "GET")); 420 | } 421 | else 422 | { 423 | links.Add( 424 | new(Url.Link("GetAuthor", new { authorId, fields }), 425 | "self", 426 | "GET")); 427 | } 428 | 429 | links.Add( 430 | new(Url.Link("CreateCourseForAuthor", new { authorId }), 431 | "create_course_for_author", 432 | "POST")); 433 | links.Add( 434 | new(Url.Link("GetCoursesForAuthor", new { authorId }), 435 | "courses", 436 | "GET")); 437 | 438 | return links; 439 | } 440 | 441 | [HttpPost(Name = "CreateAuthorWithDateOfDeath")] 442 | [RequestHeaderMatchesMediaType("Content-Type", 443 | "application/vnd.marvin.authorforcreationwithdateofdeath+json")] 444 | [Consumes("application/vnd.marvin.authorforcreationwithdateofdeath+json")] 445 | public async Task> CreateAuthorWithDateOfDeath( 446 | AuthorForCreationWithDateOfDeathDto author) 447 | { 448 | var authorEntity = _mapper.Map(author); 449 | 450 | _courseLibraryRepository.AddAuthor(authorEntity); 451 | await _courseLibraryRepository.SaveAsync(); 452 | 453 | var authorToReturn = _mapper.Map(authorEntity); 454 | 455 | // create links 456 | var links = CreateLinksForAuthor(authorToReturn.Id, null); 457 | 458 | // add 459 | var linkedResourceToReturn = authorToReturn.ShapeData(null) 460 | as IDictionary; 461 | linkedResourceToReturn.Add("links", links); 462 | 463 | return CreatedAtRoute("GetAuthor", 464 | new { authorId = linkedResourceToReturn["Id"] }, 465 | linkedResourceToReturn); 466 | } 467 | 468 | [HttpPost(Name = "CreateAuthor")] 469 | [RequestHeaderMatchesMediaType("Content-Type", 470 | "application/json", 471 | "application/vnd.marvin.authorforcreation+json")] 472 | [Consumes("application/json", 473 | "application/vnd.marvin.authorforcreation+json")] 474 | public async Task> CreateAuthor( 475 | AuthorForCreationDto author) 476 | { 477 | var authorEntity = _mapper.Map(author); 478 | 479 | _courseLibraryRepository.AddAuthor(authorEntity); 480 | await _courseLibraryRepository.SaveAsync(); 481 | 482 | var authorToReturn = _mapper.Map(authorEntity); 483 | 484 | // create links 485 | var links = CreateLinksForAuthor(authorToReturn.Id, null); 486 | 487 | // add 488 | var linkedResourceToReturn = authorToReturn.ShapeData(null) 489 | as IDictionary; 490 | linkedResourceToReturn.Add("links", links); 491 | 492 | return CreatedAtRoute("GetAuthor", 493 | new { authorId = linkedResourceToReturn["Id"] }, 494 | linkedResourceToReturn); 495 | } 496 | 497 | [HttpOptions()] 498 | public IActionResult GetAuthorsOptions() 499 | { 500 | Response.Headers.Add("Allow", "GET,HEAD,POST,OPTIONS"); 501 | return Ok(); 502 | } 503 | } 504 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Controllers/CoursesController.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using CourseLibrary.API.Models; 3 | using CourseLibrary.API.Services; 4 | using Marvin.Cache.Headers; 5 | using Microsoft.AspNetCore.JsonPatch; 6 | using Microsoft.AspNetCore.Mvc; 7 | using Microsoft.AspNetCore.Mvc.Infrastructure; 8 | using Microsoft.AspNetCore.Mvc.ModelBinding; 9 | using Microsoft.Extensions.Options; 10 | 11 | namespace CourseLibrary.API.Controllers; 12 | 13 | [ApiController] 14 | [Route("api/authors/{authorId}/courses")] 15 | //[ResponseCache(CacheProfileName = "240SecondsCacheProfile")] 16 | [HttpCacheExpiration(CacheLocation = CacheLocation.Public)] 17 | [HttpCacheValidation(MustRevalidate = true)] 18 | public class CoursesController : ControllerBase 19 | { 20 | private readonly ICourseLibraryRepository _courseLibraryRepository; 21 | private readonly IMapper _mapper; 22 | 23 | public CoursesController(ICourseLibraryRepository courseLibraryRepository, 24 | IMapper mapper) 25 | { 26 | _courseLibraryRepository = courseLibraryRepository ?? 27 | throw new ArgumentNullException(nameof(courseLibraryRepository)); 28 | _mapper = mapper ?? 29 | throw new ArgumentNullException(nameof(mapper)); 30 | } 31 | 32 | [HttpGet(Name = "GetCoursesForAuthor")] 33 | public async Task>> GetCoursesForAuthor( 34 | Guid authorId) 35 | { 36 | if (!await _courseLibraryRepository.AuthorExistsAsync(authorId)) 37 | { 38 | return NotFound(); 39 | } 40 | 41 | var coursesForAuthorFromRepo = await _courseLibraryRepository 42 | .GetCoursesAsync(authorId); 43 | return Ok(_mapper.Map>(coursesForAuthorFromRepo)); 44 | } 45 | 46 | //[ResponseCache(Duration = 120)] 47 | [HttpGet("{courseId}", Name = "GetCourseForAuthor")] 48 | [HttpCacheExpiration(CacheLocation = CacheLocation.Public, MaxAge = 1000)] 49 | [HttpCacheValidation(MustRevalidate = false)] 50 | public async Task> GetCourseForAuthor( 51 | Guid authorId, Guid courseId) 52 | { 53 | if (!await _courseLibraryRepository.AuthorExistsAsync(authorId)) 54 | { 55 | return NotFound(); 56 | } 57 | 58 | var courseForAuthorFromRepo = await _courseLibraryRepository 59 | .GetCourseAsync(authorId, courseId); 60 | 61 | if (courseForAuthorFromRepo == null) 62 | { 63 | return NotFound(); 64 | } 65 | return Ok(_mapper.Map(courseForAuthorFromRepo)); 66 | } 67 | 68 | 69 | [HttpPost(Name = "CreateCourseForAuthor")] 70 | public async Task> CreateCourseForAuthor( 71 | Guid authorId, CourseForCreationDto course) 72 | { 73 | if (!await _courseLibraryRepository.AuthorExistsAsync(authorId)) 74 | { 75 | return NotFound(); 76 | } 77 | 78 | var courseEntity = _mapper.Map(course); 79 | _courseLibraryRepository.AddCourse(authorId, courseEntity); 80 | await _courseLibraryRepository.SaveAsync(); 81 | 82 | var courseToReturn = _mapper.Map(courseEntity); 83 | 84 | return CreatedAtRoute("GetCourseForAuthor", 85 | new { authorId, courseId = courseToReturn.Id }, 86 | courseToReturn); 87 | } 88 | 89 | 90 | [HttpPut("{courseId}")] 91 | public async Task UpdateCourseForAuthor(Guid authorId, 92 | Guid courseId, 93 | CourseForUpdateDto course) 94 | { 95 | if (!await _courseLibraryRepository.AuthorExistsAsync(authorId)) 96 | { 97 | return NotFound(); 98 | } 99 | 100 | var courseForAuthorFromRepo = await _courseLibraryRepository 101 | .GetCourseAsync(authorId, courseId); 102 | 103 | if (courseForAuthorFromRepo == null) 104 | { 105 | var courseToAdd = _mapper.Map(course); 106 | courseToAdd.Id = courseId; 107 | _courseLibraryRepository.AddCourse(authorId, courseToAdd); 108 | await _courseLibraryRepository.SaveAsync(); 109 | 110 | var courseToReturn = _mapper.Map(courseToAdd); 111 | return CreatedAtRoute("GetCourseForAuthor", 112 | new { authorId, courseId = courseToReturn.Id }, 113 | courseToReturn); 114 | } 115 | 116 | _mapper.Map(course, courseForAuthorFromRepo); 117 | 118 | _courseLibraryRepository.UpdateCourse(courseForAuthorFromRepo); 119 | 120 | await _courseLibraryRepository.SaveAsync(); 121 | return NoContent(); 122 | } 123 | 124 | [HttpPatch("{courseId}")] 125 | public async Task PartiallyUpdateCourseForAuthor( 126 | Guid authorId, 127 | Guid courseId, 128 | JsonPatchDocument patchDocument) 129 | { 130 | if (!await _courseLibraryRepository.AuthorExistsAsync(authorId)) 131 | { 132 | return NotFound(); 133 | } 134 | 135 | var courseForAuthorFromRepo = await _courseLibraryRepository 136 | .GetCourseAsync(authorId, courseId); 137 | 138 | if (courseForAuthorFromRepo == null) 139 | { 140 | var courseDto = new CourseForUpdateDto(); 141 | patchDocument.ApplyTo(courseDto, ModelState); 142 | 143 | if (!TryValidateModel(courseDto)) 144 | { 145 | return ValidationProblem(ModelState); 146 | } 147 | 148 | var courseToAdd = _mapper.Map(courseDto); 149 | courseToAdd.Id = courseId; 150 | 151 | _courseLibraryRepository.AddCourse(authorId, courseToAdd); 152 | await _courseLibraryRepository.SaveAsync(); 153 | 154 | var courseToReturn = _mapper.Map(courseToAdd); 155 | return CreatedAtRoute("GetCourseForAuthor", 156 | new { authorId, courseId = courseToReturn.Id }, 157 | courseToReturn); 158 | } 159 | 160 | var courseToPatch = _mapper.Map( 161 | courseForAuthorFromRepo); 162 | patchDocument.ApplyTo(courseToPatch, ModelState); 163 | 164 | if (!TryValidateModel(courseToPatch)) 165 | { 166 | return ValidationProblem(ModelState); 167 | } 168 | 169 | _mapper.Map(courseToPatch, courseForAuthorFromRepo); 170 | 171 | _courseLibraryRepository.UpdateCourse(courseForAuthorFromRepo); 172 | 173 | await _courseLibraryRepository.SaveAsync(); 174 | 175 | return NoContent(); 176 | } 177 | 178 | 179 | [HttpDelete("{courseId}")] 180 | public async Task DeleteCourseForAuthor( 181 | Guid authorId, Guid courseId) 182 | { 183 | if (!await _courseLibraryRepository.AuthorExistsAsync(authorId)) 184 | { 185 | return NotFound(); 186 | } 187 | 188 | var courseForAuthorFromRepo = await _courseLibraryRepository 189 | .GetCourseAsync(authorId, courseId); 190 | 191 | if (courseForAuthorFromRepo == null) 192 | { 193 | return NotFound(); 194 | } 195 | 196 | _courseLibraryRepository.DeleteCourse(courseForAuthorFromRepo); 197 | await _courseLibraryRepository.SaveAsync(); 198 | 199 | return NoContent(); 200 | } 201 | 202 | public override ActionResult ValidationProblem( 203 | [ActionResultObjectValue] ModelStateDictionary modelStateDictionary) 204 | { 205 | var options = HttpContext.RequestServices 206 | .GetRequiredService>(); 207 | 208 | return (ActionResult)options.Value 209 | .InvalidModelStateResponseFactory(ControllerContext); 210 | } 211 | 212 | } -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Controllers/RootController.cs: -------------------------------------------------------------------------------- 1 | using CourseLibrary.API.Models; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace CourseLibrary.API.Controllers; 5 | 6 | [Route("api")] 7 | [ApiController] 8 | public class RootController : ControllerBase 9 | { 10 | [HttpGet(Name = "GetRoot")] 11 | public IActionResult GetRoot() 12 | { 13 | // create links for root 14 | var links = new List(); 15 | 16 | links.Add( 17 | new(Url.Link("GetRoot", new { }), 18 | "self", 19 | "GET")); 20 | 21 | links.Add( 22 | new(Url.Link("GetAuthors", new { }), 23 | "authors", 24 | "GET")); 25 | 26 | links.Add( 27 | new(Url.Link("CreateAuthor", new { }), 28 | "create_author", 29 | "POST")); 30 | 31 | return Ok(links); 32 | } 33 | } -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/CourseLibrary.API.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | true 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | all 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/DbContexts/CourseLibraryContext.cs: -------------------------------------------------------------------------------- 1 | using CourseLibrary.API.Entities; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 4 | 5 | namespace CourseLibrary.API.DbContexts; 6 | 7 | public class CourseLibraryContext : DbContext 8 | { 9 | public CourseLibraryContext(DbContextOptions options) 10 | : base(options) 11 | { 12 | } 13 | 14 | // base DbContext constructor ensures that Books and Authors are not null after 15 | // having been constructed. Compiler warning ("uninitialized non-nullable property") 16 | // can safely be ignored with the "null-forgiving operator" (= null!) 17 | 18 | public DbSet Authors { get; set; } = null!; 19 | public DbSet Courses { get; set; } = null!; 20 | 21 | protected override void OnModelCreating(ModelBuilder modelBuilder) 22 | { 23 | // seed the database with dummy data 24 | modelBuilder.Entity().HasData( 25 | new Author("Berry", "Griffin Beak Eldritch", "Ships") 26 | { 27 | Id = Guid.Parse("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 28 | DateOfBirth = new DateTime(1980, 7, 23) 29 | }, 30 | new Author("Nancy", "Swashbuckler Rye", "Rum") 31 | { 32 | Id = Guid.Parse("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 33 | DateOfBirth = new DateTime(1978, 5, 21) 34 | }, 35 | new Author("Eli", "Ivory Bones Sweet", "Singing") 36 | { 37 | Id = Guid.Parse("2902b665-1190-4c70-9915-b9c2d7680450"), 38 | DateOfBirth = new DateTime(1957, 12, 16) 39 | }, 40 | new Author("Arnold", "The Unseen Stafford", "Singing") 41 | { 42 | Id = Guid.Parse("102b566b-ba1f-404c-b2df-e2cde39ade09"), 43 | DateOfBirth = new DateTime(1957, 3, 6) 44 | }, 45 | new Author("Seabury", "Toxic Reyson", "Maps") 46 | { 47 | Id = Guid.Parse("5b3621c0-7b12-4e80-9c8b-3398cba7ee05"), 48 | DateOfBirth = new DateTime(1956, 11, 23) 49 | }, 50 | new Author("Rutherford", "Fearless Cloven", "General debauchery") 51 | { 52 | Id = Guid.Parse("2aadd2df-7caf-45ab-9355-7f6332985a87"), 53 | DateOfBirth = new DateTime(1981, 4, 5) 54 | }, 55 | new Author("Atherton", "Crow Ridley", "Rum") 56 | { 57 | Id = Guid.Parse("2ee49fe3-edf2-4f91-8409-3eb25ce6ca51"), 58 | DateOfBirth = new DateTime(1982, 10, 11) 59 | } 60 | ); 61 | 62 | modelBuilder.Entity().HasData( 63 | new Course("Commandeering a Ship Without Getting Caught") 64 | { 65 | Id = Guid.Parse("5b1c2b4d-48c7-402a-80c3-cc796ad49c6b"), 66 | AuthorId = Guid.Parse("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 67 | Description = "Commandeering a ship in rough waters isn't easy. Commandeering it without getting caught is even harder. In this course you'll learn how to sail away and avoid those pesky musketeers." 68 | }, 69 | new Course("Overthrowing Mutiny") 70 | { 71 | Id = Guid.Parse("d8663e5e-7494-4f81-8739-6e0de1bea7ee"), 72 | AuthorId = Guid.Parse("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 73 | Description = "In this course, the author provides tips to avoid, or, if needed, overthrow pirate mutiny." 74 | }, 75 | new Course("Avoiding Brawls While Drinking as Much Rum as You Desire") 76 | { 77 | Id = Guid.Parse("d173e20d-159e-4127-9ce9-b0ac2564ad97"), 78 | AuthorId = Guid.Parse("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 79 | Description = "Every good pirate loves rum, but it also has a tendency to get you into trouble. In this course you'll learn how to avoid that. This new exclusive edition includes an additional chapter on how to run fast without falling while drunk." 80 | }, 81 | new Course("Singalong Pirate Hits") 82 | { 83 | Id = Guid.Parse("40ff5488-fdab-45b5-bc3a-14302d59869a"), 84 | AuthorId = Guid.Parse("2902b665-1190-4c70-9915-b9c2d7680450"), 85 | Description = "In this course you'll learn how to sing all-time favourite pirate songs without sounding like you actually know the words or how to hold a note." 86 | } 87 | ); 88 | 89 | // fix to allow sorting on DateTimeOffset when using Sqlite, based on 90 | // https://blog.dangl.me/archive/handling-datetimeoffset-in-sqlite-with-entity-framework-core/ 91 | if (Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite") 92 | { 93 | // Sqlite does not have proper support for DateTimeOffset via Entity Framework Core, see the limitations 94 | // here: https://docs.microsoft.com/en-us/ef/core/providers/sqlite/limitations#query-limitations 95 | // To work around this, when the Sqlite database provider is used, all model properties of type DateTimeOffset 96 | // use the DateTimeOffsetToBinaryConverter 97 | // Based on: https://github.com/aspnet/EntityFrameworkCore/issues/10784#issuecomment-415769754 98 | foreach (var entityType in modelBuilder.Model.GetEntityTypes()) 99 | { 100 | var properties = entityType.ClrType.GetProperties() 101 | .Where(p => p.PropertyType == typeof(DateTimeOffset) 102 | || p.PropertyType == typeof(DateTimeOffset?)); 103 | foreach (var property in properties) 104 | { 105 | modelBuilder.Entity(entityType.Name) 106 | .Property(property.Name) 107 | .HasConversion(new DateTimeOffsetToBinaryConverter()); 108 | } 109 | } 110 | } 111 | 112 | base.OnModelCreating(modelBuilder); 113 | } 114 | } 115 | 116 | 117 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Entities/Author.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace CourseLibrary.API.Entities; 4 | 5 | public class Author 6 | { 7 | [Key] 8 | public Guid Id { get; set; } 9 | 10 | [Required] 11 | [MaxLength(50)] 12 | public string FirstName { get; set; } 13 | 14 | [Required] 15 | [MaxLength(50)] 16 | public string LastName { get; set; } 17 | 18 | [Required] 19 | public DateTimeOffset DateOfBirth { get; set; } 20 | 21 | public DateTimeOffset? DateOfDeath { get; set; } 22 | 23 | [Required] 24 | [MaxLength(50)] 25 | public string MainCategory { get; set; } 26 | 27 | public ICollection Courses { get; set; } 28 | = new List(); 29 | 30 | public Author(string firstName, string lastName, string mainCategory) 31 | { 32 | FirstName = firstName; 33 | LastName = lastName; 34 | MainCategory = mainCategory; 35 | } 36 | } 37 | 38 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Entities/Course.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | 4 | namespace CourseLibrary.API.Entities; 5 | 6 | public class Course 7 | { 8 | [Key] 9 | public Guid Id { get; set; } 10 | 11 | [Required] 12 | [MaxLength(100)] 13 | public string Title { get; set; } 14 | 15 | [MaxLength(1500)] 16 | public string? Description { get; set; } 17 | 18 | [ForeignKey("AuthorId")] 19 | public Author Author { get; set; } = null!; 20 | 21 | public Guid AuthorId { get; set; } 22 | 23 | public Course(string title) 24 | { 25 | Title = title; 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Helpers/ArrayModelBinder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.ModelBinding; 2 | using System.ComponentModel; 3 | using System.Reflection; 4 | 5 | namespace CourseLibrary.API.Helpers; 6 | 7 | public class ArrayModelBinder : IModelBinder 8 | { 9 | public Task BindModelAsync(ModelBindingContext bindingContext) 10 | { 11 | // Our binder works only on enumerable types 12 | if (!bindingContext.ModelMetadata.IsEnumerableType) 13 | { 14 | bindingContext.Result = ModelBindingResult.Failed(); 15 | return Task.CompletedTask; 16 | } 17 | 18 | // Get the inputted value through the value provider 19 | var value = bindingContext.ValueProvider 20 | .GetValue(bindingContext.ModelName).ToString(); 21 | 22 | // If that value is null or whitespace, we return null 23 | if (string.IsNullOrWhiteSpace(value)) 24 | { 25 | bindingContext.Result = ModelBindingResult.Success(null); 26 | return Task.CompletedTask; 27 | } 28 | 29 | // The value isn't null or whitespace, 30 | // and the type of the model is enumerable. 31 | // Get the enumerable's type, and a converter 32 | var elementType = bindingContext.ModelType.GetTypeInfo() 33 | .GenericTypeArguments[0]; 34 | var converter = TypeDescriptor.GetConverter(elementType); 35 | 36 | // Convert each item in the value list to the enumerable type 37 | var values = value.Split(new[] { "," }, 38 | StringSplitOptions.RemoveEmptyEntries) 39 | .Select(x => converter.ConvertFromString(x.Trim())) 40 | .ToArray(); 41 | 42 | // Create an array of that type, and set it as the Model value 43 | var typedValues = Array.CreateInstance(elementType, values.Length); 44 | values.CopyTo(typedValues, 0); 45 | bindingContext.Model = typedValues; 46 | 47 | // return a successful result, passing in the Model 48 | bindingContext.Result = ModelBindingResult.Success(bindingContext.Model); 49 | return Task.CompletedTask; 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Helpers/DateTimeOffsetExtensions.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CourseLibrary.API.Helpers; 3 | public static class DateTimeOffsetExtensions 4 | { 5 | public static int GetCurrentAge(this DateTimeOffset dateTimeOffset, 6 | DateTimeOffset? dateOfDeath) 7 | { 8 | var dateToCalculateTo = DateTime.UtcNow; 9 | 10 | if (dateOfDeath != null) 11 | { 12 | dateToCalculateTo = dateOfDeath.Value.UtcDateTime; 13 | } 14 | 15 | var age = dateToCalculateTo.Year - dateTimeOffset.Year; 16 | 17 | if (dateToCalculateTo < dateTimeOffset.AddYears(age)) 18 | { 19 | age--; 20 | } 21 | 22 | return age; 23 | } 24 | 25 | } 26 | 27 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Helpers/IEnumerableExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Dynamic; 2 | using System.Reflection; 3 | 4 | namespace CourseLibrary.API.Helpers; 5 | 6 | public static class IEnumerableExtensions 7 | { 8 | public static IEnumerable ShapeData( 9 | this IEnumerable source, 10 | string? fields) 11 | { 12 | if (source == null) 13 | { 14 | throw new ArgumentNullException(nameof(source)); 15 | } 16 | 17 | // create a list to hold our ExpandoObjects 18 | var expandoObjectList = new List(); 19 | 20 | // create a list with PropertyInfo objects on TSource. Reflection is 21 | // expensive, so rather than doing it for each object in the list, we do 22 | // it once and reuse the results. After all, part of the reflection is on the 23 | // type of the object (TSource), not on the instance 24 | var propertyInfoList = new List(); 25 | 26 | if (string.IsNullOrWhiteSpace(fields)) 27 | { 28 | // all public properties should be in the ExpandoObject 29 | var propertyInfos = typeof(TSource) 30 | .GetProperties(BindingFlags.IgnoreCase 31 | | BindingFlags.Public | BindingFlags.Instance); 32 | 33 | propertyInfoList.AddRange(propertyInfos); 34 | } 35 | else 36 | { // the field are separated by ",", so we split it. 37 | var fieldsAfterSplit = fields.Split(','); 38 | 39 | foreach (var field in fieldsAfterSplit) 40 | { 41 | // trim each field, as it might contain leading 42 | // or trailing spaces. Can't trim the var in foreach, 43 | // so use another var. 44 | var propertyName = field.Trim(); 45 | 46 | // use reflection to get the property on the source object 47 | // we need to include public and instance, b/c specifying a binding 48 | // flag overwrites the already-existing binding flags. 49 | var propertyInfo = typeof(TSource) 50 | .GetProperty(propertyName, BindingFlags.IgnoreCase | 51 | BindingFlags.Public | BindingFlags.Instance); 52 | 53 | if (propertyInfo == null) 54 | { 55 | throw new Exception($"Property {propertyName} wasn't found on" + 56 | $" {typeof(TSource)}"); 57 | } 58 | 59 | // add propertyInfo to list 60 | propertyInfoList.Add(propertyInfo); 61 | } 62 | } 63 | 64 | // run through the source objects 65 | foreach (TSource sourceObject in source) 66 | { 67 | // create an ExpandoObject that will hold the 68 | // selected properties & values 69 | var dataShapedObject = new ExpandoObject(); 70 | 71 | // Get the value of each property we have to return. For that, 72 | // we run through the list 73 | foreach (var propertyInfo in propertyInfoList) 74 | { 75 | // GetValue returns the value of the property on the source object 76 | var propertyValue = propertyInfo.GetValue(sourceObject); 77 | 78 | // add the field to the ExpandoObject 79 | ((IDictionary)dataShapedObject) 80 | .Add(propertyInfo.Name, propertyValue); 81 | } 82 | 83 | // add the ExpandoObject to the list 84 | expandoObjectList.Add(dataShapedObject); 85 | } 86 | 87 | // return the list 88 | return expandoObjectList; 89 | } 90 | } -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Helpers/IQueryableExtensions.cs: -------------------------------------------------------------------------------- 1 | using CourseLibrary.API.Services; 2 | using System.Linq.Dynamic.Core; 3 | 4 | namespace CourseLibrary.API.Helpers; 5 | 6 | public static class IQueryableExtensions 7 | { 8 | public static IQueryable ApplySort( 9 | this IQueryable source, 10 | string orderBy, 11 | Dictionary mappingDictionary) 12 | { 13 | if (source == null) 14 | { 15 | throw new ArgumentNullException(nameof(source)); 16 | } 17 | 18 | if (mappingDictionary == null) 19 | { 20 | throw new ArgumentNullException(nameof(mappingDictionary)); 21 | } 22 | 23 | if (string.IsNullOrWhiteSpace(orderBy)) 24 | { 25 | return source; 26 | } 27 | 28 | var orderByString = string.Empty; 29 | 30 | // the orderBy string is separated by ",", so we split it. 31 | var orderByAfterSplit = orderBy.Split(','); 32 | 33 | // apply each orderby clause 34 | foreach (var orderByClause in orderByAfterSplit) 35 | { 36 | // trim the orderBy clause, as it might contain leading 37 | // or trailing spaces. Can't trim the var in foreach, 38 | // so use another var. 39 | var trimmedOrderByClause = orderByClause.Trim(); 40 | 41 | // if the sort option ends with with " desc", we order 42 | // descending, ortherwise ascending 43 | var orderDescending = trimmedOrderByClause.EndsWith(" desc"); 44 | 45 | // remove " asc" or " desc" from the orderBy clause, so we 46 | // get the property name to look for in the mapping dictionary 47 | var indexOfFirstSpace = trimmedOrderByClause.IndexOf(" "); 48 | var propertyName = indexOfFirstSpace == -1 ? 49 | trimmedOrderByClause : trimmedOrderByClause 50 | .Remove(indexOfFirstSpace); 51 | 52 | // find the matching property 53 | if (!mappingDictionary.ContainsKey(propertyName)) 54 | { 55 | throw new ArgumentException($"Key mapping for {propertyName} is missing"); 56 | } 57 | 58 | // get the PropertyMappingValue 59 | var propertyMappingValue = mappingDictionary[propertyName]; 60 | 61 | if (propertyMappingValue == null) 62 | { 63 | throw new ArgumentNullException(nameof(propertyMappingValue)); 64 | } 65 | 66 | // revert sort order if necessary 67 | if (propertyMappingValue.Revert) 68 | { 69 | orderDescending = !orderDescending; 70 | } 71 | 72 | // Run through the property names 73 | foreach (var destinationProperty in 74 | propertyMappingValue.DestinationProperties) 75 | { 76 | orderByString = orderByString + 77 | (string.IsNullOrWhiteSpace(orderByString) ? string.Empty : ", ") 78 | + destinationProperty 79 | + (orderDescending ? " descending" : " ascending"); 80 | } 81 | } 82 | 83 | return source.OrderBy(orderByString); 84 | } 85 | } 86 | 87 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Helpers/ObjectExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Dynamic; 2 | using System.Reflection; 3 | 4 | namespace CourseLibrary.API.Helpers; 5 | 6 | public static class ObjectExtensions 7 | { 8 | public static ExpandoObject ShapeData(this TSource source, 9 | string? fields) 10 | { 11 | if (source == null) 12 | { 13 | throw new ArgumentNullException(nameof(source)); 14 | } 15 | 16 | var dataShapedObject = new ExpandoObject(); 17 | 18 | if (string.IsNullOrWhiteSpace(fields)) 19 | { 20 | // all public properties should be in the ExpandoObject 21 | var propertyInfos = typeof(TSource) 22 | .GetProperties(BindingFlags.IgnoreCase | 23 | BindingFlags.Public | BindingFlags.Instance); 24 | 25 | foreach (var propertyInfo in propertyInfos) 26 | { 27 | // get the value of the property on the source object 28 | var propertyValue = propertyInfo.GetValue(source); 29 | 30 | // add the field to the ExpandoObject 31 | ((IDictionary)dataShapedObject) 32 | .Add(propertyInfo.Name, propertyValue); 33 | } 34 | 35 | return dataShapedObject; 36 | } 37 | 38 | // the field are separated by ",", so we split it. 39 | var fieldsAfterSplit = fields.Split(','); 40 | 41 | foreach (var field in fieldsAfterSplit) 42 | { 43 | // trim each field, as it might contain leading 44 | // or trailing spaces. Can't trim the var in foreach, 45 | // so use another var. 46 | var propertyName = field.Trim(); 47 | 48 | // use reflection to get the property on the source object 49 | // we need to include public and instance, b/c specifying a 50 | // binding flag overwrites the already-existing binding flags. 51 | var propertyInfo = typeof(TSource) 52 | .GetProperty(propertyName, 53 | BindingFlags.IgnoreCase | BindingFlags.Public | 54 | BindingFlags.Instance); 55 | 56 | if (propertyInfo == null) 57 | { 58 | throw new Exception($"Property {propertyName} wasn't found " + 59 | $"on {typeof(TSource)}"); 60 | } 61 | 62 | // get the value of the property on the source object 63 | var propertyValue = propertyInfo.GetValue(source); 64 | 65 | // add the field to the ExpandoObject 66 | ((IDictionary)dataShapedObject) 67 | .Add(propertyInfo.Name, propertyValue); 68 | } 69 | 70 | // return the shaped object 71 | return dataShapedObject; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Helpers/PagedList.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace CourseLibrary.API.Helpers; 4 | 5 | public class PagedList : List 6 | { 7 | public int CurrentPage { get; private set; } 8 | public int TotalPages { get; private set; } 9 | public int PageSize { get; private set; } 10 | public int TotalCount { get; private set; } 11 | public bool HasPrevious => (CurrentPage > 1); 12 | public bool HasNext => (CurrentPage < TotalPages); 13 | 14 | public PagedList(List items, int count, int pageNumber, int pageSize) 15 | { 16 | TotalCount = count; 17 | PageSize = pageSize; 18 | CurrentPage = pageNumber; 19 | TotalPages = (int)Math.Ceiling(count / (double)pageSize); 20 | AddRange(items); 21 | } 22 | 23 | public static async Task> CreateAsync( 24 | IQueryable source, int pageNumber, int pageSize) 25 | { 26 | var count = source.Count(); 27 | var items = await source.Skip((pageNumber - 1) * pageSize) 28 | .Take(pageSize).ToListAsync(); 29 | return new PagedList(items, count, pageNumber, pageSize); 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Helpers/ResourceUriType.cs: -------------------------------------------------------------------------------- 1 | namespace CourseLibrary.API.Helpers; 2 | 3 | public enum ResourceUriType 4 | { 5 | PreviousPage, 6 | NextPage, 7 | Current 8 | } 9 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Migrations/AddDateOfDeathToAuthor.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using CourseLibrary.API.DbContexts; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | 9 | #nullable disable 10 | 11 | namespace CourseLibrary.API.Migrations 12 | { 13 | [DbContext(typeof(CourseLibraryContext))] 14 | [Migration("20231130095428_AddDateOfDeathToAuthor")] 15 | partial class AddDateOfDeathToAuthor 16 | { 17 | /// 18 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 | { 20 | #pragma warning disable 612, 618 21 | modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); 22 | 23 | modelBuilder.Entity("CourseLibrary.API.Entities.Author", b => 24 | { 25 | b.Property("Id") 26 | .ValueGeneratedOnAdd() 27 | .HasColumnType("TEXT"); 28 | 29 | b.Property("DateOfBirth") 30 | .HasColumnType("INTEGER"); 31 | 32 | b.Property("DateOfDeath") 33 | .HasColumnType("INTEGER"); 34 | 35 | b.Property("FirstName") 36 | .IsRequired() 37 | .HasMaxLength(50) 38 | .HasColumnType("TEXT"); 39 | 40 | b.Property("LastName") 41 | .IsRequired() 42 | .HasMaxLength(50) 43 | .HasColumnType("TEXT"); 44 | 45 | b.Property("MainCategory") 46 | .IsRequired() 47 | .HasMaxLength(50) 48 | .HasColumnType("TEXT"); 49 | 50 | b.HasKey("Id"); 51 | 52 | b.ToTable("Authors"); 53 | 54 | b.HasData( 55 | new 56 | { 57 | Id = new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 58 | DateOfBirth = 1279360106496000120L, 59 | FirstName = "Berry", 60 | LastName = "Griffin Beak Eldritch", 61 | MainCategory = "Ships" 62 | }, 63 | new 64 | { 65 | Id = new Guid("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 66 | DateOfBirth = 1277955145728000120L, 67 | FirstName = "Nancy", 68 | LastName = "Swashbuckler Rye", 69 | MainCategory = "Rum" 70 | }, 71 | new 72 | { 73 | Id = new Guid("2902b665-1190-4c70-9915-b9c2d7680450"), 74 | DateOfBirth = 1264753115136000060L, 75 | FirstName = "Eli", 76 | LastName = "Ivory Bones Sweet", 77 | MainCategory = "Singing" 78 | }, 79 | new 80 | { 81 | Id = new Guid("102b566b-ba1f-404c-b2df-e2cde39ade09"), 82 | DateOfBirth = 1264248815616000060L, 83 | FirstName = "Arnold", 84 | LastName = "The Unseen Stafford", 85 | MainCategory = "Singing" 86 | }, 87 | new 88 | { 89 | Id = new Guid("5b3621c0-7b12-4e80-9c8b-3398cba7ee05"), 90 | DateOfBirth = 1264066560000000060L, 91 | FirstName = "Seabury", 92 | LastName = "Toxic Reyson", 93 | MainCategory = "Maps" 94 | }, 95 | new 96 | { 97 | Id = new Guid("2aadd2df-7caf-45ab-9355-7f6332985a87"), 98 | DateOfBirth = 1279813091328000120L, 99 | FirstName = "Rutherford", 100 | LastName = "Fearless Cloven", 101 | MainCategory = "General debauchery" 102 | }, 103 | new 104 | { 105 | Id = new Guid("2ee49fe3-edf2-4f91-8409-3eb25ce6ca51"), 106 | DateOfBirth = 1280793378816000120L, 107 | FirstName = "Atherton", 108 | LastName = "Crow Ridley", 109 | MainCategory = "Rum" 110 | }); 111 | }); 112 | 113 | modelBuilder.Entity("CourseLibrary.API.Entities.Course", b => 114 | { 115 | b.Property("Id") 116 | .ValueGeneratedOnAdd() 117 | .HasColumnType("TEXT"); 118 | 119 | b.Property("AuthorId") 120 | .HasColumnType("TEXT"); 121 | 122 | b.Property("Description") 123 | .HasMaxLength(1500) 124 | .HasColumnType("TEXT"); 125 | 126 | b.Property("Title") 127 | .IsRequired() 128 | .HasMaxLength(100) 129 | .HasColumnType("TEXT"); 130 | 131 | b.HasKey("Id"); 132 | 133 | b.HasIndex("AuthorId"); 134 | 135 | b.ToTable("Courses"); 136 | 137 | b.HasData( 138 | new 139 | { 140 | Id = new Guid("5b1c2b4d-48c7-402a-80c3-cc796ad49c6b"), 141 | AuthorId = new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 142 | Description = "Commandeering a ship in rough waters isn't easy. Commandeering it without getting caught is even harder. In this course you'll learn how to sail away and avoid those pesky musketeers.", 143 | Title = "Commandeering a Ship Without Getting Caught" 144 | }, 145 | new 146 | { 147 | Id = new Guid("d8663e5e-7494-4f81-8739-6e0de1bea7ee"), 148 | AuthorId = new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 149 | Description = "In this course, the author provides tips to avoid, or, if needed, overthrow pirate mutiny.", 150 | Title = "Overthrowing Mutiny" 151 | }, 152 | new 153 | { 154 | Id = new Guid("d173e20d-159e-4127-9ce9-b0ac2564ad97"), 155 | AuthorId = new Guid("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 156 | Description = "Every good pirate loves rum, but it also has a tendency to get you into trouble. In this course you'll learn how to avoid that. This new exclusive edition includes an additional chapter on how to run fast without falling while drunk.", 157 | Title = "Avoiding Brawls While Drinking as Much Rum as You Desire" 158 | }, 159 | new 160 | { 161 | Id = new Guid("40ff5488-fdab-45b5-bc3a-14302d59869a"), 162 | AuthorId = new Guid("2902b665-1190-4c70-9915-b9c2d7680450"), 163 | Description = "In this course you'll learn how to sing all-time favourite pirate songs without sounding like you actually know the words or how to hold a note.", 164 | Title = "Singalong Pirate Hits" 165 | }); 166 | }); 167 | 168 | modelBuilder.Entity("CourseLibrary.API.Entities.Course", b => 169 | { 170 | b.HasOne("CourseLibrary.API.Entities.Author", "Author") 171 | .WithMany("Courses") 172 | .HasForeignKey("AuthorId") 173 | .OnDelete(DeleteBehavior.Cascade) 174 | .IsRequired(); 175 | 176 | b.Navigation("Author"); 177 | }); 178 | 179 | modelBuilder.Entity("CourseLibrary.API.Entities.Author", b => 180 | { 181 | b.Navigation("Courses"); 182 | }); 183 | #pragma warning restore 612, 618 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Migrations/AddDateOfDeathToAuthor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | namespace CourseLibrary.API.Migrations 7 | { 8 | /// 9 | public partial class AddDateOfDeathToAuthor : Migration 10 | { 11 | /// 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.AddColumn( 15 | name: "DateOfDeath", 16 | table: "Authors", 17 | type: "INTEGER", 18 | nullable: true); 19 | 20 | migrationBuilder.UpdateData( 21 | table: "Authors", 22 | keyColumn: "Id", 23 | keyValue: new Guid("102b566b-ba1f-404c-b2df-e2cde39ade09"), 24 | column: "DateOfDeath", 25 | value: null); 26 | 27 | migrationBuilder.UpdateData( 28 | table: "Authors", 29 | keyColumn: "Id", 30 | keyValue: new Guid("2902b665-1190-4c70-9915-b9c2d7680450"), 31 | column: "DateOfDeath", 32 | value: null); 33 | 34 | migrationBuilder.UpdateData( 35 | table: "Authors", 36 | keyColumn: "Id", 37 | keyValue: new Guid("2aadd2df-7caf-45ab-9355-7f6332985a87"), 38 | column: "DateOfDeath", 39 | value: null); 40 | 41 | migrationBuilder.UpdateData( 42 | table: "Authors", 43 | keyColumn: "Id", 44 | keyValue: new Guid("2ee49fe3-edf2-4f91-8409-3eb25ce6ca51"), 45 | column: "DateOfDeath", 46 | value: null); 47 | 48 | migrationBuilder.UpdateData( 49 | table: "Authors", 50 | keyColumn: "Id", 51 | keyValue: new Guid("5b3621c0-7b12-4e80-9c8b-3398cba7ee05"), 52 | column: "DateOfDeath", 53 | value: null); 54 | 55 | migrationBuilder.UpdateData( 56 | table: "Authors", 57 | keyColumn: "Id", 58 | keyValue: new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 59 | column: "DateOfDeath", 60 | value: null); 61 | 62 | migrationBuilder.UpdateData( 63 | table: "Authors", 64 | keyColumn: "Id", 65 | keyValue: new Guid("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 66 | column: "DateOfDeath", 67 | value: null); 68 | } 69 | 70 | /// 71 | protected override void Down(MigrationBuilder migrationBuilder) 72 | { 73 | migrationBuilder.DropColumn( 74 | name: "DateOfDeath", 75 | table: "Authors"); 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Migrations/CourseLibraryContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using CourseLibrary.API.DbContexts; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | 8 | #nullable disable 9 | 10 | namespace CourseLibrary.API.Migrations 11 | { 12 | [DbContext(typeof(CourseLibraryContext))] 13 | partial class CourseLibraryContextModelSnapshot : ModelSnapshot 14 | { 15 | protected override void BuildModel(ModelBuilder modelBuilder) 16 | { 17 | #pragma warning disable 612, 618 18 | modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); 19 | 20 | modelBuilder.Entity("CourseLibrary.API.Entities.Author", b => 21 | { 22 | b.Property("Id") 23 | .ValueGeneratedOnAdd() 24 | .HasColumnType("TEXT"); 25 | 26 | b.Property("DateOfBirth") 27 | .HasColumnType("INTEGER"); 28 | 29 | b.Property("DateOfDeath") 30 | .HasColumnType("INTEGER"); 31 | 32 | b.Property("FirstName") 33 | .IsRequired() 34 | .HasMaxLength(50) 35 | .HasColumnType("TEXT"); 36 | 37 | b.Property("LastName") 38 | .IsRequired() 39 | .HasMaxLength(50) 40 | .HasColumnType("TEXT"); 41 | 42 | b.Property("MainCategory") 43 | .IsRequired() 44 | .HasMaxLength(50) 45 | .HasColumnType("TEXT"); 46 | 47 | b.HasKey("Id"); 48 | 49 | b.ToTable("Authors"); 50 | 51 | b.HasData( 52 | new 53 | { 54 | Id = new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 55 | DateOfBirth = 1279360106496000120L, 56 | FirstName = "Berry", 57 | LastName = "Griffin Beak Eldritch", 58 | MainCategory = "Ships" 59 | }, 60 | new 61 | { 62 | Id = new Guid("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 63 | DateOfBirth = 1277955145728000120L, 64 | FirstName = "Nancy", 65 | LastName = "Swashbuckler Rye", 66 | MainCategory = "Rum" 67 | }, 68 | new 69 | { 70 | Id = new Guid("2902b665-1190-4c70-9915-b9c2d7680450"), 71 | DateOfBirth = 1264753115136000060L, 72 | FirstName = "Eli", 73 | LastName = "Ivory Bones Sweet", 74 | MainCategory = "Singing" 75 | }, 76 | new 77 | { 78 | Id = new Guid("102b566b-ba1f-404c-b2df-e2cde39ade09"), 79 | DateOfBirth = 1264248815616000060L, 80 | FirstName = "Arnold", 81 | LastName = "The Unseen Stafford", 82 | MainCategory = "Singing" 83 | }, 84 | new 85 | { 86 | Id = new Guid("5b3621c0-7b12-4e80-9c8b-3398cba7ee05"), 87 | DateOfBirth = 1264066560000000060L, 88 | FirstName = "Seabury", 89 | LastName = "Toxic Reyson", 90 | MainCategory = "Maps" 91 | }, 92 | new 93 | { 94 | Id = new Guid("2aadd2df-7caf-45ab-9355-7f6332985a87"), 95 | DateOfBirth = 1279813091328000120L, 96 | FirstName = "Rutherford", 97 | LastName = "Fearless Cloven", 98 | MainCategory = "General debauchery" 99 | }, 100 | new 101 | { 102 | Id = new Guid("2ee49fe3-edf2-4f91-8409-3eb25ce6ca51"), 103 | DateOfBirth = 1280793378816000120L, 104 | FirstName = "Atherton", 105 | LastName = "Crow Ridley", 106 | MainCategory = "Rum" 107 | }); 108 | }); 109 | 110 | modelBuilder.Entity("CourseLibrary.API.Entities.Course", b => 111 | { 112 | b.Property("Id") 113 | .ValueGeneratedOnAdd() 114 | .HasColumnType("TEXT"); 115 | 116 | b.Property("AuthorId") 117 | .HasColumnType("TEXT"); 118 | 119 | b.Property("Description") 120 | .HasMaxLength(1500) 121 | .HasColumnType("TEXT"); 122 | 123 | b.Property("Title") 124 | .IsRequired() 125 | .HasMaxLength(100) 126 | .HasColumnType("TEXT"); 127 | 128 | b.HasKey("Id"); 129 | 130 | b.HasIndex("AuthorId"); 131 | 132 | b.ToTable("Courses"); 133 | 134 | b.HasData( 135 | new 136 | { 137 | Id = new Guid("5b1c2b4d-48c7-402a-80c3-cc796ad49c6b"), 138 | AuthorId = new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 139 | Description = "Commandeering a ship in rough waters isn't easy. Commandeering it without getting caught is even harder. In this course you'll learn how to sail away and avoid those pesky musketeers.", 140 | Title = "Commandeering a Ship Without Getting Caught" 141 | }, 142 | new 143 | { 144 | Id = new Guid("d8663e5e-7494-4f81-8739-6e0de1bea7ee"), 145 | AuthorId = new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 146 | Description = "In this course, the author provides tips to avoid, or, if needed, overthrow pirate mutiny.", 147 | Title = "Overthrowing Mutiny" 148 | }, 149 | new 150 | { 151 | Id = new Guid("d173e20d-159e-4127-9ce9-b0ac2564ad97"), 152 | AuthorId = new Guid("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 153 | Description = "Every good pirate loves rum, but it also has a tendency to get you into trouble. In this course you'll learn how to avoid that. This new exclusive edition includes an additional chapter on how to run fast without falling while drunk.", 154 | Title = "Avoiding Brawls While Drinking as Much Rum as You Desire" 155 | }, 156 | new 157 | { 158 | Id = new Guid("40ff5488-fdab-45b5-bc3a-14302d59869a"), 159 | AuthorId = new Guid("2902b665-1190-4c70-9915-b9c2d7680450"), 160 | Description = "In this course you'll learn how to sing all-time favourite pirate songs without sounding like you actually know the words or how to hold a note.", 161 | Title = "Singalong Pirate Hits" 162 | }); 163 | }); 164 | 165 | modelBuilder.Entity("CourseLibrary.API.Entities.Course", b => 166 | { 167 | b.HasOne("CourseLibrary.API.Entities.Author", "Author") 168 | .WithMany("Courses") 169 | .HasForeignKey("AuthorId") 170 | .OnDelete(DeleteBehavior.Cascade) 171 | .IsRequired(); 172 | 173 | b.Navigation("Author"); 174 | }); 175 | 176 | modelBuilder.Entity("CourseLibrary.API.Entities.Author", b => 177 | { 178 | b.Navigation("Courses"); 179 | }); 180 | #pragma warning restore 612, 618 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Migrations/InitialMigration.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using CourseLibrary.API.DbContexts; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | 9 | #nullable disable 10 | 11 | namespace CourseLibrary.API.Migrations 12 | { 13 | [DbContext(typeof(CourseLibraryContext))] 14 | [Migration("20231130095259_InitialMigration")] 15 | partial class InitialMigration 16 | { 17 | /// 18 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 | { 20 | #pragma warning disable 612, 618 21 | modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); 22 | 23 | modelBuilder.Entity("CourseLibrary.API.Entities.Author", b => 24 | { 25 | b.Property("Id") 26 | .ValueGeneratedOnAdd() 27 | .HasColumnType("TEXT"); 28 | 29 | b.Property("DateOfBirth") 30 | .HasColumnType("INTEGER"); 31 | 32 | b.Property("FirstName") 33 | .IsRequired() 34 | .HasMaxLength(50) 35 | .HasColumnType("TEXT"); 36 | 37 | b.Property("LastName") 38 | .IsRequired() 39 | .HasMaxLength(50) 40 | .HasColumnType("TEXT"); 41 | 42 | b.Property("MainCategory") 43 | .IsRequired() 44 | .HasMaxLength(50) 45 | .HasColumnType("TEXT"); 46 | 47 | b.HasKey("Id"); 48 | 49 | b.ToTable("Authors"); 50 | 51 | b.HasData( 52 | new 53 | { 54 | Id = new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 55 | DateOfBirth = 1279360106496000120L, 56 | FirstName = "Berry", 57 | LastName = "Griffin Beak Eldritch", 58 | MainCategory = "Ships" 59 | }, 60 | new 61 | { 62 | Id = new Guid("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 63 | DateOfBirth = 1277955145728000120L, 64 | FirstName = "Nancy", 65 | LastName = "Swashbuckler Rye", 66 | MainCategory = "Rum" 67 | }, 68 | new 69 | { 70 | Id = new Guid("2902b665-1190-4c70-9915-b9c2d7680450"), 71 | DateOfBirth = 1264753115136000060L, 72 | FirstName = "Eli", 73 | LastName = "Ivory Bones Sweet", 74 | MainCategory = "Singing" 75 | }, 76 | new 77 | { 78 | Id = new Guid("102b566b-ba1f-404c-b2df-e2cde39ade09"), 79 | DateOfBirth = 1264248815616000060L, 80 | FirstName = "Arnold", 81 | LastName = "The Unseen Stafford", 82 | MainCategory = "Singing" 83 | }, 84 | new 85 | { 86 | Id = new Guid("5b3621c0-7b12-4e80-9c8b-3398cba7ee05"), 87 | DateOfBirth = 1264066560000000060L, 88 | FirstName = "Seabury", 89 | LastName = "Toxic Reyson", 90 | MainCategory = "Maps" 91 | }, 92 | new 93 | { 94 | Id = new Guid("2aadd2df-7caf-45ab-9355-7f6332985a87"), 95 | DateOfBirth = 1279813091328000120L, 96 | FirstName = "Rutherford", 97 | LastName = "Fearless Cloven", 98 | MainCategory = "General debauchery" 99 | }, 100 | new 101 | { 102 | Id = new Guid("2ee49fe3-edf2-4f91-8409-3eb25ce6ca51"), 103 | DateOfBirth = 1280793378816000120L, 104 | FirstName = "Atherton", 105 | LastName = "Crow Ridley", 106 | MainCategory = "Rum" 107 | }); 108 | }); 109 | 110 | modelBuilder.Entity("CourseLibrary.API.Entities.Course", b => 111 | { 112 | b.Property("Id") 113 | .ValueGeneratedOnAdd() 114 | .HasColumnType("TEXT"); 115 | 116 | b.Property("AuthorId") 117 | .HasColumnType("TEXT"); 118 | 119 | b.Property("Description") 120 | .HasMaxLength(1500) 121 | .HasColumnType("TEXT"); 122 | 123 | b.Property("Title") 124 | .IsRequired() 125 | .HasMaxLength(100) 126 | .HasColumnType("TEXT"); 127 | 128 | b.HasKey("Id"); 129 | 130 | b.HasIndex("AuthorId"); 131 | 132 | b.ToTable("Courses"); 133 | 134 | b.HasData( 135 | new 136 | { 137 | Id = new Guid("5b1c2b4d-48c7-402a-80c3-cc796ad49c6b"), 138 | AuthorId = new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 139 | Description = "Commandeering a ship in rough waters isn't easy. Commandeering it without getting caught is even harder. In this course you'll learn how to sail away and avoid those pesky musketeers.", 140 | Title = "Commandeering a Ship Without Getting Caught" 141 | }, 142 | new 143 | { 144 | Id = new Guid("d8663e5e-7494-4f81-8739-6e0de1bea7ee"), 145 | AuthorId = new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 146 | Description = "In this course, the author provides tips to avoid, or, if needed, overthrow pirate mutiny.", 147 | Title = "Overthrowing Mutiny" 148 | }, 149 | new 150 | { 151 | Id = new Guid("d173e20d-159e-4127-9ce9-b0ac2564ad97"), 152 | AuthorId = new Guid("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 153 | Description = "Every good pirate loves rum, but it also has a tendency to get you into trouble. In this course you'll learn how to avoid that. This new exclusive edition includes an additional chapter on how to run fast without falling while drunk.", 154 | Title = "Avoiding Brawls While Drinking as Much Rum as You Desire" 155 | }, 156 | new 157 | { 158 | Id = new Guid("40ff5488-fdab-45b5-bc3a-14302d59869a"), 159 | AuthorId = new Guid("2902b665-1190-4c70-9915-b9c2d7680450"), 160 | Description = "In this course you'll learn how to sing all-time favourite pirate songs without sounding like you actually know the words or how to hold a note.", 161 | Title = "Singalong Pirate Hits" 162 | }); 163 | }); 164 | 165 | modelBuilder.Entity("CourseLibrary.API.Entities.Course", b => 166 | { 167 | b.HasOne("CourseLibrary.API.Entities.Author", "Author") 168 | .WithMany("Courses") 169 | .HasForeignKey("AuthorId") 170 | .OnDelete(DeleteBehavior.Cascade) 171 | .IsRequired(); 172 | 173 | b.Navigation("Author"); 174 | }); 175 | 176 | modelBuilder.Entity("CourseLibrary.API.Entities.Author", b => 177 | { 178 | b.Navigation("Courses"); 179 | }); 180 | #pragma warning restore 612, 618 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Migrations/InitialMigration.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | #pragma warning disable CA1814 // Prefer jagged arrays over multidimensional 7 | 8 | namespace CourseLibrary.API.Migrations 9 | { 10 | /// 11 | public partial class InitialMigration : Migration 12 | { 13 | /// 14 | protected override void Up(MigrationBuilder migrationBuilder) 15 | { 16 | migrationBuilder.CreateTable( 17 | name: "Authors", 18 | columns: table => new 19 | { 20 | Id = table.Column(type: "TEXT", nullable: false), 21 | FirstName = table.Column(type: "TEXT", maxLength: 50, nullable: false), 22 | LastName = table.Column(type: "TEXT", maxLength: 50, nullable: false), 23 | DateOfBirth = table.Column(type: "INTEGER", nullable: false), 24 | MainCategory = table.Column(type: "TEXT", maxLength: 50, nullable: false) 25 | }, 26 | constraints: table => 27 | { 28 | table.PrimaryKey("PK_Authors", x => x.Id); 29 | }); 30 | 31 | migrationBuilder.CreateTable( 32 | name: "Courses", 33 | columns: table => new 34 | { 35 | Id = table.Column(type: "TEXT", nullable: false), 36 | Title = table.Column(type: "TEXT", maxLength: 100, nullable: false), 37 | Description = table.Column(type: "TEXT", maxLength: 1500, nullable: true), 38 | AuthorId = table.Column(type: "TEXT", nullable: false) 39 | }, 40 | constraints: table => 41 | { 42 | table.PrimaryKey("PK_Courses", x => x.Id); 43 | table.ForeignKey( 44 | name: "FK_Courses_Authors_AuthorId", 45 | column: x => x.AuthorId, 46 | principalTable: "Authors", 47 | principalColumn: "Id", 48 | onDelete: ReferentialAction.Cascade); 49 | }); 50 | 51 | migrationBuilder.InsertData( 52 | table: "Authors", 53 | columns: new[] { "Id", "DateOfBirth", "FirstName", "LastName", "MainCategory" }, 54 | values: new object[,] 55 | { 56 | { new Guid("102b566b-ba1f-404c-b2df-e2cde39ade09"), 1264248815616000060L, "Arnold", "The Unseen Stafford", "Singing" }, 57 | { new Guid("2902b665-1190-4c70-9915-b9c2d7680450"), 1264753115136000060L, "Eli", "Ivory Bones Sweet", "Singing" }, 58 | { new Guid("2aadd2df-7caf-45ab-9355-7f6332985a87"), 1279813091328000120L, "Rutherford", "Fearless Cloven", "General debauchery" }, 59 | { new Guid("2ee49fe3-edf2-4f91-8409-3eb25ce6ca51"), 1280793378816000120L, "Atherton", "Crow Ridley", "Rum" }, 60 | { new Guid("5b3621c0-7b12-4e80-9c8b-3398cba7ee05"), 1264066560000000060L, "Seabury", "Toxic Reyson", "Maps" }, 61 | { new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 1279360106496000120L, "Berry", "Griffin Beak Eldritch", "Ships" }, 62 | { new Guid("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 1277955145728000120L, "Nancy", "Swashbuckler Rye", "Rum" } 63 | }); 64 | 65 | migrationBuilder.InsertData( 66 | table: "Courses", 67 | columns: new[] { "Id", "AuthorId", "Description", "Title" }, 68 | values: new object[,] 69 | { 70 | { new Guid("40ff5488-fdab-45b5-bc3a-14302d59869a"), new Guid("2902b665-1190-4c70-9915-b9c2d7680450"), "In this course you'll learn how to sing all-time favourite pirate songs without sounding like you actually know the words or how to hold a note.", "Singalong Pirate Hits" }, 71 | { new Guid("5b1c2b4d-48c7-402a-80c3-cc796ad49c6b"), new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), "Commandeering a ship in rough waters isn't easy. Commandeering it without getting caught is even harder. In this course you'll learn how to sail away and avoid those pesky musketeers.", "Commandeering a Ship Without Getting Caught" }, 72 | { new Guid("d173e20d-159e-4127-9ce9-b0ac2564ad97"), new Guid("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), "Every good pirate loves rum, but it also has a tendency to get you into trouble. In this course you'll learn how to avoid that. This new exclusive edition includes an additional chapter on how to run fast without falling while drunk.", "Avoiding Brawls While Drinking as Much Rum as You Desire" }, 73 | { new Guid("d8663e5e-7494-4f81-8739-6e0de1bea7ee"), new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), "In this course, the author provides tips to avoid, or, if needed, overthrow pirate mutiny.", "Overthrowing Mutiny" } 74 | }); 75 | 76 | migrationBuilder.CreateIndex( 77 | name: "IX_Courses_AuthorId", 78 | table: "Courses", 79 | column: "AuthorId"); 80 | } 81 | 82 | /// 83 | protected override void Down(MigrationBuilder migrationBuilder) 84 | { 85 | migrationBuilder.DropTable( 86 | name: "Courses"); 87 | 88 | migrationBuilder.DropTable( 89 | name: "Authors"); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Models/AuthorDto.cs: -------------------------------------------------------------------------------- 1 | namespace CourseLibrary.API.Models; 2 | 3 | public class AuthorDto 4 | { 5 | public Guid Id { get; set; } 6 | public string Name { get; set; } = string.Empty; 7 | public int Age { get; set; } 8 | public string MainCategory { get; set; } = string.Empty; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Models/AuthorForCreationDto.cs: -------------------------------------------------------------------------------- 1 | namespace CourseLibrary.API.Models; 2 | 3 | public class AuthorForCreationDto 4 | { 5 | public string FirstName { get; set; } = string.Empty; 6 | public string LastName { get; set; } = string.Empty; 7 | public DateTimeOffset DateOfBirth { get; set; } 8 | public string MainCategory { get; set; } = string.Empty; 9 | public ICollection Courses { get; set; } 10 | = new List(); 11 | } -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Models/AuthorForCreationWithDateOfDeathDto.cs: -------------------------------------------------------------------------------- 1 | namespace CourseLibrary.API.Models; 2 | 3 | public class AuthorForCreationWithDateOfDeathDto : AuthorForCreationDto 4 | { 5 | public DateTimeOffset? DateOfDeath { get; set; } 6 | } -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Models/AuthorFullDto.cs: -------------------------------------------------------------------------------- 1 | namespace CourseLibrary.API.Models; 2 | public class AuthorFullDto 3 | { 4 | public Guid Id { get; set; } 5 | public string FirstName { get; set; } = string.Empty; 6 | public string LastName { get; set; } = string.Empty; 7 | public DateTimeOffset DateOfBirth { get; set; } 8 | public string MainCategory { get; set; } = string.Empty; 9 | } -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Models/CourseDto.cs: -------------------------------------------------------------------------------- 1 | namespace CourseLibrary.API.Models; 2 | 3 | public class CourseDto 4 | { 5 | public Guid Id { get; set; } 6 | 7 | public string Title { get; set; } = string.Empty; 8 | 9 | public string? Description { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Models/CourseForCreationDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace CourseLibrary.API.Models; 4 | 5 | public class CourseForCreationDto : CourseForManipulationDto 6 | { 7 | } 8 | 9 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Models/CourseForManipulationDto.cs: -------------------------------------------------------------------------------- 1 | using CourseLibrary.API.ValidationAttributes; 2 | using System.ComponentModel.DataAnnotations; 3 | 4 | namespace CourseLibrary.API.Models; 5 | 6 | [CourseTitleMustBeDifferentFromDescription] 7 | public abstract class CourseForManipulationDto //: IValidatableObject 8 | { 9 | [Required(ErrorMessage = "You should fill out a title.")] 10 | [MaxLength(100, ErrorMessage = "The title shouldn't have more than 100 characters.")] 11 | public string Title { get; set; } = string.Empty; 12 | 13 | [MaxLength(1500, ErrorMessage = "The description shouldn't have more than 1500 characters.")] 14 | public virtual string Description { get; set; } = string.Empty; 15 | 16 | //public IEnumerable Validate( 17 | // ValidationContext validationContext) 18 | //{ 19 | // if (Title == Description) 20 | // { 21 | // yield return new ValidationResult( 22 | // "The provided description should be different from the title.", 23 | // new[] { "Course" }); 24 | // } 25 | //} 26 | } 27 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Models/CourseForUpdateDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace CourseLibrary.API.Models; 4 | 5 | public class CourseForUpdateDto : CourseForManipulationDto 6 | { 7 | [Required(ErrorMessage = "You should fill out a description.")] 8 | public override string Description { 9 | get => base.Description; set => base.Description = value; } 10 | } -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Models/LinkDto.cs: -------------------------------------------------------------------------------- 1 | namespace CourseLibrary.API.Models; 2 | 3 | public class LinkDto 4 | { 5 | public string? Href { get; private set; } 6 | public string? Rel { get; private set; } 7 | public string Method { get; private set; } 8 | 9 | public LinkDto(string? href, string? rel, string method) 10 | { 11 | Href = href; 12 | Rel = rel; 13 | Method = method; 14 | } 15 | } -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Profiles/AuthorProfile.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using CourseLibrary.API.Helpers; 3 | 4 | namespace CourseLibrary.API.Profiles; 5 | public class AuthorsProfile : Profile 6 | { 7 | public AuthorsProfile() 8 | { 9 | CreateMap() 10 | .ForMember(dest => dest.Name, opt => 11 | opt.MapFrom(src => $"{src.FirstName} {src.LastName}")) 12 | .ForMember(dest => dest.Age, opt => 13 | opt.MapFrom(src => src.DateOfBirth.GetCurrentAge(src.DateOfDeath))); 14 | 15 | CreateMap(); 16 | 17 | CreateMap(); 18 | 19 | CreateMap(); 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Profiles/CourseProfile.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | 3 | namespace CourseLibrary.API.Profiles; 4 | public class CoursesProfile : Profile 5 | { 6 | public CoursesProfile() 7 | { 8 | CreateMap(); 9 | CreateMap(); 10 | CreateMap().ReverseMap(); 11 | 12 | } 13 | } -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Program.cs: -------------------------------------------------------------------------------- 1 | using CourseLibrary.API; 2 | 3 | var builder = WebApplication.CreateBuilder(args); 4 | 5 | var app = builder 6 | .ConfigureServices() 7 | .ConfigurePipeline(); 8 | 9 | // for demo purposes, delete the database & migrate on startup so 10 | // we can start with a clean slate 11 | await app.ResetDatabaseAsync(); 12 | 13 | // run the app 14 | app.Run(); 15 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:7499", 8 | "sslPort": 0 9 | } 10 | }, 11 | "profiles": { 12 | "CourseLibrary.API": { 13 | "commandName": "Project", 14 | "environmentVariables": { 15 | "ASPNETCORE_ENVIRONMENT": "Development" 16 | }, 17 | "applicationUrl": "http://localhost:5000", 18 | "dotnetRunMessages": true 19 | }, 20 | "IIS Express": { 21 | "commandName": "IISExpress", 22 | "launchBrowser": true, 23 | "launchUrl": "swagger", 24 | "environmentVariables": { 25 | "ASPNETCORE_ENVIRONMENT": "Development" 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/ResourceParameters/AuthorsResourceParameters.cs: -------------------------------------------------------------------------------- 1 | namespace CourseLibrary.API.ResourceParameters; 2 | 3 | public class AuthorsResourceParameters 4 | { 5 | const int maxPageSize = 20; 6 | public string? MainCategory { get; set; } 7 | public string? SearchQuery { get; set; } 8 | public int PageNumber { get; set; } = 1; 9 | 10 | private int _pageSize = 10; 11 | public int PageSize 12 | { 13 | get => _pageSize; 14 | set => _pageSize = (value > maxPageSize) ? maxPageSize : value; 15 | } 16 | public string OrderBy { get; set; } = "Name"; 17 | public string? Fields { get; set; } 18 | } -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Services/CourseLibraryRepository.cs: -------------------------------------------------------------------------------- 1 | using CourseLibrary.API.DbContexts; 2 | using CourseLibrary.API.Entities; 3 | using CourseLibrary.API.Helpers; 4 | using CourseLibrary.API.Models; 5 | using CourseLibrary.API.ResourceParameters; 6 | using Microsoft.EntityFrameworkCore; 7 | 8 | namespace CourseLibrary.API.Services; 9 | 10 | public class CourseLibraryRepository : ICourseLibraryRepository 11 | { 12 | private readonly CourseLibraryContext _context; 13 | private readonly IPropertyMappingService _propertyMappingService; 14 | 15 | public CourseLibraryRepository(CourseLibraryContext context, 16 | IPropertyMappingService propertyMappingService) 17 | { 18 | _context = context ?? throw new ArgumentNullException(nameof(context)); 19 | _propertyMappingService = propertyMappingService ?? 20 | throw new ArgumentNullException(nameof(propertyMappingService)); 21 | } 22 | 23 | public void AddCourse(Guid authorId, Course course) 24 | { 25 | if (authorId == Guid.Empty) 26 | { 27 | throw new ArgumentNullException(nameof(authorId)); 28 | } 29 | 30 | if (course == null) 31 | { 32 | throw new ArgumentNullException(nameof(course)); 33 | } 34 | 35 | // always set the AuthorId to the passed-in authorId 36 | course.AuthorId = authorId; 37 | _context.Courses.Add(course); 38 | } 39 | 40 | public void DeleteCourse(Course course) 41 | { 42 | _context.Courses.Remove(course); 43 | } 44 | 45 | public async Task GetCourseAsync(Guid authorId, Guid courseId) 46 | { 47 | if (authorId == Guid.Empty) 48 | { 49 | throw new ArgumentNullException(nameof(authorId)); 50 | } 51 | 52 | if (courseId == Guid.Empty) 53 | { 54 | throw new ArgumentNullException(nameof(courseId)); 55 | } 56 | 57 | #pragma warning disable CS8603 // Possible null reference return. 58 | return await _context.Courses 59 | .Where(c => c.AuthorId == authorId && c.Id == courseId).FirstOrDefaultAsync(); 60 | #pragma warning restore CS8603 // Possible null reference return. 61 | } 62 | 63 | public async Task> GetCoursesAsync(Guid authorId) 64 | { 65 | if (authorId == Guid.Empty) 66 | { 67 | throw new ArgumentNullException(nameof(authorId)); 68 | } 69 | 70 | return await _context.Courses 71 | .Where(c => c.AuthorId == authorId) 72 | .OrderBy(c => c.Title).ToListAsync(); 73 | } 74 | 75 | public void UpdateCourse(Course course) 76 | { 77 | // no code in this implementation 78 | } 79 | 80 | public void AddAuthor(Author author) 81 | { 82 | if (author == null) 83 | { 84 | throw new ArgumentNullException(nameof(author)); 85 | } 86 | 87 | // the repository fills the id (instead of using identity columns) 88 | author.Id = Guid.NewGuid(); 89 | 90 | foreach (var course in author.Courses) 91 | { 92 | course.Id = Guid.NewGuid(); 93 | } 94 | 95 | _context.Authors.Add(author); 96 | } 97 | 98 | public async Task AuthorExistsAsync(Guid authorId) 99 | { 100 | if (authorId == Guid.Empty) 101 | { 102 | throw new ArgumentNullException(nameof(authorId)); 103 | } 104 | 105 | return await _context.Authors.AnyAsync(a => a.Id == authorId); 106 | } 107 | 108 | public void DeleteAuthor(Author author) 109 | { 110 | if (author == null) 111 | { 112 | throw new ArgumentNullException(nameof(author)); 113 | } 114 | 115 | _context.Authors.Remove(author); 116 | } 117 | 118 | public async Task GetAuthorAsync(Guid authorId) 119 | { 120 | if (authorId == Guid.Empty) 121 | { 122 | throw new ArgumentNullException(nameof(authorId)); 123 | } 124 | 125 | #pragma warning disable CS8603 // Possible null reference return. 126 | return await _context.Authors.FirstOrDefaultAsync(a => a.Id == authorId); 127 | #pragma warning restore CS8603 // Possible null reference return. 128 | } 129 | 130 | 131 | public async Task> GetAuthorsAsync() 132 | { 133 | return await _context.Authors.ToListAsync(); 134 | } 135 | 136 | public async Task> GetAuthorsAsync( 137 | AuthorsResourceParameters authorsResourceParameters) 138 | { 139 | if (authorsResourceParameters == null) 140 | { 141 | throw new ArgumentNullException(nameof(authorsResourceParameters)); 142 | } 143 | 144 | //if (string.IsNullOrWhiteSpace(authorsResourceParameters.MainCategory) 145 | // && string.IsNullOrWhiteSpace(authorsResourceParameters.SearchQuery)) 146 | //{ 147 | // return await GetAuthorsAsync(); 148 | //} 149 | 150 | // collection to start from 151 | var collection = _context.Authors as IQueryable; 152 | 153 | if (!string.IsNullOrWhiteSpace(authorsResourceParameters.MainCategory)) 154 | { 155 | var mainCategory = authorsResourceParameters.MainCategory.Trim(); 156 | collection = collection.Where(a => a.MainCategory == mainCategory); 157 | } 158 | 159 | if (!string.IsNullOrWhiteSpace(authorsResourceParameters.SearchQuery)) 160 | { 161 | var searchQuery = authorsResourceParameters.SearchQuery.Trim(); 162 | collection = collection.Where(a => a.MainCategory.Contains(searchQuery) 163 | || a.FirstName.Contains(searchQuery) 164 | || a.LastName.Contains(searchQuery)); 165 | } 166 | 167 | if (!string.IsNullOrWhiteSpace(authorsResourceParameters.OrderBy)) 168 | { 169 | // get property mapping dictionary 170 | var authorPropertyMappingDictionary = _propertyMappingService 171 | .GetPropertyMapping(); 172 | 173 | collection = collection.ApplySort(authorsResourceParameters.OrderBy, 174 | authorPropertyMappingDictionary); 175 | } 176 | 177 | return await PagedList.CreateAsync(collection, 178 | authorsResourceParameters.PageNumber, 179 | authorsResourceParameters.PageSize); 180 | } 181 | 182 | public async Task> GetAuthorsAsync(IEnumerable authorIds) 183 | { 184 | if (authorIds == null) 185 | { 186 | throw new ArgumentNullException(nameof(authorIds)); 187 | } 188 | 189 | return await _context.Authors.Where(a => authorIds.Contains(a.Id)) 190 | .OrderBy(a => a.FirstName) 191 | .OrderBy(a => a.LastName) 192 | .ToListAsync(); 193 | } 194 | 195 | public void UpdateAuthor(Author author) 196 | { 197 | // no code in this implementation 198 | } 199 | 200 | public async Task SaveAsync() 201 | { 202 | return (await _context.SaveChangesAsync() >= 0); 203 | } 204 | 205 | 206 | } 207 | 208 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Services/ICourseLibraryRepository.cs: -------------------------------------------------------------------------------- 1 | using CourseLibrary.API.Entities; 2 | using CourseLibrary.API.Helpers; 3 | using CourseLibrary.API.ResourceParameters; 4 | 5 | namespace CourseLibrary.API.Services; 6 | 7 | public interface ICourseLibraryRepository 8 | { 9 | Task> GetCoursesAsync(Guid authorId); 10 | Task GetCourseAsync(Guid authorId, Guid courseId); 11 | void AddCourse(Guid authorId, Course course); 12 | void UpdateCourse(Course course); 13 | void DeleteCourse(Course course); 14 | Task> GetAuthorsAsync(); 15 | Task> GetAuthorsAsync(AuthorsResourceParameters authorsResourceParameters); 16 | Task GetAuthorAsync(Guid authorId); 17 | Task> GetAuthorsAsync(IEnumerable authorIds); 18 | void AddAuthor(Author author); 19 | void DeleteAuthor(Author author); 20 | void UpdateAuthor(Author author); 21 | Task AuthorExistsAsync(Guid authorId); 22 | Task SaveAsync(); 23 | } 24 | 25 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Services/IPropertyCheckerService.cs: -------------------------------------------------------------------------------- 1 | namespace CourseLibrary.API.Services; 2 | public interface IPropertyCheckerService 3 | { 4 | bool TypeHasProperties(string? fields); 5 | } 6 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Services/IPropertyMapping.cs: -------------------------------------------------------------------------------- 1 | namespace CourseLibrary.API.Services; 2 | 3 | public interface IPropertyMapping 4 | { 5 | } -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Services/IPropertyMappingService.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CourseLibrary.API.Services 3 | { 4 | public interface IPropertyMappingService 5 | { 6 | Dictionary GetPropertyMapping(); 7 | bool ValidMappingExistsFor(string fields); 8 | } 9 | } -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Services/PropertyCheckerService.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | 3 | namespace CourseLibrary.API.Services; 4 | 5 | public class PropertyCheckerService : IPropertyCheckerService 6 | { 7 | public bool TypeHasProperties(string? fields) 8 | { 9 | if (string.IsNullOrWhiteSpace(fields)) 10 | { 11 | return true; 12 | } 13 | 14 | // the field are separated by ",", so we split it. 15 | var fieldsAfterSplit = fields.Split(','); 16 | 17 | // check if the requested fields exist on source 18 | foreach (var field in fieldsAfterSplit) 19 | { 20 | // trim each field, as it might contain leading 21 | // or trailing spaces. Can't trim the var in foreach, 22 | // so use another var. 23 | var propertyName = field.Trim(); 24 | 25 | // use reflection to check if the property can be 26 | // found on T. 27 | var propertyInfo = typeof(T) 28 | .GetProperty(propertyName, 29 | BindingFlags.IgnoreCase | BindingFlags.Public | 30 | BindingFlags.Instance); 31 | 32 | // it can't be found, return false 33 | if (propertyInfo == null) 34 | { 35 | return false; 36 | } 37 | } 38 | 39 | // all checks out, return true 40 | return true; 41 | 42 | } 43 | } 44 | 45 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Services/PropertyMapping.cs: -------------------------------------------------------------------------------- 1 | namespace CourseLibrary.API.Services; 2 | 3 | public class PropertyMapping : IPropertyMapping 4 | { 5 | public Dictionary MappingDictionary 6 | { get; private set; } 7 | 8 | public PropertyMapping(Dictionary mappingDictionary) 9 | { 10 | MappingDictionary = mappingDictionary ?? 11 | throw new ArgumentNullException(nameof(mappingDictionary)); 12 | } 13 | } -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Services/PropertyMappingService.cs: -------------------------------------------------------------------------------- 1 | using CourseLibrary.API.Entities; 2 | using CourseLibrary.API.Models; 3 | 4 | namespace CourseLibrary.API.Services; 5 | 6 | public class PropertyMappingService : IPropertyMappingService 7 | { 8 | private readonly Dictionary _authorPropertyMapping = 9 | new(StringComparer.OrdinalIgnoreCase) 10 | { 11 | { "Id", new(new[] { "Id" }) }, 12 | { "MainCategory", new(new[] { "MainCategory" }) }, 13 | { "Age", new(new[] { "DateOfBirth" }, true) }, 14 | { "Name", new(new[] { "FirstName", "LastName" }) } 15 | }; 16 | 17 | private readonly IList _propertyMappings = 18 | new List(); 19 | 20 | public PropertyMappingService() 21 | { 22 | _propertyMappings.Add(new PropertyMapping( 23 | _authorPropertyMapping)); 24 | } 25 | 26 | public Dictionary GetPropertyMapping 27 | () 28 | { 29 | // get matching mapping 30 | var matchingMapping = _propertyMappings 31 | .OfType>(); 32 | 33 | if (matchingMapping.Count() == 1) 34 | { 35 | return matchingMapping.First().MappingDictionary; 36 | } 37 | 38 | throw new Exception($"Cannot find exact property mapping instance " + 39 | $"for <{typeof(TSource)},{typeof(TDestination)}"); 40 | } 41 | 42 | public bool ValidMappingExistsFor(string fields) 43 | { 44 | var propertyMapping = GetPropertyMapping(); 45 | 46 | if (string.IsNullOrWhiteSpace(fields)) 47 | { 48 | return true; 49 | } 50 | 51 | // the string is separated by ",", so we split it. 52 | var fieldsAfterSplit = fields.Split(','); 53 | 54 | // run through the fields clauses 55 | foreach (var field in fieldsAfterSplit) 56 | { 57 | // trim 58 | var trimmedField = field.Trim(); 59 | 60 | // remove everything after the first " " - if the fields 61 | // are coming from an orderBy string, this part must be 62 | // ignored 63 | var indexOfFirstSpace = trimmedField.IndexOf(" "); 64 | var propertyName = indexOfFirstSpace == -1 ? 65 | trimmedField : trimmedField.Remove(indexOfFirstSpace); 66 | 67 | // find the matching property 68 | if (!propertyMapping.ContainsKey(propertyName)) 69 | { 70 | return false; 71 | } 72 | } 73 | return true; 74 | } 75 | } 76 | 77 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/Services/PropertyMappingValue.cs: -------------------------------------------------------------------------------- 1 | namespace CourseLibrary.API.Services; 2 | 3 | public class PropertyMappingValue 4 | { 5 | public IEnumerable DestinationProperties { get; private set; } 6 | public bool Revert { get; private set; } 7 | 8 | public PropertyMappingValue(IEnumerable destinationProperties, 9 | bool revert = false) 10 | { 11 | DestinationProperties = destinationProperties 12 | ?? throw new ArgumentNullException(nameof(destinationProperties)); 13 | Revert = revert; 14 | } 15 | } -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/StartupHelperExtensions.cs: -------------------------------------------------------------------------------- 1 | using CourseLibrary.API.DbContexts; 2 | using CourseLibrary.API.Services; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.AspNetCore.Mvc.Formatters; 5 | using Microsoft.AspNetCore.Mvc.Infrastructure; 6 | using Microsoft.EntityFrameworkCore; 7 | using Newtonsoft.Json.Serialization; 8 | 9 | namespace CourseLibrary.API; 10 | 11 | internal static class StartupHelperExtensions 12 | { 13 | // Add services to the container 14 | public static WebApplication ConfigureServices( 15 | this WebApplicationBuilder builder) 16 | { 17 | builder.Services.AddControllers(configure => 18 | { 19 | configure.ReturnHttpNotAcceptable = true; 20 | configure.CacheProfiles.Add("240SecondsCacheProfile", 21 | new() { Duration = 240 }); 22 | }) 23 | .AddNewtonsoftJson(setupAction => 24 | { 25 | setupAction.SerializerSettings.ContractResolver = 26 | new CamelCasePropertyNamesContractResolver(); 27 | }) 28 | .AddXmlDataContractSerializerFormatters() 29 | .ConfigureApiBehaviorOptions(setupAction => 30 | { 31 | setupAction.InvalidModelStateResponseFactory = context => 32 | { 33 | // create a validation problem details object 34 | var problemDetailsFactory = context.HttpContext.RequestServices 35 | .GetRequiredService(); 36 | 37 | var validationProblemDetails = problemDetailsFactory 38 | .CreateValidationProblemDetails( 39 | context.HttpContext, 40 | context.ModelState); 41 | 42 | // add additional info not added by default 43 | validationProblemDetails.Detail = 44 | "See the errors field for details."; 45 | validationProblemDetails.Instance = 46 | context.HttpContext.Request.Path; 47 | 48 | // report invalid model state responses as validation issues 49 | validationProblemDetails.Type = 50 | "https://courselibrary.com/modelvalidationproblem"; 51 | validationProblemDetails.Status = 52 | StatusCodes.Status422UnprocessableEntity; 53 | validationProblemDetails.Title = 54 | "One or more validation errors occurred."; 55 | 56 | return new UnprocessableEntityObjectResult( 57 | validationProblemDetails) 58 | { 59 | ContentTypes = { "application/problem+json" } 60 | }; 61 | }; 62 | }); 63 | 64 | builder.Services.Configure(config => 65 | { 66 | var newtonsoftJsonOutputFormatter = config.OutputFormatters 67 | .OfType()?.FirstOrDefault(); 68 | 69 | if (newtonsoftJsonOutputFormatter != null) 70 | { 71 | newtonsoftJsonOutputFormatter.SupportedMediaTypes 72 | .Add("application/vnd.marvin.hateoas+json"); 73 | } 74 | }); 75 | 76 | builder.Services.AddTransient(); 78 | 79 | builder.Services.AddTransient(); 81 | 82 | builder.Services.AddScoped(); 84 | 85 | builder.Services.AddDbContext(options => 86 | { 87 | options.UseSqlite(@"Data Source=library.db"); 88 | }); 89 | 90 | builder.Services.AddAutoMapper( 91 | AppDomain.CurrentDomain.GetAssemblies()); 92 | 93 | builder.Services.AddResponseCaching(); 94 | 95 | builder.Services.AddHttpCacheHeaders( 96 | (expirationModelOptions) => 97 | { 98 | expirationModelOptions.MaxAge = 60; 99 | expirationModelOptions.CacheLocation = 100 | Marvin.Cache.Headers.CacheLocation.Private; 101 | }, 102 | (validationModelOptions) => 103 | { 104 | validationModelOptions.MustRevalidate = true; 105 | }); 106 | 107 | return builder.Build(); 108 | } 109 | 110 | // Configure the request/response pipelien 111 | public static WebApplication ConfigurePipeline(this WebApplication app) 112 | { 113 | if (app.Environment.IsDevelopment()) 114 | { 115 | app.UseDeveloperExceptionPage(); 116 | } 117 | else 118 | { 119 | app.UseExceptionHandler(appBuilder => 120 | { 121 | appBuilder.Run(async context => 122 | { 123 | context.Response.StatusCode = 500; 124 | await context.Response.WriteAsync( 125 | "An unexpected fault happened. Try again later."); 126 | }); 127 | }); 128 | } 129 | 130 | // app.UseResponseCaching(); 131 | 132 | app.UseHttpCacheHeaders(); 133 | 134 | app.UseAuthorization(); 135 | 136 | app.MapControllers(); 137 | 138 | return app; 139 | } 140 | 141 | public static async Task ResetDatabaseAsync(this WebApplication app) 142 | { 143 | using (var scope = app.Services.CreateScope()) 144 | { 145 | try 146 | { 147 | var context = scope.ServiceProvider.GetService(); 148 | if (context != null) 149 | { 150 | await context.Database.EnsureDeletedAsync(); 151 | await context.Database.MigrateAsync(); 152 | } 153 | } 154 | catch (Exception ex) 155 | { 156 | var logger = scope.ServiceProvider.GetRequiredService(); 157 | logger.LogError(ex, "An error occurred while migrating the database."); 158 | } 159 | } 160 | } 161 | } -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/ValidationAttributes/CourseTitleMustBeDifferentFromDescriptionAttribute.cs: -------------------------------------------------------------------------------- 1 | using CourseLibrary.API.Models; 2 | using System.ComponentModel.DataAnnotations; 3 | 4 | namespace CourseLibrary.API.ValidationAttributes; 5 | 6 | public class CourseTitleMustBeDifferentFromDescriptionAttribute 7 | : ValidationAttribute 8 | { 9 | public CourseTitleMustBeDifferentFromDescriptionAttribute() 10 | { 11 | } 12 | 13 | protected override ValidationResult? IsValid(object? value, 14 | ValidationContext validationContext) 15 | { 16 | if (validationContext.ObjectInstance is not 17 | CourseForManipulationDto course) 18 | { 19 | throw new Exception($"Attribute " + 20 | $"{nameof(CourseTitleMustBeDifferentFromDescriptionAttribute)} " + 21 | $"must be applied to a " + 22 | $"{nameof(CourseForManipulationDto)} or derived type."); 23 | } 24 | 25 | if (course.Title == course.Description) 26 | { 27 | return new ValidationResult( 28 | "The provided description should be different from the title.", 29 | new[] { nameof(CourseForManipulationDto) }); 30 | } 31 | 32 | return ValidationResult.Success; 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information", 7 | "Microsoft.EntityFrameworkCore.Database.Command": "Information" 8 | } 9 | }, 10 | "AllowedHosts": "*" 11 | } 12 | -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.API/library.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KevinDockx/AspNetCoreWebAPIDeepDive/e0a555bbdc12022ab815268623f0b7357d45f909/Finished sample/CourseLibrary.API/library.db -------------------------------------------------------------------------------- /Finished sample/CourseLibrary.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31612.314 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CourseLibrary.API", "CourseLibrary.API\CourseLibrary.API.csproj", "{17055168-5B45-4175-8BE2-08A2C45163E7}" 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 | {17055168-5B45-4175-8BE2-08A2C45163E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {17055168-5B45-4175-8BE2-08A2C45163E7}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {17055168-5B45-4175-8BE2-08A2C45163E7}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {17055168-5B45-4175-8BE2-08A2C45163E7}.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 = {5ABD71D6-C445-47F8-A7FC-28EECB366610} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 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 | # ASP.NET Core Web API Deep Dive 2 | Fully functioning sample and starter files for my ASP.NET Core Web API Deep Dive course, currently targeting .NET 8 and .NET 9. 3 | 4 | - The **main** branch targets .NET 8 and exactly matches the course. 5 | - The **net9** branch targets .NET 9. 6 | - The **latest-and-greatest** branch also targets .NET 9, and contains changes that were incorporated after recording. Most often these changes are language features that are relatively new and/or in preview, like primary constructors, switch expressions and so on. Most of these changes will probably make it into the main branch when course updates happen, but if you don't want to wait for that you can already check it out - enjoy :-) 7 | 8 | All my courses can be found at https://app.pluralsight.com/profile/author/kevin-dockx 9 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/Controllers/AuthorsController.cs: -------------------------------------------------------------------------------- 1 |  2 | using AutoMapper; 3 | using CourseLibrary.API.Models; 4 | using CourseLibrary.API.Services; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace CourseLibrary.API.Controllers; 8 | 9 | [ApiController] 10 | public class AuthorsController : ControllerBase 11 | { 12 | private readonly ICourseLibraryRepository _courseLibraryRepository; 13 | private readonly IMapper _mapper; 14 | 15 | public AuthorsController( 16 | ICourseLibraryRepository courseLibraryRepository, 17 | IMapper mapper) 18 | { 19 | _courseLibraryRepository = courseLibraryRepository ?? 20 | throw new ArgumentNullException(nameof(courseLibraryRepository)); 21 | _mapper = mapper ?? 22 | throw new ArgumentNullException(nameof(mapper)); 23 | } 24 | 25 | [HttpPost("api/author")] 26 | public async Task>> GetAuthors() 27 | { 28 | // get authors from repo 29 | var authorsFromRepo = await _courseLibraryRepository 30 | .GetAuthorsAsync(); 31 | 32 | // return them 33 | return Ok(_mapper.Map>(authorsFromRepo)); 34 | } 35 | 36 | [HttpGet("api/getauthor/{authorId}", Name = "GetAuthor")] 37 | public async Task> GetAuthor(Guid authorId) 38 | { 39 | // get author from repo 40 | var authorFromRepo = await _courseLibraryRepository.GetAuthorAsync(authorId); 41 | 42 | if (authorFromRepo == null) 43 | { 44 | return NotFound(); 45 | } 46 | 47 | // return author 48 | return Ok(_mapper.Map(authorFromRepo)); 49 | } 50 | 51 | [HttpPost("api/authors")] 52 | public async Task> CreateAuthor(AuthorDto author) 53 | { 54 | var authorEntity = _mapper.Map(author); 55 | 56 | _courseLibraryRepository.AddAuthor(authorEntity); 57 | await _courseLibraryRepository.SaveAsync(); 58 | 59 | var authorToReturn = _mapper.Map(authorEntity); 60 | 61 | return CreatedAtRoute("GetAuthor", 62 | new { authorId = authorToReturn.Id }, 63 | authorToReturn); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/Controllers/CoursesController.cs: -------------------------------------------------------------------------------- 1 |  2 | using AutoMapper; 3 | using CourseLibrary.API.Models; 4 | using CourseLibrary.API.Services; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace CourseLibrary.API.Controllers; 8 | 9 | [ApiController] 10 | [Route("api/author/{authorId}/courses")] 11 | public class CoursesController : ControllerBase 12 | { 13 | private readonly ICourseLibraryRepository _courseLibraryRepository; 14 | private readonly IMapper _mapper; 15 | 16 | public CoursesController(ICourseLibraryRepository courseLibraryRepository, 17 | IMapper mapper) 18 | { 19 | _courseLibraryRepository = courseLibraryRepository ?? 20 | throw new ArgumentNullException(nameof(courseLibraryRepository)); 21 | _mapper = mapper ?? 22 | throw new ArgumentNullException(nameof(mapper)); 23 | } 24 | 25 | [HttpGet] 26 | public async Task>> GetCoursesForAuthor(Guid authorId) 27 | { 28 | if (!await _courseLibraryRepository.AuthorExistsAsync(authorId)) 29 | { 30 | return NotFound(); 31 | } 32 | 33 | var coursesForAuthorFromRepo = await _courseLibraryRepository.GetCoursesAsync(authorId); 34 | return Ok(_mapper.Map>(coursesForAuthorFromRepo)); 35 | } 36 | 37 | [HttpGet("{courseId}")] 38 | public async Task> GetCourseForAuthor(Guid authorId, Guid courseId) 39 | { 40 | if (!await _courseLibraryRepository.AuthorExistsAsync(authorId)) 41 | { 42 | return NotFound(); 43 | } 44 | 45 | var courseForAuthorFromRepo = await _courseLibraryRepository.GetCourseAsync(authorId, courseId); 46 | 47 | if (courseForAuthorFromRepo == null) 48 | { 49 | return NotFound(); 50 | } 51 | return Ok(_mapper.Map(courseForAuthorFromRepo)); 52 | } 53 | 54 | 55 | [HttpPost] 56 | public async Task> CreateCourseForAuthor( 57 | Guid authorId, CourseForCreationDto course) 58 | { 59 | if (!await _courseLibraryRepository.AuthorExistsAsync(authorId)) 60 | { 61 | return NotFound(); 62 | } 63 | 64 | var courseEntity = _mapper.Map(course); 65 | _courseLibraryRepository.AddCourse(authorId, courseEntity); 66 | await _courseLibraryRepository.SaveAsync(); 67 | 68 | var courseToReturn = _mapper.Map(courseEntity); 69 | return Ok(courseToReturn); 70 | } 71 | 72 | 73 | [HttpPut("{courseId}")] 74 | public async Task UpdateCourseForAuthor(Guid authorId, 75 | Guid courseId, 76 | CourseDto course) 77 | { 78 | if (!await _courseLibraryRepository.AuthorExistsAsync(authorId)) 79 | { 80 | return NotFound(); 81 | } 82 | 83 | var courseForAuthorFromRepo = await _courseLibraryRepository.GetCourseAsync(authorId, courseId); 84 | 85 | if (courseForAuthorFromRepo == null) 86 | { 87 | return NotFound(); 88 | } 89 | 90 | _mapper.Map(course, courseForAuthorFromRepo); 91 | 92 | _courseLibraryRepository.UpdateCourse(courseForAuthorFromRepo); 93 | 94 | await _courseLibraryRepository.SaveAsync(); 95 | return NoContent(); 96 | } 97 | 98 | [HttpDelete("{courseId}")] 99 | public async Task DeleteCourseForAuthor(Guid authorId, Guid courseId) 100 | { 101 | if (!await _courseLibraryRepository.AuthorExistsAsync(authorId)) 102 | { 103 | return NotFound(); 104 | } 105 | 106 | var courseForAuthorFromRepo = await _courseLibraryRepository.GetCourseAsync(authorId, courseId); 107 | 108 | if (courseForAuthorFromRepo == null) 109 | { 110 | return NotFound(); 111 | } 112 | 113 | _courseLibraryRepository.DeleteCourse(courseForAuthorFromRepo); 114 | await _courseLibraryRepository.SaveAsync(); 115 | 116 | return NoContent(); 117 | } 118 | 119 | } -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/CourseLibrary.API.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | true 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | all 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/DbContexts/CourseLibraryContext.cs: -------------------------------------------------------------------------------- 1 | using CourseLibrary.API.Entities; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 4 | 5 | namespace CourseLibrary.API.DbContexts; 6 | 7 | public class CourseLibraryContext : DbContext 8 | { 9 | public CourseLibraryContext(DbContextOptions options) 10 | : base(options) 11 | { 12 | } 13 | 14 | // base DbContext constructor ensures that Books and Authors are not null after 15 | // having been constructed. Compiler warning ("uninitialized non-nullable property") 16 | // can safely be ignored with the "null-forgiving operator" (= null!) 17 | 18 | public DbSet Authors { get; set; } = null!; 19 | public DbSet Courses { get; set; } = null!; 20 | 21 | protected override void OnModelCreating(ModelBuilder modelBuilder) 22 | { 23 | // seed the database with dummy data 24 | modelBuilder.Entity().HasData( 25 | new Author("Berry", "Griffin Beak Eldritch", "Ships") 26 | { 27 | Id = Guid.Parse("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 28 | DateOfBirth = new DateTime(1980, 7, 23) 29 | }, 30 | new Author("Nancy", "Swashbuckler Rye", "Rum") 31 | { 32 | Id = Guid.Parse("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 33 | DateOfBirth = new DateTime(1978, 5, 21) 34 | }, 35 | new Author("Eli", "Ivory Bones Sweet", "Singing") 36 | { 37 | Id = Guid.Parse("2902b665-1190-4c70-9915-b9c2d7680450"), 38 | DateOfBirth = new DateTime(1957, 12, 16) 39 | }, 40 | new Author("Arnold", "The Unseen Stafford", "Singing") 41 | { 42 | Id = Guid.Parse("102b566b-ba1f-404c-b2df-e2cde39ade09"), 43 | DateOfBirth = new DateTime(1957, 3, 6) 44 | }, 45 | new Author("Seabury", "Toxic Reyson", "Maps") 46 | { 47 | Id = Guid.Parse("5b3621c0-7b12-4e80-9c8b-3398cba7ee05"), 48 | DateOfBirth = new DateTime(1956, 11, 23) 49 | }, 50 | new Author("Rutherford", "Fearless Cloven", "General debauchery") 51 | { 52 | Id = Guid.Parse("2aadd2df-7caf-45ab-9355-7f6332985a87"), 53 | DateOfBirth = new DateTime(1981, 4, 5) 54 | }, 55 | new Author("Atherton", "Crow Ridley", "Rum") 56 | { 57 | Id = Guid.Parse("2ee49fe3-edf2-4f91-8409-3eb25ce6ca51"), 58 | DateOfBirth = new DateTime(1982, 10, 11) 59 | } 60 | ); 61 | 62 | modelBuilder.Entity().HasData( 63 | new Course("Commandeering a Ship Without Getting Caught") 64 | { 65 | Id = Guid.Parse("5b1c2b4d-48c7-402a-80c3-cc796ad49c6b"), 66 | AuthorId = Guid.Parse("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 67 | Description = "Commandeering a ship in rough waters isn't easy. Commandeering it without getting caught is even harder. In this course you'll learn how to sail away and avoid those pesky musketeers." 68 | }, 69 | new Course("Overthrowing Mutiny") 70 | { 71 | Id = Guid.Parse("d8663e5e-7494-4f81-8739-6e0de1bea7ee"), 72 | AuthorId = Guid.Parse("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 73 | Description = "In this course, the author provides tips to avoid, or, if needed, overthrow pirate mutiny." 74 | }, 75 | new Course("Avoiding Brawls While Drinking as Much Rum as You Desire") 76 | { 77 | Id = Guid.Parse("d173e20d-159e-4127-9ce9-b0ac2564ad97"), 78 | AuthorId = Guid.Parse("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 79 | Description = "Every good pirate loves rum, but it also has a tendency to get you into trouble. In this course you'll learn how to avoid that. This new exclusive edition includes an additional chapter on how to run fast without falling while drunk." 80 | }, 81 | new Course("Singalong Pirate Hits") 82 | { 83 | Id = Guid.Parse("40ff5488-fdab-45b5-bc3a-14302d59869a"), 84 | AuthorId = Guid.Parse("2902b665-1190-4c70-9915-b9c2d7680450"), 85 | Description = "In this course you'll learn how to sing all-time favourite pirate songs without sounding like you actually know the words or how to hold a note." 86 | } 87 | ); 88 | 89 | // fix to allow sorting on DateTimeOffset when using Sqlite, based on 90 | // https://blog.dangl.me/archive/handling-datetimeoffset-in-sqlite-with-entity-framework-core/ 91 | if (Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite") 92 | { 93 | // Sqlite does not have proper support for DateTimeOffset via Entity Framework Core, see the limitations 94 | // here: https://docs.microsoft.com/en-us/ef/core/providers/sqlite/limitations#query-limitations 95 | // To work around this, when the Sqlite database provider is used, all model properties of type DateTimeOffset 96 | // use the DateTimeOffsetToBinaryConverter 97 | // Based on: https://github.com/aspnet/EntityFrameworkCore/issues/10784#issuecomment-415769754 98 | foreach (var entityType in modelBuilder.Model.GetEntityTypes()) 99 | { 100 | var properties = entityType.ClrType.GetProperties() 101 | .Where(p => p.PropertyType == typeof(DateTimeOffset) 102 | || p.PropertyType == typeof(DateTimeOffset?)); 103 | foreach (var property in properties) 104 | { 105 | modelBuilder.Entity(entityType.Name) 106 | .Property(property.Name) 107 | .HasConversion(new DateTimeOffsetToBinaryConverter()); 108 | } 109 | } 110 | } 111 | 112 | base.OnModelCreating(modelBuilder); 113 | } 114 | } 115 | 116 | 117 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/Entities/Author.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace CourseLibrary.API.Entities; 4 | 5 | public class Author 6 | { 7 | [Key] 8 | public Guid Id { get; set; } 9 | 10 | [Required] 11 | [MaxLength(50)] 12 | public string FirstName { get; set; } 13 | 14 | [Required] 15 | [MaxLength(50)] 16 | public string LastName { get; set; } 17 | 18 | [Required] 19 | public DateTimeOffset DateOfBirth { get; set; } 20 | 21 | [Required] 22 | [MaxLength(50)] 23 | public string MainCategory { get; set; } 24 | 25 | public ICollection Courses { get; set; } 26 | = new List(); 27 | 28 | public Author(string firstName, string lastName, string mainCategory) 29 | { 30 | FirstName = firstName; 31 | LastName = lastName; 32 | MainCategory = mainCategory; 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/Entities/Course.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | 4 | namespace CourseLibrary.API.Entities; 5 | 6 | public class Course 7 | { 8 | [Key] 9 | public Guid Id { get; set; } 10 | 11 | [Required] 12 | [MaxLength(100)] 13 | public string Title { get; set; } 14 | 15 | [MaxLength(1500)] 16 | public string? Description { get; set; } 17 | 18 | [ForeignKey("AuthorId")] 19 | public Author Author { get; set; } = null!; 20 | 21 | public Guid AuthorId { get; set; } 22 | 23 | public Course(string title) 24 | { 25 | Title = title; 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/Helpers/DateTimeOffsetExtensions.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CourseLibrary.API.Helpers; 3 | public static class DateTimeOffsetExtensions 4 | { 5 | public static int GetCurrentAge(this DateTimeOffset dateTimeOffset) 6 | { 7 | var currentDate = DateTime.UtcNow; 8 | int age = currentDate.Year - dateTimeOffset.Year; 9 | 10 | if (currentDate < dateTimeOffset.AddYears(age)) 11 | { 12 | age--; 13 | } 14 | 15 | return age; 16 | } 17 | } 18 | 19 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/Migrations/CourseLibraryContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using CourseLibrary.API.DbContexts; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | 8 | #nullable disable 9 | 10 | namespace CourseLibrary.API.Migrations 11 | { 12 | [DbContext(typeof(CourseLibraryContext))] 13 | partial class CourseLibraryContextModelSnapshot : ModelSnapshot 14 | { 15 | protected override void BuildModel(ModelBuilder modelBuilder) 16 | { 17 | #pragma warning disable 612, 618 18 | modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); 19 | 20 | modelBuilder.Entity("CourseLibrary.API.Entities.Author", b => 21 | { 22 | b.Property("Id") 23 | .ValueGeneratedOnAdd() 24 | .HasColumnType("TEXT"); 25 | 26 | b.Property("DateOfBirth") 27 | .HasColumnType("INTEGER"); 28 | 29 | b.Property("FirstName") 30 | .IsRequired() 31 | .HasMaxLength(50) 32 | .HasColumnType("TEXT"); 33 | 34 | b.Property("LastName") 35 | .IsRequired() 36 | .HasMaxLength(50) 37 | .HasColumnType("TEXT"); 38 | 39 | b.Property("MainCategory") 40 | .IsRequired() 41 | .HasMaxLength(50) 42 | .HasColumnType("TEXT"); 43 | 44 | b.HasKey("Id"); 45 | 46 | b.ToTable("Authors"); 47 | 48 | b.HasData( 49 | new 50 | { 51 | Id = new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 52 | DateOfBirth = 1279360106496000120L, 53 | FirstName = "Berry", 54 | LastName = "Griffin Beak Eldritch", 55 | MainCategory = "Ships" 56 | }, 57 | new 58 | { 59 | Id = new Guid("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 60 | DateOfBirth = 1277955145728000120L, 61 | FirstName = "Nancy", 62 | LastName = "Swashbuckler Rye", 63 | MainCategory = "Rum" 64 | }, 65 | new 66 | { 67 | Id = new Guid("2902b665-1190-4c70-9915-b9c2d7680450"), 68 | DateOfBirth = 1264753115136000060L, 69 | FirstName = "Eli", 70 | LastName = "Ivory Bones Sweet", 71 | MainCategory = "Singing" 72 | }, 73 | new 74 | { 75 | Id = new Guid("102b566b-ba1f-404c-b2df-e2cde39ade09"), 76 | DateOfBirth = 1264248815616000060L, 77 | FirstName = "Arnold", 78 | LastName = "The Unseen Stafford", 79 | MainCategory = "Singing" 80 | }, 81 | new 82 | { 83 | Id = new Guid("5b3621c0-7b12-4e80-9c8b-3398cba7ee05"), 84 | DateOfBirth = 1264066560000000060L, 85 | FirstName = "Seabury", 86 | LastName = "Toxic Reyson", 87 | MainCategory = "Maps" 88 | }, 89 | new 90 | { 91 | Id = new Guid("2aadd2df-7caf-45ab-9355-7f6332985a87"), 92 | DateOfBirth = 1279813091328000120L, 93 | FirstName = "Rutherford", 94 | LastName = "Fearless Cloven", 95 | MainCategory = "General debauchery" 96 | }, 97 | new 98 | { 99 | Id = new Guid("2ee49fe3-edf2-4f91-8409-3eb25ce6ca51"), 100 | DateOfBirth = 1280793378816000120L, 101 | FirstName = "Atherton", 102 | LastName = "Crow Ridley", 103 | MainCategory = "Rum" 104 | }); 105 | }); 106 | 107 | modelBuilder.Entity("CourseLibrary.API.Entities.Course", b => 108 | { 109 | b.Property("Id") 110 | .ValueGeneratedOnAdd() 111 | .HasColumnType("TEXT"); 112 | 113 | b.Property("AuthorId") 114 | .HasColumnType("TEXT"); 115 | 116 | b.Property("Description") 117 | .HasMaxLength(1500) 118 | .HasColumnType("TEXT"); 119 | 120 | b.Property("Title") 121 | .IsRequired() 122 | .HasMaxLength(100) 123 | .HasColumnType("TEXT"); 124 | 125 | b.HasKey("Id"); 126 | 127 | b.HasIndex("AuthorId"); 128 | 129 | b.ToTable("Courses"); 130 | 131 | b.HasData( 132 | new 133 | { 134 | Id = new Guid("5b1c2b4d-48c7-402a-80c3-cc796ad49c6b"), 135 | AuthorId = new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 136 | Description = "Commandeering a ship in rough waters isn't easy. Commandeering it without getting caught is even harder. In this course you'll learn how to sail away and avoid those pesky musketeers.", 137 | Title = "Commandeering a Ship Without Getting Caught" 138 | }, 139 | new 140 | { 141 | Id = new Guid("d8663e5e-7494-4f81-8739-6e0de1bea7ee"), 142 | AuthorId = new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 143 | Description = "In this course, the author provides tips to avoid, or, if needed, overthrow pirate mutiny.", 144 | Title = "Overthrowing Mutiny" 145 | }, 146 | new 147 | { 148 | Id = new Guid("d173e20d-159e-4127-9ce9-b0ac2564ad97"), 149 | AuthorId = new Guid("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 150 | Description = "Every good pirate loves rum, but it also has a tendency to get you into trouble. In this course you'll learn how to avoid that. This new exclusive edition includes an additional chapter on how to run fast without falling while drunk.", 151 | Title = "Avoiding Brawls While Drinking as Much Rum as You Desire" 152 | }, 153 | new 154 | { 155 | Id = new Guid("40ff5488-fdab-45b5-bc3a-14302d59869a"), 156 | AuthorId = new Guid("2902b665-1190-4c70-9915-b9c2d7680450"), 157 | Description = "In this course you'll learn how to sing all-time favourite pirate songs without sounding like you actually know the words or how to hold a note.", 158 | Title = "Singalong Pirate Hits" 159 | }); 160 | }); 161 | 162 | modelBuilder.Entity("CourseLibrary.API.Entities.Course", b => 163 | { 164 | b.HasOne("CourseLibrary.API.Entities.Author", "Author") 165 | .WithMany("Courses") 166 | .HasForeignKey("AuthorId") 167 | .OnDelete(DeleteBehavior.Cascade) 168 | .IsRequired(); 169 | 170 | b.Navigation("Author"); 171 | }); 172 | 173 | modelBuilder.Entity("CourseLibrary.API.Entities.Author", b => 174 | { 175 | b.Navigation("Courses"); 176 | }); 177 | #pragma warning restore 612, 618 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/Migrations/InitialMigration.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using CourseLibrary.API.DbContexts; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | 9 | #nullable disable 10 | 11 | namespace CourseLibrary.API.Migrations 12 | { 13 | [DbContext(typeof(CourseLibraryContext))] 14 | [Migration("20231130095259_InitialMigration")] 15 | partial class InitialMigration 16 | { 17 | /// 18 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 19 | { 20 | #pragma warning disable 612, 618 21 | modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); 22 | 23 | modelBuilder.Entity("CourseLibrary.API.Entities.Author", b => 24 | { 25 | b.Property("Id") 26 | .ValueGeneratedOnAdd() 27 | .HasColumnType("TEXT"); 28 | 29 | b.Property("DateOfBirth") 30 | .HasColumnType("INTEGER"); 31 | 32 | b.Property("FirstName") 33 | .IsRequired() 34 | .HasMaxLength(50) 35 | .HasColumnType("TEXT"); 36 | 37 | b.Property("LastName") 38 | .IsRequired() 39 | .HasMaxLength(50) 40 | .HasColumnType("TEXT"); 41 | 42 | b.Property("MainCategory") 43 | .IsRequired() 44 | .HasMaxLength(50) 45 | .HasColumnType("TEXT"); 46 | 47 | b.HasKey("Id"); 48 | 49 | b.ToTable("Authors"); 50 | 51 | b.HasData( 52 | new 53 | { 54 | Id = new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 55 | DateOfBirth = 1279360106496000120L, 56 | FirstName = "Berry", 57 | LastName = "Griffin Beak Eldritch", 58 | MainCategory = "Ships" 59 | }, 60 | new 61 | { 62 | Id = new Guid("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 63 | DateOfBirth = 1277955145728000120L, 64 | FirstName = "Nancy", 65 | LastName = "Swashbuckler Rye", 66 | MainCategory = "Rum" 67 | }, 68 | new 69 | { 70 | Id = new Guid("2902b665-1190-4c70-9915-b9c2d7680450"), 71 | DateOfBirth = 1264753115136000060L, 72 | FirstName = "Eli", 73 | LastName = "Ivory Bones Sweet", 74 | MainCategory = "Singing" 75 | }, 76 | new 77 | { 78 | Id = new Guid("102b566b-ba1f-404c-b2df-e2cde39ade09"), 79 | DateOfBirth = 1264248815616000060L, 80 | FirstName = "Arnold", 81 | LastName = "The Unseen Stafford", 82 | MainCategory = "Singing" 83 | }, 84 | new 85 | { 86 | Id = new Guid("5b3621c0-7b12-4e80-9c8b-3398cba7ee05"), 87 | DateOfBirth = 1264066560000000060L, 88 | FirstName = "Seabury", 89 | LastName = "Toxic Reyson", 90 | MainCategory = "Maps" 91 | }, 92 | new 93 | { 94 | Id = new Guid("2aadd2df-7caf-45ab-9355-7f6332985a87"), 95 | DateOfBirth = 1279813091328000120L, 96 | FirstName = "Rutherford", 97 | LastName = "Fearless Cloven", 98 | MainCategory = "General debauchery" 99 | }, 100 | new 101 | { 102 | Id = new Guid("2ee49fe3-edf2-4f91-8409-3eb25ce6ca51"), 103 | DateOfBirth = 1280793378816000120L, 104 | FirstName = "Atherton", 105 | LastName = "Crow Ridley", 106 | MainCategory = "Rum" 107 | }); 108 | }); 109 | 110 | modelBuilder.Entity("CourseLibrary.API.Entities.Course", b => 111 | { 112 | b.Property("Id") 113 | .ValueGeneratedOnAdd() 114 | .HasColumnType("TEXT"); 115 | 116 | b.Property("AuthorId") 117 | .HasColumnType("TEXT"); 118 | 119 | b.Property("Description") 120 | .HasMaxLength(1500) 121 | .HasColumnType("TEXT"); 122 | 123 | b.Property("Title") 124 | .IsRequired() 125 | .HasMaxLength(100) 126 | .HasColumnType("TEXT"); 127 | 128 | b.HasKey("Id"); 129 | 130 | b.HasIndex("AuthorId"); 131 | 132 | b.ToTable("Courses"); 133 | 134 | b.HasData( 135 | new 136 | { 137 | Id = new Guid("5b1c2b4d-48c7-402a-80c3-cc796ad49c6b"), 138 | AuthorId = new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 139 | Description = "Commandeering a ship in rough waters isn't easy. Commandeering it without getting caught is even harder. In this course you'll learn how to sail away and avoid those pesky musketeers.", 140 | Title = "Commandeering a Ship Without Getting Caught" 141 | }, 142 | new 143 | { 144 | Id = new Guid("d8663e5e-7494-4f81-8739-6e0de1bea7ee"), 145 | AuthorId = new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 146 | Description = "In this course, the author provides tips to avoid, or, if needed, overthrow pirate mutiny.", 147 | Title = "Overthrowing Mutiny" 148 | }, 149 | new 150 | { 151 | Id = new Guid("d173e20d-159e-4127-9ce9-b0ac2564ad97"), 152 | AuthorId = new Guid("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 153 | Description = "Every good pirate loves rum, but it also has a tendency to get you into trouble. In this course you'll learn how to avoid that. This new exclusive edition includes an additional chapter on how to run fast without falling while drunk.", 154 | Title = "Avoiding Brawls While Drinking as Much Rum as You Desire" 155 | }, 156 | new 157 | { 158 | Id = new Guid("40ff5488-fdab-45b5-bc3a-14302d59869a"), 159 | AuthorId = new Guid("2902b665-1190-4c70-9915-b9c2d7680450"), 160 | Description = "In this course you'll learn how to sing all-time favourite pirate songs without sounding like you actually know the words or how to hold a note.", 161 | Title = "Singalong Pirate Hits" 162 | }); 163 | }); 164 | 165 | modelBuilder.Entity("CourseLibrary.API.Entities.Course", b => 166 | { 167 | b.HasOne("CourseLibrary.API.Entities.Author", "Author") 168 | .WithMany("Courses") 169 | .HasForeignKey("AuthorId") 170 | .OnDelete(DeleteBehavior.Cascade) 171 | .IsRequired(); 172 | 173 | b.Navigation("Author"); 174 | }); 175 | 176 | modelBuilder.Entity("CourseLibrary.API.Entities.Author", b => 177 | { 178 | b.Navigation("Courses"); 179 | }); 180 | #pragma warning restore 612, 618 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/Migrations/InitialMigration.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | 4 | #nullable disable 5 | 6 | #pragma warning disable CA1814 // Prefer jagged arrays over multidimensional 7 | 8 | namespace CourseLibrary.API.Migrations 9 | { 10 | /// 11 | public partial class InitialMigration : Migration 12 | { 13 | /// 14 | protected override void Up(MigrationBuilder migrationBuilder) 15 | { 16 | migrationBuilder.CreateTable( 17 | name: "Authors", 18 | columns: table => new 19 | { 20 | Id = table.Column(type: "TEXT", nullable: false), 21 | FirstName = table.Column(type: "TEXT", maxLength: 50, nullable: false), 22 | LastName = table.Column(type: "TEXT", maxLength: 50, nullable: false), 23 | DateOfBirth = table.Column(type: "INTEGER", nullable: false), 24 | MainCategory = table.Column(type: "TEXT", maxLength: 50, nullable: false) 25 | }, 26 | constraints: table => 27 | { 28 | table.PrimaryKey("PK_Authors", x => x.Id); 29 | }); 30 | 31 | migrationBuilder.CreateTable( 32 | name: "Courses", 33 | columns: table => new 34 | { 35 | Id = table.Column(type: "TEXT", nullable: false), 36 | Title = table.Column(type: "TEXT", maxLength: 100, nullable: false), 37 | Description = table.Column(type: "TEXT", maxLength: 1500, nullable: true), 38 | AuthorId = table.Column(type: "TEXT", nullable: false) 39 | }, 40 | constraints: table => 41 | { 42 | table.PrimaryKey("PK_Courses", x => x.Id); 43 | table.ForeignKey( 44 | name: "FK_Courses_Authors_AuthorId", 45 | column: x => x.AuthorId, 46 | principalTable: "Authors", 47 | principalColumn: "Id", 48 | onDelete: ReferentialAction.Cascade); 49 | }); 50 | 51 | migrationBuilder.InsertData( 52 | table: "Authors", 53 | columns: new[] { "Id", "DateOfBirth", "FirstName", "LastName", "MainCategory" }, 54 | values: new object[,] 55 | { 56 | { new Guid("102b566b-ba1f-404c-b2df-e2cde39ade09"), 1264248815616000060L, "Arnold", "The Unseen Stafford", "Singing" }, 57 | { new Guid("2902b665-1190-4c70-9915-b9c2d7680450"), 1264753115136000060L, "Eli", "Ivory Bones Sweet", "Singing" }, 58 | { new Guid("2aadd2df-7caf-45ab-9355-7f6332985a87"), 1279813091328000120L, "Rutherford", "Fearless Cloven", "General debauchery" }, 59 | { new Guid("2ee49fe3-edf2-4f91-8409-3eb25ce6ca51"), 1280793378816000120L, "Atherton", "Crow Ridley", "Rum" }, 60 | { new Guid("5b3621c0-7b12-4e80-9c8b-3398cba7ee05"), 1264066560000000060L, "Seabury", "Toxic Reyson", "Maps" }, 61 | { new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), 1279360106496000120L, "Berry", "Griffin Beak Eldritch", "Ships" }, 62 | { new Guid("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), 1277955145728000120L, "Nancy", "Swashbuckler Rye", "Rum" } 63 | }); 64 | 65 | migrationBuilder.InsertData( 66 | table: "Courses", 67 | columns: new[] { "Id", "AuthorId", "Description", "Title" }, 68 | values: new object[,] 69 | { 70 | { new Guid("40ff5488-fdab-45b5-bc3a-14302d59869a"), new Guid("2902b665-1190-4c70-9915-b9c2d7680450"), "In this course you'll learn how to sing all-time favourite pirate songs without sounding like you actually know the words or how to hold a note.", "Singalong Pirate Hits" }, 71 | { new Guid("5b1c2b4d-48c7-402a-80c3-cc796ad49c6b"), new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), "Commandeering a ship in rough waters isn't easy. Commandeering it without getting caught is even harder. In this course you'll learn how to sail away and avoid those pesky musketeers.", "Commandeering a Ship Without Getting Caught" }, 72 | { new Guid("d173e20d-159e-4127-9ce9-b0ac2564ad97"), new Guid("da2fd609-d754-4feb-8acd-c4f9ff13ba96"), "Every good pirate loves rum, but it also has a tendency to get you into trouble. In this course you'll learn how to avoid that. This new exclusive edition includes an additional chapter on how to run fast without falling while drunk.", "Avoiding Brawls While Drinking as Much Rum as You Desire" }, 73 | { new Guid("d8663e5e-7494-4f81-8739-6e0de1bea7ee"), new Guid("d28888e9-2ba9-473a-a40f-e38cb54f9b35"), "In this course, the author provides tips to avoid, or, if needed, overthrow pirate mutiny.", "Overthrowing Mutiny" } 74 | }); 75 | 76 | migrationBuilder.CreateIndex( 77 | name: "IX_Courses_AuthorId", 78 | table: "Courses", 79 | column: "AuthorId"); 80 | } 81 | 82 | /// 83 | protected override void Down(MigrationBuilder migrationBuilder) 84 | { 85 | migrationBuilder.DropTable( 86 | name: "Courses"); 87 | 88 | migrationBuilder.DropTable( 89 | name: "Authors"); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/Models/AuthorDto.cs: -------------------------------------------------------------------------------- 1 | namespace CourseLibrary.API.Models; 2 | 3 | public class AuthorDto 4 | { 5 | public Guid Id { get; set; } 6 | public string Name { get; set; } = string.Empty; 7 | public int Age { get; set; } 8 | public string MainCategory { get; set; } = string.Empty; 9 | 10 | } 11 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/Models/CourseDto.cs: -------------------------------------------------------------------------------- 1 |  2 | namespace CourseLibrary.API.Models; 3 | public class CourseDto 4 | { 5 | public Guid Id { get; set; } 6 | 7 | public string Title { get; set; } = string.Empty; 8 | 9 | public string? Description { get; set; } 10 | } 11 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/Models/CourseForCreationDto.cs: -------------------------------------------------------------------------------- 1 | namespace CourseLibrary.API.Models; 2 | 3 | public class CourseForCreationDto 4 | { 5 | public string Title { get; set; } = string.Empty; 6 | public string Description { get; set; } = string.Empty; 7 | } 8 | 9 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/Profiles/AuthorProfile.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using CourseLibrary.API.Helpers; 3 | 4 | namespace CourseLibrary.API.Profiles; 5 | public class AuthorsProfile : Profile 6 | { 7 | public AuthorsProfile() 8 | { 9 | CreateMap() 10 | .ForMember(dest => dest.Name, opt => 11 | opt.MapFrom(src => $"{src.FirstName} {src.LastName}")) 12 | .ForMember(dest => dest.Age, opt => 13 | opt.MapFrom(src => src.DateOfBirth.GetCurrentAge())); 14 | 15 | } 16 | } 17 | 18 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/Profiles/CourseProfile.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | 3 | namespace CourseLibrary.API.Profiles; 4 | public class CoursesProfile : Profile 5 | { 6 | public CoursesProfile() 7 | { 8 | CreateMap(); 9 | CreateMap(); 10 | } 11 | } -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/Program.cs: -------------------------------------------------------------------------------- 1 | using CourseLibrary.API; 2 | 3 | var builder = WebApplication.CreateBuilder(args); 4 | 5 | var app = builder 6 | .ConfigureServices() 7 | .ConfigurePipeline(); 8 | 9 | // for demo purposes, delete the database & migrate on startup so 10 | // we can start with a clean slate 11 | await app.ResetDatabaseAsync(); 12 | 13 | // run the app 14 | app.Run(); 15 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:7499", 8 | "sslPort": 0 9 | } 10 | }, 11 | "profiles": { 12 | "CourseLibrary.API": { 13 | "commandName": "Project", 14 | "environmentVariables": { 15 | "ASPNETCORE_ENVIRONMENT": "Development" 16 | }, 17 | "applicationUrl": "http://localhost:5000", 18 | "dotnetRunMessages": true 19 | }, 20 | "IIS Express": { 21 | "commandName": "IISExpress", 22 | "launchBrowser": true, 23 | "launchUrl": "swagger", 24 | "environmentVariables": { 25 | "ASPNETCORE_ENVIRONMENT": "Development" 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/Services/CourseLibraryRepository.cs: -------------------------------------------------------------------------------- 1 | using CourseLibrary.API.DbContexts; 2 | using CourseLibrary.API.Entities; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace CourseLibrary.API.Services; 6 | 7 | public class CourseLibraryRepository : ICourseLibraryRepository 8 | { 9 | private readonly CourseLibraryContext _context; 10 | 11 | public CourseLibraryRepository(CourseLibraryContext context) 12 | { 13 | _context = context ?? throw new ArgumentNullException(nameof(context)); 14 | } 15 | 16 | public void AddCourse(Guid authorId, Course course) 17 | { 18 | if (authorId == Guid.Empty) 19 | { 20 | throw new ArgumentNullException(nameof(authorId)); 21 | } 22 | 23 | if (course == null) 24 | { 25 | throw new ArgumentNullException(nameof(course)); 26 | } 27 | 28 | // always set the AuthorId to the passed-in authorId 29 | course.AuthorId = authorId; 30 | _context.Courses.Add(course); 31 | } 32 | 33 | public void DeleteCourse(Course course) 34 | { 35 | _context.Courses.Remove(course); 36 | } 37 | 38 | public async Task GetCourseAsync(Guid authorId, Guid courseId) 39 | { 40 | if (authorId == Guid.Empty) 41 | { 42 | throw new ArgumentNullException(nameof(authorId)); 43 | } 44 | 45 | if (courseId == Guid.Empty) 46 | { 47 | throw new ArgumentNullException(nameof(courseId)); 48 | } 49 | 50 | #pragma warning disable CS8603 // Possible null reference return. 51 | return await _context.Courses 52 | .Where(c => c.AuthorId == authorId && c.Id == courseId).FirstOrDefaultAsync(); 53 | #pragma warning restore CS8603 // Possible null reference return. 54 | } 55 | 56 | public async Task> GetCoursesAsync(Guid authorId) 57 | { 58 | if (authorId == Guid.Empty) 59 | { 60 | throw new ArgumentNullException(nameof(authorId)); 61 | } 62 | 63 | return await _context.Courses 64 | .Where(c => c.AuthorId == authorId) 65 | .OrderBy(c => c.Title).ToListAsync(); 66 | } 67 | 68 | public void UpdateCourse(Course course) 69 | { 70 | // no code in this implementation 71 | } 72 | 73 | public void AddAuthor(Author author) 74 | { 75 | if (author == null) 76 | { 77 | throw new ArgumentNullException(nameof(author)); 78 | } 79 | 80 | // the repository fills the id (instead of using identity columns) 81 | author.Id = Guid.NewGuid(); 82 | 83 | foreach (var course in author.Courses) 84 | { 85 | course.Id = Guid.NewGuid(); 86 | } 87 | 88 | _context.Authors.Add(author); 89 | } 90 | 91 | public async Task AuthorExistsAsync(Guid authorId) 92 | { 93 | if (authorId == Guid.Empty) 94 | { 95 | throw new ArgumentNullException(nameof(authorId)); 96 | } 97 | 98 | return await _context.Authors.AnyAsync(a => a.Id == authorId); 99 | } 100 | 101 | public void DeleteAuthor(Author author) 102 | { 103 | if (author == null) 104 | { 105 | throw new ArgumentNullException(nameof(author)); 106 | } 107 | 108 | _context.Authors.Remove(author); 109 | } 110 | 111 | public async Task GetAuthorAsync(Guid authorId) 112 | { 113 | if (authorId == Guid.Empty) 114 | { 115 | throw new ArgumentNullException(nameof(authorId)); 116 | } 117 | 118 | #pragma warning disable CS8603 // Possible null reference return. 119 | return await _context.Authors.FirstOrDefaultAsync(a => a.Id == authorId); 120 | #pragma warning restore CS8603 // Possible null reference return. 121 | } 122 | 123 | 124 | public async Task> GetAuthorsAsync() 125 | { 126 | return await _context.Authors.ToListAsync(); 127 | } 128 | 129 | public async Task> GetAuthorsAsync(IEnumerable authorIds) 130 | { 131 | if (authorIds == null) 132 | { 133 | throw new ArgumentNullException(nameof(authorIds)); 134 | } 135 | 136 | return await _context.Authors.Where(a => authorIds.Contains(a.Id)) 137 | .OrderBy(a => a.FirstName) 138 | .OrderBy(a => a.LastName) 139 | .ToListAsync(); 140 | } 141 | 142 | public void UpdateAuthor(Author author) 143 | { 144 | // no code in this implementation 145 | } 146 | 147 | public async Task SaveAsync() 148 | { 149 | return (await _context.SaveChangesAsync() >= 0); 150 | } 151 | } 152 | 153 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/Services/ICourseLibraryRepository.cs: -------------------------------------------------------------------------------- 1 | using CourseLibrary.API.Entities; 2 | 3 | namespace CourseLibrary.API.Services; 4 | 5 | public interface ICourseLibraryRepository 6 | { 7 | Task> GetCoursesAsync(Guid authorId); 8 | Task GetCourseAsync(Guid authorId, Guid courseId); 9 | void AddCourse(Guid authorId, Course course); 10 | void UpdateCourse(Course course); 11 | void DeleteCourse(Course course); 12 | Task> GetAuthorsAsync(); 13 | Task GetAuthorAsync(Guid authorId); 14 | Task> GetAuthorsAsync(IEnumerable authorIds); 15 | void AddAuthor(Author author); 16 | void DeleteAuthor(Author author); 17 | void UpdateAuthor(Author author); 18 | Task AuthorExistsAsync(Guid authorId); 19 | Task SaveAsync(); 20 | } 21 | 22 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/StartupHelperExtensions.cs: -------------------------------------------------------------------------------- 1 | using CourseLibrary.API.DbContexts; 2 | using CourseLibrary.API.Services; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace CourseLibrary.API; 6 | 7 | internal static class StartupHelperExtensions 8 | { 9 | // Add services to the container 10 | public static WebApplication ConfigureServices(this WebApplicationBuilder builder) 11 | { 12 | builder.Services.AddControllers(); 13 | 14 | builder.Services.AddScoped(); 16 | 17 | builder.Services.AddDbContext(options => 18 | { 19 | options.UseSqlite(@"Data Source=library.db"); 20 | }); 21 | 22 | builder.Services.AddAutoMapper( 23 | AppDomain.CurrentDomain.GetAssemblies()); 24 | 25 | return builder.Build(); 26 | } 27 | 28 | // Configure the request/response pipelien 29 | public static WebApplication ConfigurePipeline(this WebApplication app) 30 | { 31 | if (app.Environment.IsDevelopment()) 32 | { 33 | app.UseDeveloperExceptionPage(); 34 | } 35 | 36 | app.UseAuthorization(); 37 | 38 | app.MapControllers(); 39 | 40 | return app; 41 | } 42 | 43 | public static async Task ResetDatabaseAsync(this WebApplication app) 44 | { 45 | using (var scope = app.Services.CreateScope()) 46 | { 47 | try 48 | { 49 | var context = scope.ServiceProvider.GetService(); 50 | if (context != null) 51 | { 52 | await context.Database.EnsureDeletedAsync(); 53 | await context.Database.MigrateAsync(); 54 | } 55 | } 56 | catch (Exception ex) 57 | { 58 | var logger = scope.ServiceProvider.GetRequiredService(); 59 | logger.LogError(ex, "An error occurred while migrating the database."); 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information", 7 | "Microsoft.EntityFrameworkCore.Database.Command": "Information" 8 | } 9 | }, 10 | "AllowedHosts": "*" 11 | } 12 | -------------------------------------------------------------------------------- /Starter files/CourseLibrary.API/library.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KevinDockx/AspNetCoreWebAPIDeepDive/e0a555bbdc12022ab815268623f0b7357d45f909/Starter files/CourseLibrary.API/library.db -------------------------------------------------------------------------------- /Starter files/CourseLibrary.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31612.314 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CourseLibrary.API", "CourseLibrary.API\CourseLibrary.API.csproj", "{17055168-5B45-4175-8BE2-08A2C45163E7}" 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 | {17055168-5B45-4175-8BE2-08A2C45163E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {17055168-5B45-4175-8BE2-08A2C45163E7}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {17055168-5B45-4175-8BE2-08A2C45163E7}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {17055168-5B45-4175-8BE2-08A2C45163E7}.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 = {5ABD71D6-C445-47F8-A7FC-28EECB366610} 24 | EndGlobalSection 25 | EndGlobal 26 | --------------------------------------------------------------------------------