├── .gitignore ├── LICENSE ├── README.md ├── client ├── .dockerignore ├── Dockerfile ├── StudentManagement.Client.sln ├── nginx.conf └── src │ └── WebUI │ ├── App.razor │ ├── Dtos │ └── StudentDto.cs │ ├── Layout │ ├── MainLayout.razor │ └── MainLayout.razor.css │ ├── Pages │ ├── Home.razor │ └── Students │ │ ├── Add.razor │ │ ├── Delete.razor │ │ ├── Edit.razor │ │ ├── Index.razor │ │ └── View.razor │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── Services │ ├── ApiUrls.cs │ └── StudentService.cs │ ├── WebUI.csproj │ ├── _Imports.razor │ └── wwwroot │ ├── css │ ├── app.css │ ├── bootstrap │ │ ├── bootstrap.min.css │ │ └── bootstrap.min.css.map │ ├── navbar.css │ └── style.css │ ├── images │ ├── add.svg │ ├── back.png │ ├── delete.svg │ ├── edit.svg │ ├── favicon.png │ ├── home.svg │ ├── icon-192.png │ ├── logout.png │ ├── navbar.svg │ ├── profile-photo.png │ ├── students.png │ └── view.png │ └── index.html ├── docker-compose.yml └── server ├── .dockerignore ├── Dockerfile ├── StudentManagement.Server.sln ├── src ├── Application │ ├── Application.csproj │ ├── Behaviours │ │ └── ValidationBehaviour.cs │ ├── Consts │ │ └── StudentConsts.cs │ ├── DependencyInjection.cs │ ├── RequestModels │ │ ├── CreateStudentRequest.cs │ │ └── UpdateStudentRequest.cs │ ├── ResponseModels │ │ ├── ErrorResponse.cs │ │ └── StudentResponse.cs │ └── Students │ │ ├── Commands │ │ ├── CreateStudentCommand.cs │ │ ├── CreateStudentCommandValidator.cs │ │ ├── DeleteStudentCommand.cs │ │ ├── DeleteStudentCommandValidator.cs │ │ ├── UpdateStudentCommand.cs │ │ └── UpdateStudentCommandValidator.cs │ │ ├── Handlers │ │ ├── CommandHandlers │ │ │ ├── CreateStudentHandler.cs │ │ │ ├── DeleteStudentHandler.cs │ │ │ └── UpdateStudentHandler.cs │ │ └── QueryHandlers │ │ │ ├── GetAllStudentsHandler.cs │ │ │ └── GetStudentByIdHandler.cs │ │ └── Queries │ │ ├── GetAllStudentsQuery.cs │ │ └── GetStudentByIdQuery.cs ├── Domain │ ├── Common │ │ ├── AuditableEntityBase.cs │ │ └── EntityBase.cs │ ├── Domain.csproj │ ├── Entities │ │ └── Student.cs │ ├── Interfaces │ │ └── IStudentRepository.cs │ └── ValueObjects │ │ └── Email.cs ├── Infrastructure │ ├── Configurations │ │ └── StudentConfiguration.cs │ ├── DbContexts │ │ └── StudentManagementDBContext.cs │ ├── DependencyInjection.cs │ ├── Infrastructure.csproj │ ├── Migrations │ │ ├── 20240521092028_v1.Designer.cs │ │ ├── 20240521092028_v1.cs │ │ └── StudentManagementDBContextModelSnapshot.cs │ └── Repositories │ │ └── StudentRepository.cs └── WebAPI │ ├── Controllers │ └── StudentsController.cs │ ├── Middleware │ └── GlobalExceptionHandler.cs │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── RequestExamples.http │ ├── WebAPI.csproj │ ├── appsettings.Development.json │ └── appsettings.json └── tests ├── Application.Tests.Integration ├── Application.Tests.Integration.csproj ├── BaseHandlerTest.cs ├── GlobalUsings.cs ├── IntegrationTestWebApplicationFactory.cs └── StudentHandlersTests │ └── StudentHandlersTests.cs ├── Application.Tests.Unit ├── Application.Tests.Unit.csproj ├── GlobalUsings.cs └── StudentHandlersTests │ ├── CreateStudentHandlerTests.cs │ ├── DeleteStudentHandlerTests.cs │ ├── GetAllStudentsHandlerTests.cs │ ├── GetStudentByIdHandlerTests.cs │ └── UpdateStudentHandlerTests.cs ├── Domain.Tests.Unit ├── Domain.Tests.Unit.csproj ├── GlobalUsings.cs └── ValueObjectsTests │ └── EmailTests.cs ├── WebAPI.Tests.Acceptance ├── BaseAcceptanceTest.cs ├── Drivers │ └── StudentManagementDriver.cs ├── Dtos │ └── StudentDto.cs ├── Features │ ├── StudentManagement.feature │ └── StudentManagement.feature.cs ├── GlobalUsings.cs ├── Hooks │ └── AcceptanceTestWebApplicationFactory.cs ├── StepDefinitions │ └── StudentManagementSteps.cs └── WebAPI.Tests.Acceptance.csproj └── WebAPI.Tests.Integration ├── BaseControllerTest.cs ├── ControllersTests └── StudentsControllerTests.cs ├── GlobalUsings.cs └── WebAPI.Tests.Integration.csproj /.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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Sara Rasoulian 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 | ## Student Management Application 2 | A sample full-stack application with a backend __ASP.NET Core Web API__ project and a frontend __Blazor Web Assembly__ project. 3 | 4 | ![Student-M](https://github.com/SaraRasoulian/DotNet-WebAPI-Blazor-Sample/assets/51083712/6a1cb0b0-6de0-4fd8-91fc-02a3ce6bba04) 5 | 6 | 7 | ## Technology Stack 8 | - ASP.NET Core Web API -v8 9 | - Entity Framework Core -v8 10 | - DDD (Domain-Driven Design) 11 | - TDD (Test-Driven Development) 12 | - BDD (Behavior-driven Development) 13 | - Clean Architecture 14 | - Clean Code 15 | - Repository Design Pattern 16 | - CQRS Design Pattern 17 | - Mediator Design pattern 18 | - PostgreSQL Database 19 | - Blazor WebAssembly -v8 20 | - Bootstrap -v5 21 | - Docker 22 | 23 | #### Nuget Packages 24 | - __xUnit__ for unit, integration and acceptance testing 25 | - __Testcontainers__ for integration and acceptance testing 26 | - __SpecFlow__ for acceptance testing 27 | - __NSubstitute__ for mocking 28 | - __Bogus__ for fake data generating 29 | - __MediatR__ for implementing mediator pattern 30 | - __FluentValidation__ for server-side validation 31 | - __Mapster__ for object mapping 32 | - __Blazor.Bootstrap__ for implementing user-interface 33 | 34 | 35 | This repository is intended for demonstrating best practices in software development. In real-world applications, these practices should be selected based on the specific requirements of each project. 36 | 37 | 38 | 39 | ## Run with Docker 40 | 41 | #### 1. Start with Docker compose 42 | 43 | Run the following command in project directory: 44 | 45 | ``` 46 | docker-compose up -d 47 | ``` 48 | 49 | Docker compose in this application includes 4 services: 50 | 51 | - __Web API application__ will be listening at `http://localhost:5000` 52 | 53 | - __Blazor webAssembly application__ will be listening at `http://localhost:8080` 54 | 55 | - __Postgres database__ will be listening at `http://localhost:5433` 56 | 57 | - __PgAdmin4 web interface__ will be listening at `http://localhost:8000` 58 | 59 | 60 | #### 2. Run the migrations 61 | 62 | Open `StudentManagement.Server.sln` file in visual studio, then in package manager console tab, run: 63 | 64 | ``` 65 | update-database 66 | ``` 67 | 68 | This command will generate the database schema in postgres container. 69 | 70 | 71 | --- 72 | 73 | Make sure Docker engine is running, before running integration and acceptance tests. 74 | 75 | -------------------------------------------------------------------------------- /client/.dockerignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/csharp 3 | 4 | ### Csharp ### 5 | ## Ignore Visual Studio temporary files, build results, and 6 | ## files generated by popular Visual Studio add-ons. 7 | 8 | # User-specific files 9 | *.suo 10 | *.user 11 | *.userosscache 12 | *.sln.docstates 13 | 14 | # User-specific files (MonoDevelop/Xamarin Studio) 15 | *.userprefs 16 | 17 | # Build results 18 | [Dd]ebug/ 19 | [Dd]ebugPublic/ 20 | [Rr]elease/ 21 | [Rr]eleases/ 22 | x64/ 23 | x86/ 24 | bld/ 25 | [Bb]in/ 26 | [Oo]bj/ 27 | [Ll]og/ 28 | 29 | # Visual Studio cache/options directory 30 | .vs/ 31 | # Uncomment if you have tasks that create the project's static files in wwwroot 32 | #wwwroot/ 33 | 34 | # MSTest test Results 35 | [Tt]est[Rr]esult*/ 36 | [Bb]uild[Ll]og.* 37 | 38 | # NUNIT 39 | *.VisualState.xml 40 | TestResult.xml 41 | 42 | # Build Results of an ATL Project 43 | [Dd]ebugPS/ 44 | [Rr]eleasePS/ 45 | dlldata.c 46 | 47 | # DNX 48 | project.lock.json 49 | project.fragment.lock.json 50 | artifacts/ 51 | Properties/launchSettings.json 52 | 53 | *_i.c 54 | *_p.c 55 | *_i.h 56 | *.ilk 57 | *.meta 58 | *.obj 59 | *.pch 60 | *.pdb 61 | *.pgc 62 | *.pgd 63 | *.rsp 64 | *.sbr 65 | *.tlb 66 | *.tli 67 | *.tlh 68 | *.tmp 69 | *.tmp_proj 70 | *.log 71 | *.vspscc 72 | *.vssscc 73 | .builds 74 | *.pidb 75 | *.svclog 76 | *.scc 77 | 78 | # Chutzpah Test files 79 | _Chutzpah* 80 | 81 | # Visual C++ cache files 82 | ipch/ 83 | *.aps 84 | *.ncb 85 | *.opendb 86 | *.opensdf 87 | *.sdf 88 | *.cachefile 89 | *.VC.db 90 | *.VC.VC.opendb 91 | 92 | # Visual Studio profiler 93 | *.psess 94 | *.vsp 95 | *.vspx 96 | *.sap 97 | 98 | # TFS 2012 Local Workspace 99 | $tf/ 100 | 101 | # Guidance Automation Toolkit 102 | *.gpState 103 | 104 | # ReSharper is a .NET coding add-in 105 | _ReSharper*/ 106 | *.[Rr]e[Ss]harper 107 | *.DotSettings.user 108 | 109 | # JustCode is a .NET coding add-in 110 | .JustCode 111 | 112 | # TeamCity is a build add-in 113 | _TeamCity* 114 | 115 | # DotCover is a Code Coverage Tool 116 | *.dotCover 117 | 118 | # Visual Studio code coverage results 119 | *.coverage 120 | *.coveragexml 121 | 122 | # NCrunch 123 | _NCrunch_* 124 | .*crunch*.local.xml 125 | nCrunchTemp_* 126 | 127 | # MightyMoose 128 | *.mm.* 129 | AutoTest.Net/ 130 | 131 | # Web workbench (sass) 132 | .sass-cache/ 133 | 134 | # Installshield output folder 135 | [Ee]xpress/ 136 | 137 | # DocProject is a documentation generator add-in 138 | DocProject/buildhelp/ 139 | DocProject/Help/*.HxT 140 | DocProject/Help/*.HxC 141 | DocProject/Help/*.hhc 142 | DocProject/Help/*.hhk 143 | DocProject/Help/*.hhp 144 | DocProject/Help/Html2 145 | DocProject/Help/html 146 | 147 | # Click-Once directory 148 | publish/ 149 | 150 | # Publish Web Output 151 | *.[Pp]ublish.xml 152 | *.azurePubxml 153 | # TODO: Comment the next line if you want to checkin your web deploy settings 154 | # but database connection strings (with potential passwords) will be unencrypted 155 | *.pubxml 156 | *.publishproj 157 | 158 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 159 | # checkin your Azure Web App publish settings, but sensitive information contained 160 | # in these scripts will be unencrypted 161 | PublishScripts/ 162 | 163 | # NuGet Packages 164 | *.nupkg 165 | # The packages folder can be ignored because of Package Restore 166 | **/packages/* 167 | # except build/, which is used as an MSBuild target. 168 | !**/packages/build/ 169 | # Uncomment if necessary however generally it will be regenerated when needed 170 | #!**/packages/repositories.config 171 | # NuGet v3's project.json files produces more ignoreable files 172 | *.nuget.props 173 | *.nuget.targets 174 | 175 | # Microsoft Azure Build Output 176 | csx/ 177 | *.build.csdef 178 | 179 | # Microsoft Azure Emulator 180 | ecf/ 181 | rcf/ 182 | 183 | # Windows Store app package directories and files 184 | AppPackages/ 185 | BundleArtifacts/ 186 | Package.StoreAssociation.xml 187 | _pkginfo.txt 188 | 189 | # Visual Studio cache files 190 | # files ending in .cache can be ignored 191 | *.[Cc]ache 192 | # but keep track of directories ending in .cache 193 | !*.[Cc]ache/ 194 | 195 | # Others 196 | ClientBin/ 197 | ~$* 198 | *~ 199 | *.dbmdl 200 | *.dbproj.schemaview 201 | *.jfm 202 | *.pfx 203 | *.publishsettings 204 | node_modules/ 205 | orleans.codegen.cs 206 | 207 | # Since there are multiple workflows, uncomment next line to ignore bower_components 208 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 209 | #bower_components/ 210 | 211 | # RIA/Silverlight projects 212 | Generated_Code/ 213 | 214 | # Backup & report files from converting an old project file 215 | # to a newer Visual Studio version. Backup files are not needed, 216 | # because we have git ;-) 217 | _UpgradeReport_Files/ 218 | Backup*/ 219 | UpgradeLog*.XML 220 | UpgradeLog*.htm 221 | 222 | # SQL Server files 223 | *.mdf 224 | *.ldf 225 | 226 | # Business Intelligence projects 227 | *.rdl.data 228 | *.bim.layout 229 | *.bim_*.settings 230 | 231 | # Microsoft Fakes 232 | FakesAssemblies/ 233 | 234 | # GhostDoc plugin setting file 235 | *.GhostDoc.xml 236 | 237 | # Node.js Tools for Visual Studio 238 | .ntvs_analysis.dat 239 | 240 | # Visual Studio 6 build log 241 | *.plg 242 | 243 | # Visual Studio 6 workspace options file 244 | *.opt 245 | 246 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 247 | *.vbw 248 | 249 | # Visual Studio LightSwitch build output 250 | **/*.HTMLClient/GeneratedArtifacts 251 | **/*.DesktopClient/GeneratedArtifacts 252 | **/*.DesktopClient/ModelManifest.xml 253 | **/*.Server/GeneratedArtifacts 254 | **/*.Server/ModelManifest.xml 255 | _Pvt_Extensions 256 | 257 | # Paket dependency manager 258 | .paket/paket.exe 259 | paket-files/ 260 | 261 | # FAKE - F# Make 262 | .fake/ 263 | 264 | # JetBrains Rider 265 | .idea/ 266 | *.sln.iml 267 | 268 | # CodeRush 269 | .cr/ 270 | 271 | # Python Tools for Visual Studio (PTVS) 272 | __pycache__/ 273 | *.pyc 274 | 275 | # Cake - Uncomment if you are using it 276 | # tools/ 277 | tools/Cake.CoreCLR 278 | .vscode 279 | tools 280 | .dotnet 281 | Dockerfile 282 | 283 | # .env file contains default environment variables for docker 284 | .env 285 | .git/ -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build 2 | WORKDIR /app 3 | COPY . ./ 4 | RUN dotnet publish -c Release -o output 5 | FROM nginx:latest 6 | WORKDIR /var/www/web 7 | COPY --from=build /app/output/wwwroot . 8 | COPY nginx.conf /etc/nginx/nginx.conf 9 | EXPOSE 8080 -------------------------------------------------------------------------------- /client/StudentManagement.Client.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.8.34601.278 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebUI", "src\WebUI\WebUI.csproj", "{3C17E96B-0D83-482B-9984-51A59476A4EE}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{79759013-F62E-4185-A4E0-341B8AD89F3A}" 9 | ProjectSection(SolutionItems) = preProject 10 | .dockerignore = .dockerignore 11 | Dockerfile = Dockerfile 12 | nginx.conf = nginx.conf 13 | EndProjectSection 14 | EndProject 15 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{EAE8E7F3-6FB3-4E58-8F35-E5689497CE42}" 16 | EndProject 17 | Global 18 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 19 | Debug|Any CPU = Debug|Any CPU 20 | Release|Any CPU = Release|Any CPU 21 | EndGlobalSection 22 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 23 | {3C17E96B-0D83-482B-9984-51A59476A4EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {3C17E96B-0D83-482B-9984-51A59476A4EE}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {3C17E96B-0D83-482B-9984-51A59476A4EE}.Release|Any CPU.ActiveCfg = Release|Any CPU 26 | {3C17E96B-0D83-482B-9984-51A59476A4EE}.Release|Any CPU.Build.0 = Release|Any CPU 27 | EndGlobalSection 28 | GlobalSection(SolutionProperties) = preSolution 29 | HideSolutionNode = FALSE 30 | EndGlobalSection 31 | GlobalSection(NestedProjects) = preSolution 32 | {3C17E96B-0D83-482B-9984-51A59476A4EE} = {EAE8E7F3-6FB3-4E58-8F35-E5689497CE42} 33 | EndGlobalSection 34 | GlobalSection(ExtensibilityGlobals) = postSolution 35 | SolutionGuid = {184A2DD8-AB3F-4E1A-A85E-B2AB99253868} 36 | EndGlobalSection 37 | EndGlobal 38 | -------------------------------------------------------------------------------- /client/nginx.conf: -------------------------------------------------------------------------------- 1 | events { } 2 | http { 3 | include mime.types; 4 | types { 5 | application/wasm wasm; 6 | } 7 | server { 8 | listen 8080; 9 | index index.html; 10 | location / { 11 | root /var/www/web; 12 | try_files $uri $uri/ /index.html =404; 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /client/src/WebUI/App.razor: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | Not found 8 | 9 |

Sorry, there's nothing at this address.

10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /client/src/WebUI/Dtos/StudentDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace WebUI.Dtos; 4 | 5 | public record StudentDto 6 | { 7 | public long Id { get; set; } 8 | 9 | [Required(ErrorMessage = "First name is required")] 10 | [RegularExpression(@"^[a-zA-Z0-9 ]+$", ErrorMessage = "Special characters are not accepted")] 11 | [MinLength(2, ErrorMessage = "First name must be at least 2 characters")] 12 | [MaxLength(50, ErrorMessage = "First name must be at most 50 characters")] 13 | public string FirstName { get; set; } = null!; 14 | 15 | [Required(ErrorMessage = "Last name is required")] 16 | [RegularExpression(@"^[a-zA-Z0-9 ]+$", ErrorMessage = "Special characters are not accepted")] 17 | [MinLength(2, ErrorMessage = "Last name must be at least 2 characters")] 18 | [MaxLength(50, ErrorMessage = "Last name must be at most 50 characters")] 19 | public string LastName { get; set; } = null!; 20 | 21 | [Required(ErrorMessage = "Email is required")] 22 | public Email Email { get; set; } = null!; 23 | 24 | [Required(ErrorMessage = "Birth date is required")] 25 | public DateOnly BirthDate { get; set; } 26 | 27 | [Required(ErrorMessage = "GitHub username is required")] 28 | [RegularExpression(@"^[a-zA-Z0-9\-]+$", ErrorMessage = "Special characters are not accepted")] 29 | [MinLength(3, ErrorMessage = "GitHub username must be at least 3 characters")] 30 | [MaxLength(40, ErrorMessage = "GitHub username must be at most 40 characters")] 31 | public string GitHubUsername { get; set; } = null!; 32 | } 33 | 34 | public record Email 35 | { 36 | public string Value { get; set; } = null!; 37 | } -------------------------------------------------------------------------------- /client/src/WebUI/Layout/MainLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 |
3 |
4 | 5 |
6 | 28 |
29 | 30 | 31 | 61 |
62 | 63 | 64 |
65 |
66 | 104 | 106 | 111 |
112 |
113 | 114 |
115 | @Body 116 |
117 |
118 | 119 | @code { 120 | #region Toggle Mini Sidebar 121 | private bool isSidebarMinimized = false; 122 | private string? MinSidebarCssClass => isSidebarMinimized ? "min-sidebar" : null; 123 | private void ToggleSidebar() 124 | { 125 | isSidebarMinimized = !isSidebarMinimized; 126 | } 127 | #endregion 128 | 129 | #region Toggle Mobile Navbar 130 | private bool isMobileNavbarVisible = false; 131 | private string? MobileNavbarCssClass => isMobileNavbarVisible ? "display" : null; 132 | private void ToggleMobileNavbar() 133 | { 134 | isMobileNavbarVisible = !isMobileNavbarVisible; 135 | } 136 | #endregion 137 | } -------------------------------------------------------------------------------- /client/src/WebUI/Layout/MainLayout.razor.css: -------------------------------------------------------------------------------- 1 | .page { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | main { 8 | flex: 1; 9 | } 10 | 11 | .sidebar { 12 | background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); 13 | } 14 | 15 | .top-row { 16 | background-color: #f7f7f7; 17 | border-bottom: 1px solid #d6d5d5; 18 | justify-content: flex-end; 19 | height: 3.5rem; 20 | display: flex; 21 | align-items: center; 22 | } 23 | 24 | .top-row ::deep a, .top-row ::deep .btn-link { 25 | white-space: nowrap; 26 | margin-left: 1.5rem; 27 | text-decoration: none; 28 | } 29 | 30 | .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { 31 | text-decoration: underline; 32 | } 33 | 34 | .top-row ::deep a:first-child { 35 | overflow: hidden; 36 | text-overflow: ellipsis; 37 | } 38 | 39 | @media (max-width: 640.98px) { 40 | .top-row { 41 | justify-content: space-between; 42 | } 43 | 44 | .top-row ::deep a, .top-row ::deep .btn-link { 45 | margin-left: 0; 46 | } 47 | } 48 | 49 | @media (min-width: 641px) { 50 | .page { 51 | flex-direction: row; 52 | } 53 | 54 | .sidebar { 55 | width: 250px; 56 | height: 100vh; 57 | position: sticky; 58 | top: 0; 59 | } 60 | 61 | .top-row { 62 | position: sticky; 63 | top: 0; 64 | z-index: 1; 65 | } 66 | 67 | .top-row.auth ::deep a:first-child { 68 | flex: 1; 69 | text-align: right; 70 | width: 0; 71 | } 72 | 73 | .top-row, article { 74 | padding-left: 2rem !important; 75 | padding-right: 1.5rem !important; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /client/src/WebUI/Pages/Home.razor: -------------------------------------------------------------------------------- 1 | @page "/" 2 | 3 | Home 4 |
5 |

Welcome Admin!

6 |
-------------------------------------------------------------------------------- /client/src/WebUI/Pages/Students/Add.razor: -------------------------------------------------------------------------------- 1 | @page "/students/add" 2 | @inject NavigationManager NavigationManager 3 | @inject StudentService studentService 4 | @inject IJSRuntime JsRuntime 5 | 6 | Add Student 7 |
8 |
9 | 11 |
12 | 13 |
14 |
15 |

Add Student

16 |
17 |
18 | 19 |
20 |
21 | 22 | 23 | 24 |
25 |
26 | 27 | 28 | 29 |
30 |
31 | 32 |
33 |
34 | 35 | 36 | 37 |
38 |
39 | 40 |
41 |
42 | 43 | 44 | 45 |
46 |
47 | 48 |
49 |
50 | 51 | 52 | 53 |
54 |
55 | 56 |
57 |
58 | 59 | 60 | 61 |
62 |
63 | 64 |
65 |
66 |
67 | 68 | Cancel 69 |
70 |
71 |
72 |
73 | 74 |
75 |
76 |
77 | 78 | @code { 79 | private DateOnly minBirthDate = DateOnly.FromDateTime(DateTime.Now).AddYears(-140); 80 | private DateOnly maxBirthDate = DateOnly.FromDateTime(DateTime.Now).AddYears(-18); 81 | private StudentDto newStudent = new StudentDto(); 82 | private List ErrorList = new List(); 83 | 84 | protected override async Task OnInitializedAsync() 85 | { 86 | newStudent.BirthDate = maxBirthDate; 87 | newStudent.Email = new Email(); 88 | } 89 | 90 | private async Task AddStudent() 91 | { 92 | var response = await studentService.Add(newStudent); 93 | if (response.IsSuccessStatusCode) 94 | { 95 | await JsRuntime.InvokeVoidAsync("alert", "Student added!"); 96 | NavigationManager.NavigateTo("/students"); 97 | } 98 | else 99 | { 100 | await JsRuntime.InvokeVoidAsync("alert", "Something went wrong!"); 101 | } 102 | } 103 | } -------------------------------------------------------------------------------- /client/src/WebUI/Pages/Students/Delete.razor: -------------------------------------------------------------------------------- 1 | @page "/students/delete/{studentId}" 2 | @inject NavigationManager NavigationManager 3 | @inject StudentService studentService 4 | @inject IJSRuntime JsRuntime 5 | 6 | Delete Student 7 | @if (student is not null) 8 | { 9 |
10 |
11 |
12 | 14 |
15 | 16 |
17 |
18 |

Delete Student

19 |
20 |
21 | 22 |
23 |
24 |
25 |
26 |
27 |
28 | First Name 29 |

@student.FirstName

30 |
31 |
32 |
33 | 34 |
35 |
36 |
37 | Last Name 38 |

@student.LastName

39 |
40 |
41 |
42 | 43 |
44 |
45 |
46 | Email 47 |

@student.Email.Value

48 |
49 |
50 |
51 | 52 |
53 |
54 |
55 | Birth Date 56 |

@student.BirthDate

57 |
58 |
59 |
60 | 61 | 62 |
63 |
64 |
65 | GitHub Username 66 |

@student.GitHubUsername

67 |
68 |
69 |
70 | 71 |
72 |
73 |
74 | 75 | Cancel 76 |
77 |
78 |
79 | 80 |
81 |
82 |
83 | 84 |
85 |
86 | } 87 | else 88 | { 89 | 90 | } 91 | 92 | @code { 93 | private StudentDto student; 94 | 95 | [Parameter] 96 | public string studentId { get; set; } 97 | 98 | protected override async Task OnInitializedAsync() 99 | { 100 | student = await studentService.Get(studentId); 101 | } 102 | 103 | private async void DeleteStudent() 104 | { 105 | var response = await studentService.Delete(studentId); 106 | if (response.IsSuccessStatusCode) 107 | { 108 | await JsRuntime.InvokeVoidAsync("alert", "Student deleted!"); 109 | NavigationManager.NavigateTo("/students"); 110 | } 111 | else 112 | { 113 | await JsRuntime.InvokeVoidAsync("alert", "Something went wrong!"); 114 | } 115 | } 116 | } -------------------------------------------------------------------------------- /client/src/WebUI/Pages/Students/Edit.razor: -------------------------------------------------------------------------------- 1 | @page "/students/edit/{studentId}" 2 | @inject NavigationManager NavigationManager 3 | @inject StudentService studentService 4 | @inject IJSRuntime JsRuntime 5 | 6 | Edit Student 7 | @if (student is not null) 8 | { 9 |
10 |
11 | 13 |
14 | 15 |
16 |
17 |

Edit Student

18 |
19 |
20 | 21 |
22 |
23 | 24 | 25 | 26 | 27 |
28 |
29 | 30 | 31 | 32 |
33 |
34 | 35 |
36 |
37 | 38 | 39 | 40 |
41 |
42 | 43 |
44 |
45 | 46 | 47 | 48 |
49 |
50 | 51 |
52 |
53 | 54 | 55 | 56 |
57 |
58 | 59 | 60 |
61 |
62 | 63 | 64 | 65 |
66 |
67 | 68 |
69 |
70 |
71 | 72 | Cancel 73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 | } 81 | else 82 | { 83 | 84 | } 85 | 86 | @code { 87 | private DateOnly minBirthDate = DateOnly.FromDateTime(DateTime.Now).AddYears(-140); 88 | private DateOnly maxBirthDate = DateOnly.FromDateTime(DateTime.Now).AddYears(-18); 89 | 90 | private StudentDto student; 91 | 92 | [Parameter] 93 | public string studentId { get; set; } 94 | 95 | protected override async Task OnInitializedAsync() 96 | { 97 | student = await studentService.Get(studentId); 98 | } 99 | 100 | private async void EditStudent() 101 | { 102 | var response = await studentService.Edit(studentId, student); 103 | if (response.IsSuccessStatusCode) 104 | { 105 | await JsRuntime.InvokeVoidAsync("alert", "Student edited!"); 106 | NavigationManager.NavigateTo("/students"); 107 | } 108 | else 109 | { 110 | await JsRuntime.InvokeVoidAsync("alert", "Something went wrong!"); 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /client/src/WebUI/Pages/Students/Index.razor: -------------------------------------------------------------------------------- 1 | @page "/students" 2 | @inject StudentService studentService 3 | 4 | Students 5 | @if (studentList is not null) 6 | { 7 |
8 |
9 |
10 |

Students

11 |
12 | @studentList.Count() 13 | total 14 |
15 |
16 |
17 | 18 | 19 | Add 20 | 21 |
22 |
23 | 24 |
25 | 26 |
27 | 28 |
29 |
30 |
31 | First Name 32 |
33 |
34 | Last Name 35 |
36 |
37 | Email 38 |
39 |
40 | BirthDate 41 |
42 |
43 |
44 | Controls 45 |
46 |
47 | 48 | 49 | @foreach (var item in studentList) 50 | { 51 |
52 |
53 |
54 | @item.FirstName 55 |
56 |
57 | @item.LastName 58 |
59 |
60 |

61 | @item.Email.Value 62 |

63 |
64 |
65 | @item.BirthDate 66 |
67 |
68 |
69 | 71 | 72 | 73 | 75 | 76 | 77 | 79 | 80 | 81 |
82 |
83 |
84 | } 85 |
86 |
87 | } 88 | else 89 | { 90 | 91 | } 92 | 93 | @code { 94 | private StudentDto[] studentList; 95 | 96 | protected override async Task OnInitializedAsync() 97 | { 98 | studentList = await studentService.GetAll(); 99 | } 100 | } -------------------------------------------------------------------------------- /client/src/WebUI/Pages/Students/View.razor: -------------------------------------------------------------------------------- 1 | @page "/students/view/{studentId}" 2 | @inject NavigationManager NavigationManager 3 | @inject StudentService studentService 4 | 5 | View Student 6 | @if (student is not null) 7 | { 8 |
9 |
10 |
11 | 13 |
14 | 15 |
16 |
17 |

View Student

18 | 19 |
20 | 22 | 23 | 24 | 25 | 26 | 27 |
28 |
29 |
30 | 31 |
32 |
33 |
34 |
35 |
36 |
37 | First Name 38 |

@student.FirstName

39 |
40 |
41 |
42 | 43 |
44 |
45 |
46 | Last Name 47 |

@student.LastName

48 |
49 |
50 |
51 | 52 |
53 |
54 |
55 | Email 56 |

@student.Email.Value

57 |
58 |
59 |
60 | 61 |
62 |
63 |
64 | Birth Date 65 |

@student.BirthDate

66 |
67 |
68 |
69 | 70 |
71 |
72 |
73 | GitHub Username 74 |

@student.GitHubUsername

75 |
76 |
77 |
78 | 79 |
80 |
81 |
82 | 83 |
84 |
85 | } 86 | else 87 | { 88 | 89 | } 90 | 91 | @code { 92 | private StudentDto student; 93 | 94 | [Parameter] 95 | public string studentId { get; set; } 96 | 97 | protected override async Task OnInitializedAsync() 98 | { 99 | student = await studentService.Get(studentId); 100 | } 101 | } -------------------------------------------------------------------------------- /client/src/WebUI/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Components.WebAssembly.Hosting; 2 | using Microsoft.AspNetCore.Components.Web; 3 | using WebUI.Services; 4 | using WebUI; 5 | 6 | var builder = WebAssemblyHostBuilder.CreateDefault(args); 7 | builder.RootComponents.Add("#app"); 8 | builder.RootComponents.Add("head::after"); 9 | 10 | builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); 11 | 12 | builder.Services.AddScoped(); 13 | builder.Services.AddBlazorBootstrap(); 14 | 15 | await builder.Build().RunAsync(); 16 | -------------------------------------------------------------------------------- /client/src/WebUI/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:7565", 8 | "sslPort": 44347 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 17 | "applicationUrl": "http://localhost:5089", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "https": { 23 | "commandName": "Project", 24 | "dotnetRunMessages": true, 25 | "launchBrowser": true, 26 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 27 | "applicationUrl": "https://localhost:7147;http://localhost:5089", 28 | "environmentVariables": { 29 | "ASPNETCORE_ENVIRONMENT": "Development" 30 | } 31 | }, 32 | "IIS Express": { 33 | "commandName": "IISExpress", 34 | "launchBrowser": true, 35 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 36 | "environmentVariables": { 37 | "ASPNETCORE_ENVIRONMENT": "Development" 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /client/src/WebUI/Services/ApiUrls.cs: -------------------------------------------------------------------------------- 1 | namespace WebUI.Services; 2 | 3 | public static class ApiUrls 4 | { 5 | // URL for API app running inside the Docker container 6 | public const string BaseURL = "http://localhost:5000"; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/WebUI/Services/StudentService.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Json; 2 | using WebUI.Dtos; 3 | 4 | namespace WebUI.Services; 5 | 6 | public class StudentService 7 | { 8 | private const string URL = ApiUrls.BaseURL + "/api/students"; 9 | private readonly HttpClient _httpClient; 10 | 11 | public StudentService(HttpClient httpClient) 12 | { 13 | _httpClient = httpClient; 14 | } 15 | 16 | public async Task GetAll() 17 | { 18 | return await _httpClient.GetFromJsonAsync(URL); 19 | } 20 | 21 | public async Task Get(string studentId) 22 | { 23 | return await _httpClient.GetFromJsonAsync($"{URL}/{studentId}"); 24 | } 25 | 26 | public async Task Add(StudentDto student) 27 | { 28 | return await _httpClient.PostAsJsonAsync(URL, student); 29 | } 30 | 31 | public async Task Edit(string studentId, StudentDto StudentDto) 32 | { 33 | return await _httpClient.PutAsJsonAsync($"{URL}/{studentId}", StudentDto); 34 | } 35 | 36 | public async Task Delete(string studentId) 37 | { 38 | return await _httpClient.DeleteAsync($"{URL}/{studentId}"); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/src/WebUI/WebUI.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /client/src/WebUI/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using Microsoft.AspNetCore.Components.Web.Virtualization 2 | @using Microsoft.AspNetCore.Components.WebAssembly.Http 3 | @using Microsoft.AspNetCore.Components.Routing 4 | @using Microsoft.AspNetCore.Components.Forms 5 | @using Microsoft.AspNetCore.Components.Web 6 | @using System.Net.Http.Json 7 | @using Microsoft.JSInterop 8 | @using BlazorBootstrap; 9 | @using System.Net.Http 10 | @using WebUI.Services; 11 | @using WebUI.Layout; 12 | @using WebUI.Dtos; 13 | @using WebUI -------------------------------------------------------------------------------- /client/src/WebUI/wwwroot/css/app.css: -------------------------------------------------------------------------------- 1 | .valid.modified:not([type=checkbox]) { 2 | outline: 1px solid #26b050; 3 | } 4 | 5 | .invalid { 6 | outline: 1px solid red; 7 | } 8 | 9 | .validation-message { 10 | color: red; 11 | } 12 | 13 | #blazor-error-ui { 14 | background: lightyellow; 15 | bottom: 0; 16 | box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); 17 | display: none; 18 | left: 0; 19 | padding: 0.6rem 1.25rem 0.7rem 1.25rem; 20 | position: fixed; 21 | width: 100%; 22 | z-index: 1000; 23 | } 24 | 25 | #blazor-error-ui .dismiss { 26 | cursor: pointer; 27 | position: absolute; 28 | right: 0.75rem; 29 | top: 0.5rem; 30 | } 31 | 32 | .blazor-error-boundary { 33 | background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; 34 | padding: 1rem 1rem 1rem 3.7rem; 35 | color: white; 36 | } 37 | 38 | .blazor-error-boundary::after { 39 | content: "An error has occurred." 40 | } 41 | 42 | .loading-progress { 43 | position: relative; 44 | display: block; 45 | width: 8rem; 46 | height: 8rem; 47 | margin: 20vh auto 1rem auto; 48 | } 49 | 50 | .loading-progress circle { 51 | fill: none; 52 | stroke: #e0e0e0; 53 | stroke-width: 0.6rem; 54 | transform-origin: 50% 50%; 55 | transform: rotate(-90deg); 56 | } 57 | 58 | .loading-progress circle:last-child { 59 | stroke: #1b6ec2; 60 | stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; 61 | transition: stroke-dasharray 0.05s ease-in-out; 62 | } 63 | 64 | .loading-progress-text { 65 | position: absolute; 66 | text-align: center; 67 | font-weight: bold; 68 | inset: calc(20vh + 3.25rem) 0 auto 0.2rem; 69 | } 70 | 71 | .loading-progress-text:after { 72 | content: var(--blazor-load-percentage-text, "Loading"); 73 | } 74 | 75 | code { 76 | color: #c02d76; 77 | } 78 | -------------------------------------------------------------------------------- /client/src/WebUI/wwwroot/css/navbar.css: -------------------------------------------------------------------------------- 1 | /******************** Header ********************/ 2 | .header { 3 | padding-right: 25px; 4 | background-color: var(--theme-color); 5 | height: 60px; 6 | } 7 | 8 | .header .nav-item { 9 | height: fit-content; 10 | } 11 | 12 | .top-navbar { 13 | float: right; 14 | } 15 | 16 | .profile-photo { 17 | width: 2rem; 18 | border-radius: 50%; 19 | margin-right: 5px; 20 | } 21 | 22 | .wraper { 23 | display: flex; 24 | align-items: center; 25 | justify-content: end; 26 | height: 60px; 27 | } 28 | 29 | .wraper-div { 30 | text-align: center; 31 | } 32 | 33 | .header .dropdown-menu { 34 | left: auto; 35 | right: 0; 36 | position: absolute; 37 | } 38 | 39 | .dropdown-menu li { 40 | padding: 3px 0; 41 | } 42 | 43 | .header .dropdown-menu .dropdown-icon { 44 | margin-right: 10px; 45 | } 46 | 47 | .header .dropdown-item:active { 48 | background-color: var(--active-gray); 49 | color: var(--text-color); 50 | } 51 | 52 | /******************** Sidebar ********************/ 53 | .sidebar { 54 | height: 100%; 55 | width: 250px; 56 | position: fixed; 57 | top: 0; 58 | left: 0; 59 | background-color: var(--theme-color); 60 | padding: 10px 15px; 61 | margin: 0; 62 | z-index: 1030; 63 | } 64 | 65 | .sidebar ul { 66 | list-style: none; 67 | padding: 0; 68 | } 69 | 70 | .sidebar li { 71 | margin-bottom: .5rem; 72 | } 73 | 74 | /* toggle-sidebar */ 75 | .toggle-sidebar-wrapper { 76 | display: flex; 77 | align-items: center; 78 | } 79 | 80 | .toggle-sidebar { 81 | text-align: center; 82 | justify-content: center; 83 | align-items: center; 84 | padding: 10px; 85 | height: 40px; 86 | width: 40px; 87 | border-radius: 50%; 88 | display: flex; 89 | cursor: pointer; 90 | } 91 | 92 | .toggle-sidebar:hover { 93 | background-color: var(--dark-gray); 94 | } 95 | 96 | .toggle-sidebar:active { 97 | background-color: var(--active-color); 98 | } 99 | 100 | .sidebar li.sidebar-title { 101 | margin-bottom: 30px; 102 | } 103 | 104 | .sidebar-title span { 105 | margin: 0 5px; 106 | } 107 | 108 | .sidebar-icon { 109 | width: 1rem; 110 | height: 1rem; 111 | margin: auto 0; 112 | } 113 | 114 | .nav-link-inner { 115 | display: inline-flex; 116 | padding: 8px 12px; 117 | } 118 | 119 | .nav-link-inner span { 120 | padding: 0 1rem; 121 | } 122 | 123 | .sidebar .nav-item:hover, 124 | .mobile-navbar .nav-item:hover { 125 | background-color: var(--dark-gray); 126 | border-top-right-radius: 50px; 127 | border-bottom-right-radius: 50px; 128 | width: -webkit-fill-available; 129 | width: -moz-available; 130 | margin-left: -2rem; 131 | padding-left: 2rem; 132 | } 133 | 134 | 135 | .sidebar .nav-item:active, 136 | .mobile-navbar .nav-item:active { 137 | background-color: var(--active-color); 138 | border-top-right-radius: 50px; 139 | border-bottom-right-radius: 50px; 140 | width: -webkit-fill-available; 141 | width: -moz-available; 142 | margin-left: -2rem; 143 | padding-left: 2rem; 144 | } 145 | 146 | /*** active item ***/ 147 | .sidebar .active, 148 | .sidebar .active:hover, 149 | .mobile-navbar .nav-item .active { 150 | background-color: var(--active-color); 151 | border-top-right-radius: 50px; 152 | border-bottom-right-radius: 50px; 153 | width: -webkit-fill-available; 154 | width: -moz-available; 155 | font-weight: 600; 156 | margin-left: -2rem; 157 | padding-left: 2rem; 158 | } 159 | .sidebar .nav-link .active { 160 | font-weight: 600; 161 | color: var(--text-color); 162 | } 163 | 164 | .nav-link { 165 | color: var(--text-color); 166 | padding: 0; 167 | display: flex; 168 | align-items: center; 169 | height: 40px; 170 | } 171 | 172 | .nav-link:hover { 173 | color: black; 174 | } 175 | 176 | .nav-link:focus { 177 | color: var(--text-color); 178 | } 179 | 180 | .desktop-navbar { 181 | display: block; 182 | } 183 | 184 | .mobile-navbar { 185 | display: none; 186 | } 187 | 188 | @media (max-width: 768px) { 189 | .desktop-navbar { 190 | display: none; 191 | } 192 | 193 | .mobile-navbar { 194 | display: block; 195 | padding-bottom: 65px; 196 | } 197 | 198 | .mobile-navbar .navbar-top { 199 | background-color: var(--theme-color); 200 | box-shadow: 0 0px 5px var(--bg-color); 201 | height: 55px; 202 | display: flex; 203 | align-items: center; 204 | width: 100%; 205 | position: fixed; 206 | padding: 0; 207 | margin: 0; 208 | z-index: 9; 209 | } 210 | 211 | .mobile-navbar .navbar-icon { 212 | margin: auto 29px; 213 | cursor: pointer; 214 | width: 1.3rem; 215 | } 216 | 217 | .mobile-navbar .navbar-content { 218 | height: 100vh; 219 | width: 70vw; 220 | position: fixed; 221 | z-index: 999; 222 | display: none; 223 | background-color: var(--theme-color); 224 | box-shadow: 0 0px 5px var(--gray); 225 | padding: 2.5rem 1.8rem; 226 | animation-duration: 0.2s; 227 | animation-timing-function: ease-in-out; 228 | animation-fill-mode: forwards; 229 | overflow-y: scroll; 230 | } 231 | 232 | .mobile-navbar .navbar-background { 233 | height: 100vh; 234 | width: 100vw; 235 | position: fixed; 236 | display: none; 237 | z-index: 99; 238 | opacity: 0.2; 239 | background-color: var(--gray); 240 | } 241 | 242 | .mobile-navbar .navbar-content.display { 243 | display: block; 244 | animation-name: slideIn; 245 | } 246 | 247 | @keyframes slideIn { 248 | 0% { 249 | transform: translateX(-100%); 250 | } 251 | 252 | 100% { 253 | transform: translateX(0); 254 | } 255 | } 256 | 257 | .mobile-navbar .navbar-background.display { 258 | display: block; 259 | } 260 | 261 | .mobile-navbar .nav-item { 262 | margin-bottom: 10px; 263 | } 264 | 265 | .mobile-navbar .line { 266 | margin: 0 0 8px 0; 267 | } 268 | 269 | .mobile-navbar .profile-container { 270 | display: block; 271 | padding: 0 1rem; 272 | } 273 | 274 | .mobile-navbar .profile-container span { 275 | padding: 5px; 276 | } 277 | } 278 | 279 | .noselect { 280 | -webkit-touch-callout: none; 281 | /* iOS Safari */ 282 | -webkit-user-select: none; 283 | /* Safari */ 284 | -khtml-user-select: none; 285 | /* Konqueror HTML */ 286 | -moz-user-select: none; 287 | /* Old versions of Firefox */ 288 | -ms-user-select: none; 289 | /* Internet Explorer/Edge */ 290 | user-select: none; 291 | /* Non-prefixed version, currently 292 | supported by Chrome, Edge, Opera and Firefox */ 293 | } 294 | 295 | 296 | /******************** min-sidebar ********************/ 297 | .min-sidebar .content-container { 298 | margin-left: 90px; 299 | } 300 | 301 | .min-sidebar .sidebar { 302 | width: 70px; 303 | display: flex; 304 | justify-content: center; 305 | align-content: center; 306 | } 307 | 308 | .min-sidebar .sidebar span { 309 | display: none; 310 | } 311 | 312 | .min-sidebar .sidebar li { 313 | justify-content: center; 314 | width: 40px; 315 | } 316 | 317 | .min-sidebar .sidebar .nav-item:hover { 318 | border-radius: 50%; 319 | margin-left: 0; 320 | padding-left: 0; 321 | } 322 | 323 | 324 | .min-sidebar .sidebar .nav-item:active { 325 | border-radius: 50%; 326 | margin-left: 0; 327 | padding-left: 0; 328 | } 329 | 330 | /*** active item in min sidebar ***/ 331 | .min-sidebar .sidebar .active, 332 | .min-sidebar .sidebar .active:hover { 333 | border-radius: 50%; 334 | margin-left: 0; 335 | padding-left: 0; 336 | } 337 | -------------------------------------------------------------------------------- /client/src/WebUI/wwwroot/css/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /******************** Colors ********************/ 3 | --bg-color: #1C2128; 4 | --theme-color: #22272E; 5 | --text-color: #f2f5fc; 6 | --active-color: #444C56; 7 | --gray: #68717c; 8 | --btn-cancel-color: #393E46; 9 | --light-gray: #DEDCDC; 10 | --dark-gray: #333a42; 11 | --active-gray: #e2e2e6; 12 | --green: #29BC9C; 13 | --dark-green: #26A88C; 14 | --red: #DB575A; 15 | --dark-red: #CB5153; 16 | /* Bootstrap colors */ 17 | --bs-body-color: #f2f5fc; 18 | --bs-body-bg: #22272E; 19 | --bs-tertiary-bg: #333a42; 20 | } 21 | 22 | /******************** Base styles ********************/ 23 | html { 24 | scroll-behavior: smooth; 25 | } 26 | 27 | span, 28 | p, 29 | label, 30 | h1, 31 | h2, 32 | h3, 33 | h4, 34 | h5, 35 | h6 { 36 | color: var(--text-color); 37 | padding: 0; 38 | margin: 0; 39 | } 40 | 41 | body { 42 | background-color: var(--bg-color); 43 | margin: 0; 44 | padding: 0; 45 | box-sizing: border-box; 46 | position: relative; 47 | overflow-x: hidden; 48 | overflow-y: scroll; 49 | line-height: 1.5; 50 | font-weight: 400; 51 | } 52 | 53 | .bold { 54 | font-weight: 600; 55 | } 56 | 57 | .secondary-text { 58 | color: var(--gray); 59 | font-size: 15px; 60 | } 61 | 62 | /******************** Content ********************/ 63 | .content-container { 64 | background-color: var(--theme-color); 65 | padding: 20px; 66 | margin: 80px 20px 0 270px; 67 | height: fit-content; 68 | border-radius: 12px; 69 | } 70 | 71 | .content { 72 | margin: 15px 0 20px 0; 73 | } 74 | 75 | .error-list { 76 | list-style: disc; 77 | margin: 0; 78 | } 79 | 80 | @media (max-width: 768px) { 81 | .content-container { 82 | background-color: var(--theme-color); 83 | padding: 10px; 84 | margin: 0 10px !important; 85 | } 86 | 87 | .content { 88 | margin: 0; 89 | } 90 | } 91 | 92 | .title-wrapper { 93 | display: flex; 94 | align-items: center; 95 | margin-right: 15px; 96 | } 97 | 98 | .back-to-list-wrapper { 99 | margin: 5px; 100 | } 101 | 102 | .back-to-list { 103 | display: flex; 104 | align-items: center; 105 | justify-content: center; 106 | cursor: pointer; 107 | height: 40px; 108 | width: 40px; 109 | border-radius: 50%; 110 | } 111 | 112 | .back-to-list:hover { 113 | background-color: var(--dark-gray); 114 | } 115 | 116 | .back-to-list:active { 117 | background-color: var(--active-color); 118 | } 119 | 120 | .back-icon { 121 | width: 1rem; 122 | height: 1rem; 123 | margin: auto 0; 124 | } 125 | 126 | .row { 127 | margin-bottom: 1rem; 128 | } 129 | 130 | .line { 131 | margin: 0; 132 | padding: 0; 133 | color: var(--active-color); 134 | } 135 | 136 | textarea { 137 | height: 86px; 138 | } 139 | 140 | ::placeholder { 141 | color: var(--light-gray) !important; 142 | opacity: 1; /* Firefox */ 143 | } 144 | 145 | ::-ms-input-placeholder { /* Edge 12 -18 */ 146 | color: var(--light-gray) !important; 147 | } 148 | 149 | textarea, 150 | input { 151 | color: var(--text-color); 152 | background-color: var(--bg-color); 153 | } 154 | 155 | .form-control { 156 | color: var(--text-color); 157 | background-color: var(--bg-color); 158 | } 159 | 160 | .form-control:active, .form-control:focus { 161 | color: var(--text-color); 162 | background-color: var(--active-color); 163 | } 164 | 165 | /*********** Buttons ***********/ 166 | .content-container .button-container { 167 | display: flex; 168 | } 169 | 170 | .content-container .btn { 171 | border-radius: 40px; 172 | border: none; 173 | font-weight: 600; 174 | width: 100%; 175 | height: 2.4rem; 176 | width: 10rem; 177 | display: flex; 178 | text-align: center; 179 | align-items: center; 180 | justify-content: center; 181 | } 182 | 183 | .content-container .btn-save { 184 | background-color: var(--green); 185 | color: white; 186 | } 187 | 188 | .content-container .btn-save:hover, 189 | .content-container .btn-save:focus { 190 | background-color: var(--dark-green); 191 | color: white; 192 | } 193 | 194 | .content-container .btn-delete { 195 | background-color: var(--red); 196 | color: white; 197 | } 198 | 199 | .content-container .btn-delete:hover, 200 | .content-container .btn-delete:focus { 201 | background-color: var(--dark-red); 202 | color: white; 203 | } 204 | 205 | 206 | .content-container .btn-cancel { 207 | background-color: var(--btn-cancel-color); 208 | color: var(--text-color); 209 | margin-left: 5px; 210 | } 211 | 212 | .content-container .btn-cancel:hover, 213 | .content-container .btn-cancel:focus { 214 | background-color: var(--active-color); 215 | color: var(--text-color); 216 | } 217 | 218 | .content-container .btn-add { 219 | height: 2rem; 220 | width: 7rem; 221 | } 222 | 223 | /*********** list ***********/ 224 | .list-header { 225 | padding: 0 15px 16px 15px; 226 | display: flex; 227 | } 228 | 229 | .list-header span { 230 | color: var(--gray); 231 | } 232 | 233 | .list-header .control-header { 234 | min-width: 120px; 235 | } 236 | 237 | .list-header .message-control-header { 238 | min-width: 75px; 239 | } 240 | 241 | .list-title { 242 | display: flex; 243 | text-align: center; 244 | align-items: flex-end; 245 | justify-content: end; 246 | } 247 | 248 | .right-button-wrapper, 249 | .view-wrapper .buttons-wrapper { 250 | margin-left: auto; 251 | margin-right: 0; 252 | } 253 | 254 | .parent-page-title { 255 | margin: 10px 15px; 256 | } 257 | 258 | .list-total { 259 | margin-left: 10px; 260 | } 261 | 262 | .list-total span { 263 | color: darkgray; 264 | } 265 | 266 | .list-total span:first-child { 267 | margin-right: 5px; 268 | } 269 | 270 | .add-icon { 271 | margin-right: 5px; 272 | } 273 | 274 | .add-text { 275 | color: white; 276 | } 277 | 278 | .list-item { 279 | padding: 25px 15px; 280 | display: flex; 281 | align-items: center; 282 | } 283 | 284 | .list-item .row, 285 | .list-header .row { 286 | width: 100%; 287 | display: flex; 288 | margin-bottom: 0; 289 | margin-right: 0; 290 | align-items: center; 291 | } 292 | 293 | .list-item .buttons-wrapper { 294 | display: flex; 295 | } 296 | 297 | .form-container { 298 | padding: 0 15px; 299 | } 300 | 301 | @media (max-width: 768px) { 302 | .form-container { 303 | padding: 10px; 304 | } 305 | 306 | .list-header { 307 | display: none; 308 | } 309 | 310 | .right-button-wrapper .add-text { 311 | display: none; 312 | } 313 | 314 | .content-container .btn-add { 315 | width: 2rem; 316 | } 317 | 318 | .add-icon { 319 | margin: 0; 320 | } 321 | 322 | .parent-page-title { 323 | margin: 10px; 324 | } 325 | 326 | .title-wrapper { 327 | margin-right: 10px; 328 | } 329 | 330 | .list-item { 331 | padding: 15px 10px; 332 | display: block; 333 | } 334 | 335 | .list-item .col-sm-12 { 336 | margin: 3px 0; 337 | } 338 | 339 | .buttons-wrapper { 340 | padding: 10px 0 0 0; 341 | width: fit-content; 342 | margin: 0 auto; 343 | } 344 | 345 | .back-to-list-wrapper { 346 | margin-left: 0px; 347 | } 348 | 349 | .view-wrapper .buttons-wrapper { 350 | padding: 0; 351 | } 352 | } 353 | 354 | .list-item:hover { 355 | box-shadow: 0 0px 5px var(--active-color); 356 | cursor: pointer; 357 | } 358 | 359 | .truncated { 360 | overflow: hidden; 361 | white-space: nowrap; 362 | text-overflow: ellipsis; 363 | max-width: 25ch; 364 | } 365 | 366 | /*********** list button ***********/ 367 | .list-button { 368 | padding: 9px; 369 | border-radius: 50%; 370 | background-color: var(--theme-color); 371 | box-shadow: 0 0 4px var(--active-color); 372 | text-decoration: none; 373 | text-align: center; 374 | display: inline-flex; 375 | margin-left: 15px; 376 | } 377 | 378 | .buttons-wrapper .list-button:first-child { 379 | margin-left: 0px; 380 | } 381 | 382 | .list-button:hover { 383 | background-color: var(--active-color); 384 | } 385 | 386 | .list-button-icon { 387 | width: 12px; 388 | height: 12px; 389 | } 390 | 391 | .view-item-wrapper { 392 | display: grid; 393 | margin-bottom: 1rem; 394 | } 395 | 396 | .view-item-wrapper span { 397 | font-weight: 600; 398 | margin-bottom: 5px; 399 | } 400 | 401 | .view-profile-photo { 402 | width: 10rem; 403 | } 404 | 405 | .edit-photo-wrapper { 406 | display: flex; 407 | } 408 | 409 | .photo-buttons-wrapper { 410 | margin: 0 0 0 1rem; 411 | } 412 | 413 | .photo-buttons-wrapper .photo-btn { 414 | border: none; 415 | background-color: white; 416 | font-weight: 600; 417 | padding: 0; 418 | } 419 | 420 | .photo-buttons-wrapper .photo-btn.update-btn { 421 | color: var(--green); 422 | margin-right: 20px; 423 | } 424 | 425 | .photo-buttons-wrapper .photo-btn.update-btn:hover { 426 | color: var(--dark-green); 427 | } 428 | 429 | .photo-buttons-wrapper .photo-btn.remove-btn { 430 | color: var(--red); 431 | } 432 | 433 | .photo-buttons-wrapper .photo-btn.remove-btn:hover { 434 | color: var(--dark-red); 435 | } 436 | 437 | @media (max-width: 426px) { 438 | 439 | .list-button, 440 | .buttons-wrapper .list-button:first-child { 441 | margin: 0 20px; 442 | } 443 | 444 | .view-wrapper .buttons-wrapper .list-button { 445 | margin: 0 5px; 446 | } 447 | 448 | .edit-photo-wrapper { 449 | display: block; 450 | } 451 | 452 | .view-profile-photo { 453 | width: 100%; 454 | } 455 | 456 | .photo-buttons-wrapper { 457 | margin: 1rem 0 0 0; 458 | } 459 | } 460 | -------------------------------------------------------------------------------- /client/src/WebUI/wwwroot/images/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/WebUI/wwwroot/images/back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SaraRasoulian/DotNet-WebAPI-Blazor-Sample/bd88afb855ec77cd296ffcc79623b9e24779cdc1/client/src/WebUI/wwwroot/images/back.png -------------------------------------------------------------------------------- /client/src/WebUI/wwwroot/images/delete.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/WebUI/wwwroot/images/edit.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/WebUI/wwwroot/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SaraRasoulian/DotNet-WebAPI-Blazor-Sample/bd88afb855ec77cd296ffcc79623b9e24779cdc1/client/src/WebUI/wwwroot/images/favicon.png -------------------------------------------------------------------------------- /client/src/WebUI/wwwroot/images/home.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/WebUI/wwwroot/images/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SaraRasoulian/DotNet-WebAPI-Blazor-Sample/bd88afb855ec77cd296ffcc79623b9e24779cdc1/client/src/WebUI/wwwroot/images/icon-192.png -------------------------------------------------------------------------------- /client/src/WebUI/wwwroot/images/logout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SaraRasoulian/DotNet-WebAPI-Blazor-Sample/bd88afb855ec77cd296ffcc79623b9e24779cdc1/client/src/WebUI/wwwroot/images/logout.png -------------------------------------------------------------------------------- /client/src/WebUI/wwwroot/images/navbar.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/WebUI/wwwroot/images/profile-photo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SaraRasoulian/DotNet-WebAPI-Blazor-Sample/bd88afb855ec77cd296ffcc79623b9e24779cdc1/client/src/WebUI/wwwroot/images/profile-photo.png -------------------------------------------------------------------------------- /client/src/WebUI/wwwroot/images/students.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SaraRasoulian/DotNet-WebAPI-Blazor-Sample/bd88afb855ec77cd296ffcc79623b9e24779cdc1/client/src/WebUI/wwwroot/images/students.png -------------------------------------------------------------------------------- /client/src/WebUI/wwwroot/images/view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SaraRasoulian/DotNet-WebAPI-Blazor-Sample/bd88afb855ec77cd296ffcc79623b9e24779cdc1/client/src/WebUI/wwwroot/images/view.png -------------------------------------------------------------------------------- /client/src/WebUI/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Student Management 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 |
25 |
26 | 27 |
28 | An unhandled error has occurred. 29 | Reload 30 | 🗙 31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | # ASP.NET Core Web API Application 4 | student_management_api: 5 | container_name: api_container 6 | ports: 7 | - "5000:5000" 8 | depends_on: 9 | - student_management_postgres_db 10 | build: 11 | context: ./server 12 | dockerfile: Dockerfile 13 | environment: 14 | - ConnectionStrings__DefaultConnection=Server=postgres_db_container;Port=5432;Database=StudentManagementDB;Username=postgres;Password=mysecretpassword; 15 | - ASPNETCORE_URLS=http://+:5000 16 | networks: 17 | - student_management_network 18 | 19 | # Blazor WebAssembly Application 20 | student_management_client: 21 | container_name: client_container 22 | ports: 23 | - "8080:8080" 24 | build: 25 | context: ./client 26 | dockerfile: Dockerfile 27 | networks: 28 | - student_management_network 29 | 30 | # PostgreSQL Database 31 | student_management_postgres_db: 32 | container_name: postgres_db_container 33 | image: postgres:latest 34 | environment: 35 | POSTGRES_USER: postgres 36 | POSTGRES_PASSWORD: mysecretpassword 37 | PGDATA: /data/postgres 38 | volumes: 39 | - student_management_postgres_data:/data/postgres 40 | ports: 41 | - "5433:5432" 42 | networks: 43 | - student_management_network 44 | restart: unless-stopped 45 | 46 | # PGAdmin User Interface 47 | student_management_pgadmin: 48 | container_name: pgadmin_container 49 | image: dpage/pgadmin4 50 | environment: 51 | PGADMIN_DEFAULT_EMAIL: admin@gmail.com 52 | PGADMIN_DEFAULT_PASSWORD: mysecretpassword 53 | PGADMIN_CONFIG_SERVER_MODE: 'False' 54 | volumes: 55 | - student_management_pgadmin_data:/var/lib/pgadmin 56 | ports: 57 | - "8000:80" 58 | networks: 59 | - student_management_network 60 | restart: unless-stopped 61 | 62 | networks: 63 | student_management_network: 64 | driver: bridge 65 | 66 | volumes: 67 | student_management_postgres_data: 68 | student_management_pgadmin_data: -------------------------------------------------------------------------------- /server/.dockerignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/csharp 3 | 4 | ### Csharp ### 5 | ## Ignore Visual Studio temporary files, build results, and 6 | ## files generated by popular Visual Studio add-ons. 7 | 8 | # User-specific files 9 | *.suo 10 | *.user 11 | *.userosscache 12 | *.sln.docstates 13 | 14 | # User-specific files (MonoDevelop/Xamarin Studio) 15 | *.userprefs 16 | 17 | # Build results 18 | [Dd]ebug/ 19 | [Dd]ebugPublic/ 20 | [Rr]elease/ 21 | [Rr]eleases/ 22 | x64/ 23 | x86/ 24 | bld/ 25 | [Bb]in/ 26 | [Oo]bj/ 27 | [Ll]og/ 28 | 29 | # Visual Studio cache/options directory 30 | .vs/ 31 | # Uncomment if you have tasks that create the project's static files in wwwroot 32 | #wwwroot/ 33 | 34 | # MSTest test Results 35 | [Tt]est[Rr]esult*/ 36 | [Bb]uild[Ll]og.* 37 | 38 | # NUNIT 39 | *.VisualState.xml 40 | TestResult.xml 41 | 42 | # Build Results of an ATL Project 43 | [Dd]ebugPS/ 44 | [Rr]eleasePS/ 45 | dlldata.c 46 | 47 | # DNX 48 | project.lock.json 49 | project.fragment.lock.json 50 | artifacts/ 51 | Properties/launchSettings.json 52 | 53 | *_i.c 54 | *_p.c 55 | *_i.h 56 | *.ilk 57 | *.meta 58 | *.obj 59 | *.pch 60 | *.pdb 61 | *.pgc 62 | *.pgd 63 | *.rsp 64 | *.sbr 65 | *.tlb 66 | *.tli 67 | *.tlh 68 | *.tmp 69 | *.tmp_proj 70 | *.log 71 | *.vspscc 72 | *.vssscc 73 | .builds 74 | *.pidb 75 | *.svclog 76 | *.scc 77 | 78 | # Chutzpah Test files 79 | _Chutzpah* 80 | 81 | # Visual C++ cache files 82 | ipch/ 83 | *.aps 84 | *.ncb 85 | *.opendb 86 | *.opensdf 87 | *.sdf 88 | *.cachefile 89 | *.VC.db 90 | *.VC.VC.opendb 91 | 92 | # Visual Studio profiler 93 | *.psess 94 | *.vsp 95 | *.vspx 96 | *.sap 97 | 98 | # TFS 2012 Local Workspace 99 | $tf/ 100 | 101 | # Guidance Automation Toolkit 102 | *.gpState 103 | 104 | # ReSharper is a .NET coding add-in 105 | _ReSharper*/ 106 | *.[Rr]e[Ss]harper 107 | *.DotSettings.user 108 | 109 | # JustCode is a .NET coding add-in 110 | .JustCode 111 | 112 | # TeamCity is a build add-in 113 | _TeamCity* 114 | 115 | # DotCover is a Code Coverage Tool 116 | *.dotCover 117 | 118 | # Visual Studio code coverage results 119 | *.coverage 120 | *.coveragexml 121 | 122 | # NCrunch 123 | _NCrunch_* 124 | .*crunch*.local.xml 125 | nCrunchTemp_* 126 | 127 | # MightyMoose 128 | *.mm.* 129 | AutoTest.Net/ 130 | 131 | # Web workbench (sass) 132 | .sass-cache/ 133 | 134 | # Installshield output folder 135 | [Ee]xpress/ 136 | 137 | # DocProject is a documentation generator add-in 138 | DocProject/buildhelp/ 139 | DocProject/Help/*.HxT 140 | DocProject/Help/*.HxC 141 | DocProject/Help/*.hhc 142 | DocProject/Help/*.hhk 143 | DocProject/Help/*.hhp 144 | DocProject/Help/Html2 145 | DocProject/Help/html 146 | 147 | # Click-Once directory 148 | publish/ 149 | 150 | # Publish Web Output 151 | *.[Pp]ublish.xml 152 | *.azurePubxml 153 | # TODO: Comment the next line if you want to checkin your web deploy settings 154 | # but database connection strings (with potential passwords) will be unencrypted 155 | *.pubxml 156 | *.publishproj 157 | 158 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 159 | # checkin your Azure Web App publish settings, but sensitive information contained 160 | # in these scripts will be unencrypted 161 | PublishScripts/ 162 | 163 | # NuGet Packages 164 | *.nupkg 165 | # The packages folder can be ignored because of Package Restore 166 | **/packages/* 167 | # except build/, which is used as an MSBuild target. 168 | !**/packages/build/ 169 | # Uncomment if necessary however generally it will be regenerated when needed 170 | #!**/packages/repositories.config 171 | # NuGet v3's project.json files produces more ignoreable files 172 | *.nuget.props 173 | *.nuget.targets 174 | 175 | # Microsoft Azure Build Output 176 | csx/ 177 | *.build.csdef 178 | 179 | # Microsoft Azure Emulator 180 | ecf/ 181 | rcf/ 182 | 183 | # Windows Store app package directories and files 184 | AppPackages/ 185 | BundleArtifacts/ 186 | Package.StoreAssociation.xml 187 | _pkginfo.txt 188 | 189 | # Visual Studio cache files 190 | # files ending in .cache can be ignored 191 | *.[Cc]ache 192 | # but keep track of directories ending in .cache 193 | !*.[Cc]ache/ 194 | 195 | # Others 196 | ClientBin/ 197 | ~$* 198 | *~ 199 | *.dbmdl 200 | *.dbproj.schemaview 201 | *.jfm 202 | *.pfx 203 | *.publishsettings 204 | node_modules/ 205 | orleans.codegen.cs 206 | 207 | # Since there are multiple workflows, uncomment next line to ignore bower_components 208 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 209 | #bower_components/ 210 | 211 | # RIA/Silverlight projects 212 | Generated_Code/ 213 | 214 | # Backup & report files from converting an old project file 215 | # to a newer Visual Studio version. Backup files are not needed, 216 | # because we have git ;-) 217 | _UpgradeReport_Files/ 218 | Backup*/ 219 | UpgradeLog*.XML 220 | UpgradeLog*.htm 221 | 222 | # SQL Server files 223 | *.mdf 224 | *.ldf 225 | 226 | # Business Intelligence projects 227 | *.rdl.data 228 | *.bim.layout 229 | *.bim_*.settings 230 | 231 | # Microsoft Fakes 232 | FakesAssemblies/ 233 | 234 | # GhostDoc plugin setting file 235 | *.GhostDoc.xml 236 | 237 | # Node.js Tools for Visual Studio 238 | .ntvs_analysis.dat 239 | 240 | # Visual Studio 6 build log 241 | *.plg 242 | 243 | # Visual Studio 6 workspace options file 244 | *.opt 245 | 246 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 247 | *.vbw 248 | 249 | # Visual Studio LightSwitch build output 250 | **/*.HTMLClient/GeneratedArtifacts 251 | **/*.DesktopClient/GeneratedArtifacts 252 | **/*.DesktopClient/ModelManifest.xml 253 | **/*.Server/GeneratedArtifacts 254 | **/*.Server/ModelManifest.xml 255 | _Pvt_Extensions 256 | 257 | # Paket dependency manager 258 | .paket/paket.exe 259 | paket-files/ 260 | 261 | # FAKE - F# Make 262 | .fake/ 263 | 264 | # JetBrains Rider 265 | .idea/ 266 | *.sln.iml 267 | 268 | # CodeRush 269 | .cr/ 270 | 271 | # Python Tools for Visual Studio (PTVS) 272 | __pycache__/ 273 | *.pyc 274 | 275 | # Cake - Uncomment if you are using it 276 | # tools/ 277 | tools/Cake.CoreCLR 278 | .vscode 279 | tools 280 | .dotnet 281 | Dockerfile 282 | 283 | # .env file contains default environment variables for docker 284 | .env 285 | .git/ -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base 2 | WORKDIR /app 3 | EXPOSE 5000 4 | 5 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build 6 | WORKDIR /src 7 | COPY ["src/WebAPI/WebAPI.csproj", "src/WebAPI/"] 8 | COPY ["src/Application/Application.csproj", "src/Application/"] 9 | COPY ["src/Domain/Domain.csproj", "src/Domain/"] 10 | COPY ["src/Infrastructure/Infrastructure.csproj", "src/Infrastructure/"] 11 | RUN dotnet restore "./src/WebAPI/WebAPI.csproj" 12 | COPY . . 13 | WORKDIR "/src/src/WebAPI" 14 | RUN dotnet build "WebAPI.csproj" -c Release -o /app/build 15 | 16 | FROM build AS publish 17 | RUN dotnet publish "WebAPI.csproj" -c Release -o /app/publish 18 | 19 | FROM base AS final 20 | WORKDIR /app 21 | 22 | COPY --from=publish /app/publish . 23 | ENTRYPOINT ["dotnet", "WebAPI.dll"] -------------------------------------------------------------------------------- /server/StudentManagement.Server.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{6ED356A7-8B47-4613-AD01-C85CF28491BD}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{664D406C-2F83-48F0-BFC3-408D5CB53C65}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E2DA20AA-28D1-455C-BF50-C49A8F831633}" 11 | ProjectSection(SolutionItems) = preProject 12 | .dockerignore = .dockerignore 13 | Dockerfile = Dockerfile 14 | EndProjectSection 15 | EndProject 16 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebAPI", "src\WebApi\WebAPI.csproj", "{C28C5493-ED77-40AA-8E7F-78AA28633EDC}" 17 | EndProject 18 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain.Tests.Unit", "tests\Domain.Tests.Unit\Domain.Tests.Unit.csproj", "{05FB5C23-10FA-41A7-A520-C15CD14A5375}" 19 | EndProject 20 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application.Tests.Unit", "tests\Application.Tests.Unit\Application.Tests.Unit.csproj", "{74A83D1D-306A-46AE-8F49-7D80D6C480A7}" 21 | EndProject 22 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application.Tests.Integration", "tests\Application.Tests.Integration\Application.Tests.Integration.csproj", "{DCBC54A2-5D16-465B-95B8-FCC7B33CC3CD}" 23 | EndProject 24 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Domain", "src\Domain\Domain.csproj", "{9C28732F-A548-4EF0-A796-1063FDB3E479}" 25 | EndProject 26 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application", "src\Application\Application.csproj", "{22A603E9-7A63-4235-9EA2-53525948045D}" 27 | EndProject 28 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure", "src\Infrastructure\Infrastructure.csproj", "{B8F88D98-9933-47AA-806B-3606CCA177C3}" 29 | EndProject 30 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebAPI.Tests.Integration", "tests\WebAPI.Tests.Integration\WebAPI.Tests.Integration.csproj", "{F3C10C1F-A947-4A05-ADFE-517E3ADBF3D1}" 31 | EndProject 32 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebAPI.Tests.Acceptance", "tests\WebAPI.Tests.Acceptance\WebAPI.Tests.Acceptance.csproj", "{26D5CFC5-2368-4C94-822C-5A51AE178A91}" 33 | EndProject 34 | Global 35 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 36 | Debug|Any CPU = Debug|Any CPU 37 | Release|Any CPU = Release|Any CPU 38 | EndGlobalSection 39 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 40 | {C28C5493-ED77-40AA-8E7F-78AA28633EDC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 41 | {C28C5493-ED77-40AA-8E7F-78AA28633EDC}.Debug|Any CPU.Build.0 = Debug|Any CPU 42 | {C28C5493-ED77-40AA-8E7F-78AA28633EDC}.Release|Any CPU.ActiveCfg = Release|Any CPU 43 | {C28C5493-ED77-40AA-8E7F-78AA28633EDC}.Release|Any CPU.Build.0 = Release|Any CPU 44 | {05FB5C23-10FA-41A7-A520-C15CD14A5375}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 45 | {05FB5C23-10FA-41A7-A520-C15CD14A5375}.Debug|Any CPU.Build.0 = Debug|Any CPU 46 | {05FB5C23-10FA-41A7-A520-C15CD14A5375}.Release|Any CPU.ActiveCfg = Release|Any CPU 47 | {05FB5C23-10FA-41A7-A520-C15CD14A5375}.Release|Any CPU.Build.0 = Release|Any CPU 48 | {74A83D1D-306A-46AE-8F49-7D80D6C480A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 49 | {74A83D1D-306A-46AE-8F49-7D80D6C480A7}.Debug|Any CPU.Build.0 = Debug|Any CPU 50 | {74A83D1D-306A-46AE-8F49-7D80D6C480A7}.Release|Any CPU.ActiveCfg = Release|Any CPU 51 | {74A83D1D-306A-46AE-8F49-7D80D6C480A7}.Release|Any CPU.Build.0 = Release|Any CPU 52 | {DCBC54A2-5D16-465B-95B8-FCC7B33CC3CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 53 | {DCBC54A2-5D16-465B-95B8-FCC7B33CC3CD}.Debug|Any CPU.Build.0 = Debug|Any CPU 54 | {DCBC54A2-5D16-465B-95B8-FCC7B33CC3CD}.Release|Any CPU.ActiveCfg = Release|Any CPU 55 | {DCBC54A2-5D16-465B-95B8-FCC7B33CC3CD}.Release|Any CPU.Build.0 = Release|Any CPU 56 | {9C28732F-A548-4EF0-A796-1063FDB3E479}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 57 | {9C28732F-A548-4EF0-A796-1063FDB3E479}.Debug|Any CPU.Build.0 = Debug|Any CPU 58 | {9C28732F-A548-4EF0-A796-1063FDB3E479}.Release|Any CPU.ActiveCfg = Release|Any CPU 59 | {9C28732F-A548-4EF0-A796-1063FDB3E479}.Release|Any CPU.Build.0 = Release|Any CPU 60 | {22A603E9-7A63-4235-9EA2-53525948045D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 61 | {22A603E9-7A63-4235-9EA2-53525948045D}.Debug|Any CPU.Build.0 = Debug|Any CPU 62 | {22A603E9-7A63-4235-9EA2-53525948045D}.Release|Any CPU.ActiveCfg = Release|Any CPU 63 | {22A603E9-7A63-4235-9EA2-53525948045D}.Release|Any CPU.Build.0 = Release|Any CPU 64 | {B8F88D98-9933-47AA-806B-3606CCA177C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 65 | {B8F88D98-9933-47AA-806B-3606CCA177C3}.Debug|Any CPU.Build.0 = Debug|Any CPU 66 | {B8F88D98-9933-47AA-806B-3606CCA177C3}.Release|Any CPU.ActiveCfg = Release|Any CPU 67 | {B8F88D98-9933-47AA-806B-3606CCA177C3}.Release|Any CPU.Build.0 = Release|Any CPU 68 | {F3C10C1F-A947-4A05-ADFE-517E3ADBF3D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 69 | {F3C10C1F-A947-4A05-ADFE-517E3ADBF3D1}.Debug|Any CPU.Build.0 = Debug|Any CPU 70 | {F3C10C1F-A947-4A05-ADFE-517E3ADBF3D1}.Release|Any CPU.ActiveCfg = Release|Any CPU 71 | {F3C10C1F-A947-4A05-ADFE-517E3ADBF3D1}.Release|Any CPU.Build.0 = Release|Any CPU 72 | {26D5CFC5-2368-4C94-822C-5A51AE178A91}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 73 | {26D5CFC5-2368-4C94-822C-5A51AE178A91}.Debug|Any CPU.Build.0 = Debug|Any CPU 74 | {26D5CFC5-2368-4C94-822C-5A51AE178A91}.Release|Any CPU.ActiveCfg = Release|Any CPU 75 | {26D5CFC5-2368-4C94-822C-5A51AE178A91}.Release|Any CPU.Build.0 = Release|Any CPU 76 | EndGlobalSection 77 | GlobalSection(SolutionProperties) = preSolution 78 | HideSolutionNode = FALSE 79 | EndGlobalSection 80 | GlobalSection(NestedProjects) = preSolution 81 | {C28C5493-ED77-40AA-8E7F-78AA28633EDC} = {6ED356A7-8B47-4613-AD01-C85CF28491BD} 82 | {05FB5C23-10FA-41A7-A520-C15CD14A5375} = {664D406C-2F83-48F0-BFC3-408D5CB53C65} 83 | {74A83D1D-306A-46AE-8F49-7D80D6C480A7} = {664D406C-2F83-48F0-BFC3-408D5CB53C65} 84 | {DCBC54A2-5D16-465B-95B8-FCC7B33CC3CD} = {664D406C-2F83-48F0-BFC3-408D5CB53C65} 85 | {9C28732F-A548-4EF0-A796-1063FDB3E479} = {6ED356A7-8B47-4613-AD01-C85CF28491BD} 86 | {22A603E9-7A63-4235-9EA2-53525948045D} = {6ED356A7-8B47-4613-AD01-C85CF28491BD} 87 | {B8F88D98-9933-47AA-806B-3606CCA177C3} = {6ED356A7-8B47-4613-AD01-C85CF28491BD} 88 | {F3C10C1F-A947-4A05-ADFE-517E3ADBF3D1} = {664D406C-2F83-48F0-BFC3-408D5CB53C65} 89 | {26D5CFC5-2368-4C94-822C-5A51AE178A91} = {664D406C-2F83-48F0-BFC3-408D5CB53C65} 90 | EndGlobalSection 91 | GlobalSection(ExtensibilityGlobals) = postSolution 92 | SolutionGuid = {3CB609D9-5D54-4C11-A371-DAAC8B74E430} 93 | EndGlobalSection 94 | EndGlobal 95 | -------------------------------------------------------------------------------- /server/src/Application/Application.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /server/src/Application/Behaviours/ValidationBehaviour.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | using MediatR; 3 | 4 | namespace Application.Behaviours; 5 | 6 | public class ValidationBehaviour : IPipelineBehavior 7 | where TRequest : notnull 8 | { 9 | private readonly IEnumerable> _validators; 10 | 11 | public ValidationBehaviour(IEnumerable> validators) 12 | { 13 | _validators = validators; 14 | } 15 | 16 | public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) 17 | { 18 | if (_validators.Any()) 19 | { 20 | var context = new ValidationContext(request); 21 | 22 | var validationResults = await Task.WhenAll( 23 | _validators.Select(v => 24 | v.ValidateAsync(context, cancellationToken))); 25 | 26 | var failures = validationResults 27 | .Where(r => r.Errors.Any()) 28 | .SelectMany(r => r.Errors) 29 | .ToList(); 30 | 31 | if (failures.Any()) 32 | throw new ValidationException(failures); 33 | } 34 | return await next(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /server/src/Application/Consts/StudentConsts.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Consts; 2 | 3 | public static class StudentConsts 4 | { 5 | public const int NameMaxLength = 50; 6 | public const int NameMinLength = 2; 7 | 8 | public const int EmailMaxLength = 320; 9 | 10 | public const int UsernameMaxLength = 40; 11 | public const int UsernameMinLength = 3; 12 | } 13 | -------------------------------------------------------------------------------- /server/src/Application/DependencyInjection.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Application.Behaviours; 3 | using System.Reflection; 4 | using FluentValidation; 5 | using MediatR; 6 | 7 | namespace Application; 8 | public static class DependencyInjection 9 | { 10 | public static IServiceCollection AddApplicationServices(this IServiceCollection services) 11 | { 12 | services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); 13 | 14 | services.AddMediatR(cfg => 15 | { 16 | cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); 17 | cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>)); 18 | }); 19 | 20 | return services; 21 | } 22 | } -------------------------------------------------------------------------------- /server/src/Application/RequestModels/CreateStudentRequest.cs: -------------------------------------------------------------------------------- 1 | using Domain.ValueObjects; 2 | 3 | namespace Application.RequestModels; 4 | 5 | public record CreateStudentRequest(string FirstName, string LastName, Email Email, DateOnly BirthDate, string GitHubUsername); -------------------------------------------------------------------------------- /server/src/Application/RequestModels/UpdateStudentRequest.cs: -------------------------------------------------------------------------------- 1 | using Domain.ValueObjects; 2 | 3 | namespace Application.RequestModels; 4 | 5 | public record UpdateStudentRequest(long Id, string FirstName, string LastName, Email Email, 6 | DateOnly BirthDate, string GitHubUsername); -------------------------------------------------------------------------------- /server/src/Application/ResponseModels/ErrorResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Application.ResponseModels; 2 | 3 | public record ErrorResponse 4 | { 5 | public string Title { get; set; } 6 | public int StatusCode { get; set; } 7 | public string Message { get; set; } 8 | } 9 | -------------------------------------------------------------------------------- /server/src/Application/ResponseModels/StudentResponse.cs: -------------------------------------------------------------------------------- 1 | using Domain.ValueObjects; 2 | 3 | namespace Application.ResponseModels; 4 | 5 | public record StudentResponse(long Id, string FirstName, string LastName, Email Email, DateOnly BirthDate, string GitHubUsername); -------------------------------------------------------------------------------- /server/src/Application/Students/Commands/CreateStudentCommand.cs: -------------------------------------------------------------------------------- 1 | using Application.ResponseModels; 2 | using Domain.ValueObjects; 3 | using MediatR; 4 | 5 | namespace Application.Students.Commands; 6 | 7 | public record CreateStudentCommand(string FirstName, string LastName, Email Email, DateOnly BirthDate, 8 | string GitHubUsername) : IRequest; 9 | -------------------------------------------------------------------------------- /server/src/Application/Students/Commands/CreateStudentCommandValidator.cs: -------------------------------------------------------------------------------- 1 | using Domain.ValueObjects; 2 | using Domain.Interfaces; 3 | using FluentValidation; 4 | using Application.Consts; 5 | 6 | namespace Application.Students.Commands; 7 | 8 | public sealed class CreateStudentCommandValidator : AbstractValidator 9 | { 10 | private readonly IStudentRepository _studentRepository; 11 | public CreateStudentCommandValidator(IStudentRepository studentRepository) 12 | { 13 | _studentRepository = studentRepository; 14 | 15 | RuleFor(v => v.FirstName) 16 | .NotEmpty() 17 | .NotNull() 18 | .MaximumLength(StudentConsts.NameMaxLength) 19 | .MinimumLength(StudentConsts.NameMinLength); 20 | 21 | RuleFor(v => v.LastName) 22 | .NotEmpty() 23 | .NotNull() 24 | .MaximumLength(StudentConsts.NameMaxLength) 25 | .MinimumLength(StudentConsts.NameMinLength); 26 | 27 | RuleFor(v => v.Email) 28 | .MustAsync(IsEmailUnique) 29 | .WithMessage("'{PropertyName}' must be unique.") 30 | .WithErrorCode("Unique"); 31 | 32 | RuleFor(v => v.BirthDate) 33 | .LessThan(DateOnly.FromDateTime(DateTime.Now)) 34 | .GreaterThan(DateOnly.FromDateTime(DateTime.Now.AddYears(-150))); 35 | 36 | RuleFor(v => v.GitHubUsername) 37 | .NotEmpty() 38 | .NotNull() 39 | .MaximumLength(StudentConsts.UsernameMaxLength) 40 | .MinimumLength(StudentConsts.UsernameMinLength) 41 | .MustAsync(IsGitHubUsernameUnique) 42 | .WithMessage("'{PropertyName}' must be unique.") 43 | .WithErrorCode("Unique"); 44 | } 45 | 46 | private async Task IsEmailUnique(Email email, CancellationToken token) 47 | { 48 | return await _studentRepository.IsEmailUnique(email); 49 | } 50 | 51 | private async Task IsGitHubUsernameUnique(string gitHubUsername, CancellationToken token) 52 | { 53 | return await _studentRepository.IsGitHubUsernameUnique(gitHubUsername); 54 | } 55 | } -------------------------------------------------------------------------------- /server/src/Application/Students/Commands/DeleteStudentCommand.cs: -------------------------------------------------------------------------------- 1 | using Application.ResponseModels; 2 | using MediatR; 3 | 4 | namespace Application.Students.Commands; 5 | 6 | public record DeleteStudentCommand(long StudentId) : IRequest; -------------------------------------------------------------------------------- /server/src/Application/Students/Commands/DeleteStudentCommandValidator.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | 3 | namespace Application.Students.Commands; 4 | 5 | public sealed class DeleteStudentCommandValidator : AbstractValidator 6 | { 7 | public DeleteStudentCommandValidator() 8 | { 9 | RuleFor(v => v.StudentId) 10 | .NotEmpty() 11 | .NotNull() 12 | .GreaterThan(0); 13 | } 14 | } -------------------------------------------------------------------------------- /server/src/Application/Students/Commands/UpdateStudentCommand.cs: -------------------------------------------------------------------------------- 1 | using Application.ResponseModels; 2 | using Domain.ValueObjects; 3 | using MediatR; 4 | 5 | namespace Application.Students.Commands; 6 | 7 | public record UpdateStudentCommand(long Id, string FirstName, string LastName, Email Email, DateOnly BirthDate, 8 | string GitHubUsername) : IRequest; 9 | -------------------------------------------------------------------------------- /server/src/Application/Students/Commands/UpdateStudentCommandValidator.cs: -------------------------------------------------------------------------------- 1 | using Domain.ValueObjects; 2 | using Domain.Interfaces; 3 | using FluentValidation; 4 | using Application.Consts; 5 | 6 | namespace Application.Students.Commands; 7 | 8 | public sealed class UpdateStudentCommandValidator : AbstractValidator 9 | { 10 | private readonly IStudentRepository _studentRepository; 11 | public UpdateStudentCommandValidator(IStudentRepository studentRepository) 12 | { 13 | _studentRepository = studentRepository; 14 | 15 | RuleFor(v => v.Id) 16 | .NotEmpty() 17 | .NotNull() 18 | .GreaterThan(0); 19 | 20 | RuleFor(v => v.FirstName) 21 | .NotEmpty() 22 | .NotNull() 23 | .MaximumLength(StudentConsts.NameMaxLength) 24 | .MinimumLength(StudentConsts.NameMinLength); 25 | 26 | RuleFor(v => v.LastName) 27 | .NotEmpty() 28 | .NotNull() 29 | .MaximumLength(StudentConsts.NameMaxLength) 30 | .MinimumLength(StudentConsts.NameMinLength); 31 | 32 | RuleFor(v => v.Email) 33 | .MustAsync(IsEmailUnique) 34 | .WithMessage("'{PropertyName}' must be unique.") 35 | .WithErrorCode("Unique"); 36 | 37 | RuleFor(v => v.BirthDate) 38 | .LessThan(DateOnly.FromDateTime(DateTime.Now)) 39 | .GreaterThan(DateOnly.FromDateTime(DateTime.Now.AddYears(-150))); 40 | 41 | RuleFor(v => v.GitHubUsername) 42 | .NotEmpty() 43 | .NotNull() 44 | .MaximumLength(StudentConsts.UsernameMaxLength) 45 | .MinimumLength(StudentConsts.UsernameMinLength) 46 | .MustAsync(IsGitHubUsernameUnique) 47 | .WithMessage("'{PropertyName}' must be unique.") 48 | .WithErrorCode("Unique"); 49 | } 50 | 51 | private async Task IsEmailUnique(UpdateStudentCommand model, Email email, CancellationToken token) 52 | { 53 | return await _studentRepository.IsEmailUnique(email, model.Id); 54 | } 55 | 56 | private async Task IsGitHubUsernameUnique(UpdateStudentCommand model, string gitHubUsername, CancellationToken token) 57 | { 58 | return await _studentRepository.IsGitHubUsernameUnique(gitHubUsername, model.Id); 59 | } 60 | } -------------------------------------------------------------------------------- /server/src/Application/Students/Handlers/CommandHandlers/CreateStudentHandler.cs: -------------------------------------------------------------------------------- 1 | using Application.Students.Commands; 2 | using Application.ResponseModels; 3 | using Domain.Interfaces; 4 | using Domain.Entities; 5 | using Mapster; 6 | using MediatR; 7 | 8 | namespace Application.Students.Handlers.CommandHandlers; 9 | 10 | public class CreateStudentHandler : IRequestHandler 11 | { 12 | private readonly IStudentRepository _studentRepository; 13 | public CreateStudentHandler(IStudentRepository studentRepository) 14 | { 15 | _studentRepository = studentRepository; 16 | } 17 | 18 | public async Task Handle(CreateStudentCommand request, CancellationToken cancellationToken) 19 | { 20 | Student toAdd = request.Adapt(); 21 | var result = await _studentRepository.Add(toAdd); 22 | await _studentRepository.SaveChanges(); 23 | 24 | return result.Adapt(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/src/Application/Students/Handlers/CommandHandlers/DeleteStudentHandler.cs: -------------------------------------------------------------------------------- 1 | using Application.Students.Commands; 2 | using Domain.Interfaces; 3 | using MediatR; 4 | 5 | namespace Application.Students.Handlers.CommandHandlers; 6 | 7 | public class DeleteStudentHandler : IRequestHandler 8 | { 9 | private readonly IStudentRepository _studentRepository; 10 | public DeleteStudentHandler(IStudentRepository studentRepository) 11 | { 12 | _studentRepository = studentRepository; 13 | } 14 | 15 | public async Task Handle(DeleteStudentCommand request, CancellationToken cancellationToken) 16 | { 17 | var toDelete = await _studentRepository.GetById(request.StudentId); 18 | if (toDelete is null) throw new ArgumentException("Student not found"); 19 | 20 | _studentRepository.Delete(toDelete); 21 | await _studentRepository.SaveChanges(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /server/src/Application/Students/Handlers/CommandHandlers/UpdateStudentHandler.cs: -------------------------------------------------------------------------------- 1 | using Application.Students.Commands; 2 | using Application.ResponseModels; 3 | using Domain.Interfaces; 4 | using Domain.Entities; 5 | using Mapster; 6 | using MediatR; 7 | 8 | namespace Application.Students.Handlers.CommandHandlers; 9 | 10 | public class UpdateStudentHandler : IRequestHandler 11 | { 12 | private readonly IStudentRepository _studentRepository; 13 | public UpdateStudentHandler(IStudentRepository studentRepository) 14 | { 15 | _studentRepository = studentRepository; 16 | } 17 | 18 | public async Task Handle(UpdateStudentCommand request, CancellationToken cancellationToken) 19 | { 20 | var student = await _studentRepository.GetById(request.Id); 21 | if (student is null) throw new Exception("Student not found"); 22 | 23 | Student toUpdate = request.Adapt(); 24 | 25 | var result = _studentRepository.Update(toUpdate); 26 | await _studentRepository.SaveChanges(); 27 | return result.Adapt(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /server/src/Application/Students/Handlers/QueryHandlers/GetAllStudentsHandler.cs: -------------------------------------------------------------------------------- 1 | using Application.Students.Queries; 2 | using Application.ResponseModels; 3 | using Domain.Interfaces; 4 | using Mapster; 5 | using MediatR; 6 | 7 | namespace Application.Students.Handlers.QueryHandlers; 8 | 9 | public class GetAllStudentsHandler : IRequestHandler> 10 | { 11 | private readonly IStudentRepository _studentRepository; 12 | public GetAllStudentsHandler(IStudentRepository studentRepository) 13 | { 14 | _studentRepository = studentRepository; 15 | } 16 | public async Task> Handle(GetAllStudentsQuery request, CancellationToken cancellationToken) 17 | { 18 | var result = await _studentRepository.GetAll(); 19 | return result.Adapt>(); 20 | } 21 | } -------------------------------------------------------------------------------- /server/src/Application/Students/Handlers/QueryHandlers/GetStudentByIdHandler.cs: -------------------------------------------------------------------------------- 1 | using Application.Students.Queries; 2 | using Application.ResponseModels; 3 | using Domain.Interfaces; 4 | using Mapster; 5 | using MediatR; 6 | 7 | namespace Application.Students.Handlers.QueryHandlers; 8 | 9 | public class GetStudentByIdHandler : IRequestHandler 10 | { 11 | private readonly IStudentRepository _studentRepository; 12 | public GetStudentByIdHandler(IStudentRepository studentRepository) 13 | { 14 | _studentRepository = studentRepository; 15 | } 16 | public async Task Handle(GetStudentByIdQuery request, CancellationToken cancellationToken) 17 | { 18 | var result = await _studentRepository.GetById(request.StudentId); 19 | return result.Adapt(); 20 | } 21 | } -------------------------------------------------------------------------------- /server/src/Application/Students/Queries/GetAllStudentsQuery.cs: -------------------------------------------------------------------------------- 1 | using Application.ResponseModels; 2 | using MediatR; 3 | 4 | namespace Application.Students.Queries; 5 | 6 | public record GetAllStudentsQuery : IRequest>; -------------------------------------------------------------------------------- /server/src/Application/Students/Queries/GetStudentByIdQuery.cs: -------------------------------------------------------------------------------- 1 | using Application.ResponseModels; 2 | using MediatR; 3 | 4 | namespace Application.Students.Queries; 5 | 6 | public record GetStudentByIdQuery(long StudentId) : IRequest; -------------------------------------------------------------------------------- /server/src/Domain/Common/AuditableEntityBase.cs: -------------------------------------------------------------------------------- 1 | namespace Domain.Common; 2 | 3 | public abstract record AuditableEntityBase : EntityBase 4 | { 5 | public DateTimeOffset CreatedAt { get; set; } 6 | 7 | public long CreatedBy { get; set; } 8 | 9 | public DateTimeOffset LastUpdatedAt { get; set; } 10 | 11 | public long LastUpdatedBy { get; set; } 12 | } 13 | -------------------------------------------------------------------------------- /server/src/Domain/Common/EntityBase.cs: -------------------------------------------------------------------------------- 1 | namespace Domain.Common; 2 | 3 | public abstract record EntityBase 4 | { 5 | public long Id { get; set; } 6 | } 7 | -------------------------------------------------------------------------------- /server/src/Domain/Domain.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /server/src/Domain/Entities/Student.cs: -------------------------------------------------------------------------------- 1 | using Domain.ValueObjects; 2 | using Domain.Common; 3 | 4 | namespace Domain.Entities; 5 | 6 | public record Student : EntityBase 7 | { 8 | public string FirstName { get; set; } = null!; 9 | 10 | public string LastName { get; set; } = null!; 11 | 12 | public Email Email { get; set; } = null!; 13 | 14 | public DateOnly BirthDate { get; set; } 15 | 16 | public string GitHubUsername { get; set; } = null!; 17 | } -------------------------------------------------------------------------------- /server/src/Domain/Interfaces/IStudentRepository.cs: -------------------------------------------------------------------------------- 1 | using Domain.ValueObjects; 2 | using Domain.Entities; 3 | 4 | namespace Domain.Interfaces; 5 | 6 | public interface IStudentRepository 7 | { 8 | Task> GetAll(); 9 | 10 | Task GetById(long id); 11 | 12 | Task Add(Student model); 13 | 14 | Student Update(Student model); 15 | 16 | void Delete(Student model); 17 | 18 | Task SaveChanges(); 19 | 20 | Task IsEmailUnique(Email email); 21 | 22 | Task IsEmailUnique(Email email, long studentId); 23 | 24 | Task IsGitHubUsernameUnique(string gitHubUsername); 25 | 26 | Task IsGitHubUsernameUnique(string gitHubUsername, long studentId); 27 | } -------------------------------------------------------------------------------- /server/src/Domain/ValueObjects/Email.cs: -------------------------------------------------------------------------------- 1 | namespace Domain.ValueObjects; 2 | 3 | public record Email 4 | { 5 | public string Value { get; private set; } 6 | 7 | public Email(string value) 8 | { 9 | if (!IsEmailValid(value)) 10 | throw new InvalidDataException("Email is invalid"); 11 | 12 | Value = value.Trim().ToLower(); 13 | } 14 | 15 | bool IsEmailValid(string value) 16 | { 17 | if (string.IsNullOrWhiteSpace(value)) 18 | return false; 19 | if (!value.Contains('@')) 20 | return false; 21 | if (value.EndsWith(".")) 22 | return false; 23 | 24 | try 25 | { 26 | var addr = new System.Net.Mail.MailAddress(value); 27 | return addr.Address == value.Trim(); 28 | } 29 | catch 30 | { 31 | return false; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /server/src/Infrastructure/Configurations/StudentConfiguration.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Metadata.Builders; 2 | using Microsoft.EntityFrameworkCore; 3 | using Domain.ValueObjects; 4 | using Domain.Entities; 5 | using Application.Consts; 6 | 7 | namespace Infrastructure.Configurations; 8 | 9 | public class StudentConfiguration : IEntityTypeConfiguration 10 | { 11 | public void Configure(EntityTypeBuilder builder) 12 | { 13 | builder.HasKey(x => x.Id); 14 | 15 | builder.Property(x => x.FirstName) 16 | .HasMaxLength(StudentConsts.NameMaxLength) 17 | .IsRequired(); 18 | 19 | builder.Property(x => x.LastName) 20 | .HasMaxLength(StudentConsts.NameMaxLength) 21 | .IsRequired(); 22 | 23 | builder.Property(x => x.Email) 24 | .HasConversion( 25 | e => e.Value, 26 | value => new Email(value)) 27 | .HasMaxLength(StudentConsts.EmailMaxLength) 28 | .IsRequired(); 29 | 30 | builder.HasIndex(x => x.Email) 31 | .IsUnique(); 32 | 33 | builder.Property(x => x.BirthDate) 34 | .IsRequired(); 35 | 36 | builder.Property(x => x.GitHubUsername) 37 | .HasMaxLength(StudentConsts.UsernameMaxLength) 38 | .IsRequired(); 39 | 40 | builder.HasIndex(x => x.GitHubUsername) 41 | .IsUnique(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /server/src/Infrastructure/DbContexts/StudentManagementDBContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using System.Reflection; 3 | using Domain.Entities; 4 | 5 | namespace Infrastructure.DbContexts; 6 | 7 | public class StudentManagementDBContext : DbContext 8 | { 9 | public StudentManagementDBContext(DbContextOptions options) : base(options) { } 10 | 11 | public DbSet Students => Set(); 12 | 13 | protected override void OnModelCreating(ModelBuilder modelBuilder) 14 | { 15 | modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly()); 16 | } 17 | } -------------------------------------------------------------------------------- /server/src/Infrastructure/DependencyInjection.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | using Microsoft.EntityFrameworkCore; 3 | using Infrastructure.Repositories; 4 | using Infrastructure.DbContexts; 5 | using Domain.Interfaces; 6 | 7 | namespace Microsoft.Extensions.DependencyInjection; 8 | 9 | public static class DependencyInjection 10 | { 11 | public static IServiceCollection AddInfrastructureServices(this IServiceCollection services, IConfiguration configuration) 12 | { 13 | // Setting DBContexts 14 | var connectionString = configuration.GetConnectionString("DefaultConnection"); 15 | services.AddDbContext(options => options.UseNpgsql(connectionString, o => o.UseNodaTime())); 16 | services.AddHealthChecks().AddNpgSql(connectionString, "StudentManagementDB"); 17 | 18 | AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); 19 | 20 | services.AddScoped(); 21 | 22 | return services; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /server/src/Infrastructure/Infrastructure.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | all 14 | runtime; build; native; contentfiles; analyzers; buildtransitive 15 | 16 | 17 | all 18 | runtime; build; native; contentfiles; analyzers; buildtransitive 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /server/src/Infrastructure/Migrations/20240521092028_v1.Designer.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Infrastructure.DbContexts; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Migrations; 7 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 8 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 9 | 10 | #nullable disable 11 | 12 | namespace Infrastructure.Migrations 13 | { 14 | [DbContext(typeof(StudentManagementDBContext))] 15 | [Migration("20240521092028_v1")] 16 | partial class v1 17 | { 18 | /// 19 | protected override void BuildTargetModel(ModelBuilder modelBuilder) 20 | { 21 | #pragma warning disable 612, 618 22 | modelBuilder 23 | .HasAnnotation("ProductVersion", "8.0.4") 24 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 25 | 26 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 27 | 28 | modelBuilder.Entity("Domain.Entities.Student", b => 29 | { 30 | b.Property("Id") 31 | .ValueGeneratedOnAdd() 32 | .HasColumnType("bigint"); 33 | 34 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 35 | 36 | b.Property("BirthDate") 37 | .HasColumnType("date"); 38 | 39 | b.Property("Email") 40 | .IsRequired() 41 | .HasMaxLength(320) 42 | .HasColumnType("character varying(320)"); 43 | 44 | b.Property("FirstName") 45 | .IsRequired() 46 | .HasMaxLength(50) 47 | .HasColumnType("character varying(50)"); 48 | 49 | b.Property("GitHubUsername") 50 | .IsRequired() 51 | .HasMaxLength(40) 52 | .HasColumnType("character varying(40)"); 53 | 54 | b.Property("LastName") 55 | .IsRequired() 56 | .HasMaxLength(50) 57 | .HasColumnType("character varying(50)"); 58 | 59 | b.HasKey("Id"); 60 | 61 | b.HasIndex("Email") 62 | .IsUnique(); 63 | 64 | b.HasIndex("GitHubUsername") 65 | .IsUnique(); 66 | 67 | b.ToTable("Students"); 68 | }); 69 | #pragma warning restore 612, 618 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /server/src/Infrastructure/Migrations/20240521092028_v1.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Migrations; 3 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 4 | 5 | #nullable disable 6 | 7 | namespace Infrastructure.Migrations 8 | { 9 | /// 10 | public partial class v1 : Migration 11 | { 12 | /// 13 | protected override void Up(MigrationBuilder migrationBuilder) 14 | { 15 | migrationBuilder.CreateTable( 16 | name: "Students", 17 | columns: table => new 18 | { 19 | Id = table.Column(type: "bigint", nullable: false) 20 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), 21 | FirstName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), 22 | LastName = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), 23 | Email = table.Column(type: "character varying(320)", maxLength: 320, nullable: false), 24 | BirthDate = table.Column(type: "date", nullable: false), 25 | GitHubUsername = table.Column(type: "character varying(40)", maxLength: 40, nullable: false) 26 | }, 27 | constraints: table => 28 | { 29 | table.PrimaryKey("PK_Students", x => x.Id); 30 | }); 31 | 32 | migrationBuilder.CreateIndex( 33 | name: "IX_Students_Email", 34 | table: "Students", 35 | column: "Email", 36 | unique: true); 37 | 38 | migrationBuilder.CreateIndex( 39 | name: "IX_Students_GitHubUsername", 40 | table: "Students", 41 | column: "GitHubUsername", 42 | unique: true); 43 | } 44 | 45 | /// 46 | protected override void Down(MigrationBuilder migrationBuilder) 47 | { 48 | migrationBuilder.DropTable( 49 | name: "Students"); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /server/src/Infrastructure/Migrations/StudentManagementDBContextModelSnapshot.cs: -------------------------------------------------------------------------------- 1 | // 2 | using System; 3 | using Infrastructure.DbContexts; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Infrastructure; 6 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 7 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 8 | 9 | #nullable disable 10 | 11 | namespace Infrastructure.Migrations 12 | { 13 | [DbContext(typeof(StudentManagementDBContext))] 14 | partial class StudentManagementDBContextModelSnapshot : ModelSnapshot 15 | { 16 | protected override void BuildModel(ModelBuilder modelBuilder) 17 | { 18 | #pragma warning disable 612, 618 19 | modelBuilder 20 | .HasAnnotation("ProductVersion", "8.0.4") 21 | .HasAnnotation("Relational:MaxIdentifierLength", 63); 22 | 23 | NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); 24 | 25 | modelBuilder.Entity("Domain.Entities.Student", b => 26 | { 27 | b.Property("Id") 28 | .ValueGeneratedOnAdd() 29 | .HasColumnType("bigint"); 30 | 31 | NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); 32 | 33 | b.Property("BirthDate") 34 | .HasColumnType("date"); 35 | 36 | b.Property("Email") 37 | .IsRequired() 38 | .HasMaxLength(320) 39 | .HasColumnType("character varying(320)"); 40 | 41 | b.Property("FirstName") 42 | .IsRequired() 43 | .HasMaxLength(50) 44 | .HasColumnType("character varying(50)"); 45 | 46 | b.Property("GitHubUsername") 47 | .IsRequired() 48 | .HasMaxLength(40) 49 | .HasColumnType("character varying(40)"); 50 | 51 | b.Property("LastName") 52 | .IsRequired() 53 | .HasMaxLength(50) 54 | .HasColumnType("character varying(50)"); 55 | 56 | b.HasKey("Id"); 57 | 58 | b.HasIndex("Email") 59 | .IsUnique(); 60 | 61 | b.HasIndex("GitHubUsername") 62 | .IsUnique(); 63 | 64 | b.ToTable("Students"); 65 | }); 66 | #pragma warning restore 612, 618 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /server/src/Infrastructure/Repositories/StudentRepository.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Infrastructure.DbContexts; 3 | using Domain.ValueObjects; 4 | using Domain.Interfaces; 5 | using Domain.Entities; 6 | 7 | namespace Infrastructure.Repositories 8 | { 9 | public class StudentRepository : IStudentRepository 10 | { 11 | private readonly StudentManagementDBContext _dbContext; 12 | public StudentRepository(StudentManagementDBContext dbContext) 13 | { 14 | _dbContext = dbContext; 15 | } 16 | 17 | public async Task> GetAll() 18 | { 19 | return await _dbContext.Students.ToListAsync(); 20 | } 21 | 22 | public async Task GetById(long id) 23 | { 24 | return await _dbContext.Students.AsNoTracking().FirstOrDefaultAsync(c => c.Id == id); 25 | } 26 | 27 | public async Task Add(Student model) 28 | { 29 | var result = await _dbContext.Students.AddAsync(model); 30 | return result.Entity; 31 | } 32 | 33 | public Student Update(Student model) 34 | { 35 | var result = _dbContext.Students.Update(model); 36 | return result.Entity; 37 | } 38 | 39 | public void Delete(Student model) 40 | { 41 | _dbContext.Students.Remove(model); 42 | } 43 | 44 | public async Task SaveChanges() 45 | { 46 | await _dbContext.SaveChangesAsync(); 47 | } 48 | 49 | public async Task IsEmailUnique(Email email) 50 | { 51 | return !await _dbContext.Students.AnyAsync(c => c.Email == email); 52 | } 53 | 54 | public async Task IsEmailUnique(Email email, long studentId) 55 | { 56 | return !await _dbContext.Students.AnyAsync(c => c.Email == email && c.Id != studentId); 57 | } 58 | 59 | public async Task IsGitHubUsernameUnique(string gitHubUsername) 60 | { 61 | return !await _dbContext.Students.AnyAsync(c => c.GitHubUsername.ToLower() == gitHubUsername.ToLower()); 62 | } 63 | 64 | public async Task IsGitHubUsernameUnique(string gitHubUsername, long studentId) 65 | { 66 | return !await _dbContext.Students.AnyAsync(c => c.GitHubUsername.ToLower() == gitHubUsername.ToLower() && c.Id != studentId); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /server/src/WebAPI/Controllers/StudentsController.cs: -------------------------------------------------------------------------------- 1 | using Application.Students.Commands; 2 | using Application.Students.Queries; 3 | using Application.RequestModels; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Mapster; 6 | using MediatR; 7 | 8 | namespace WebAPI.Controllers; 9 | 10 | [Route("api/students")] 11 | [ApiController] 12 | public class StudentsController : ControllerBase 13 | { 14 | private readonly IMediator _mediator; 15 | public StudentsController(IMediator mediator) 16 | { 17 | _mediator = mediator; 18 | } 19 | 20 | [HttpGet] 21 | public async Task Get() 22 | { 23 | var query = new GetAllStudentsQuery(); 24 | var response = await _mediator.Send(query); 25 | return Ok(response); 26 | } 27 | 28 | [HttpGet("{studentId:long}")] 29 | public async Task Get(long studentId) 30 | { 31 | var query = new GetStudentByIdQuery(studentId); 32 | var response = await _mediator.Send(query); 33 | return Ok(response); 34 | } 35 | 36 | [HttpPost] 37 | public async Task Post([FromBody] CreateStudentRequest request) 38 | { 39 | var command = request.Adapt(); 40 | var response = await _mediator.Send(command); 41 | return Ok(response); 42 | } 43 | 44 | [HttpPut("{studentId:long}")] 45 | public async Task Put(long studentId, [FromBody] UpdateStudentRequest request) 46 | { 47 | var command = request.Adapt(); 48 | if (studentId != command.Id) return BadRequest(); 49 | var response = await _mediator.Send(command); 50 | return Ok(response); 51 | } 52 | 53 | [HttpDelete("{studentId:long}")] 54 | public async Task Delete(long studentId) 55 | { 56 | var command = new DeleteStudentCommand(studentId); 57 | await _mediator.Send(command); 58 | return Ok(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /server/src/WebAPI/Middleware/GlobalExceptionHandler.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Diagnostics; 2 | using Application.ResponseModels; 3 | using System.Net; 4 | 5 | namespace WebAPI.Middleware; 6 | 7 | public class GlobalExceptionHandler : IExceptionHandler 8 | { 9 | private readonly ILogger _logger; 10 | 11 | public GlobalExceptionHandler(ILogger logger) 12 | { 13 | _logger = logger; 14 | } 15 | 16 | public async ValueTask TryHandleAsync( 17 | HttpContext httpContext, 18 | Exception exception, 19 | CancellationToken cancellationToken) 20 | { 21 | _logger.LogError( 22 | $"An error occurred while processing your request: {exception.Message}"); 23 | 24 | var errorResponse = new ErrorResponse 25 | { 26 | Message = exception.Message 27 | }; 28 | 29 | switch (exception) 30 | { 31 | case BadHttpRequestException: 32 | errorResponse.StatusCode = (int)HttpStatusCode.BadRequest; 33 | errorResponse.Title = exception.GetType().Name + "://"; 34 | break; 35 | 36 | default: 37 | errorResponse.StatusCode = (int)HttpStatusCode.InternalServerError; 38 | errorResponse.Title = "Internal Server Error ://"; 39 | break; 40 | } 41 | 42 | httpContext.Response.StatusCode = errorResponse.StatusCode; 43 | 44 | await httpContext 45 | .Response 46 | .WriteAsJsonAsync(errorResponse, cancellationToken); 47 | 48 | return true; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /server/src/WebAPI/Program.cs: -------------------------------------------------------------------------------- 1 | using Application; 2 | using WebAPI.Middleware; 3 | 4 | var builder = WebApplication.CreateBuilder(args); 5 | 6 | builder.Services.AddApplicationServices(); 7 | builder.Services.AddInfrastructureServices(builder.Configuration); 8 | builder.Services.AddExceptionHandler(); 9 | builder.Services.AddProblemDetails(); 10 | 11 | builder.Services.AddControllers(); 12 | builder.Services.AddEndpointsApiExplorer(); 13 | builder.Services.AddSwaggerGen(); 14 | 15 | var app = builder.Build(); 16 | 17 | // Enable CORS for the client app running inside a Docker container 18 | app.UseCors(options => options.WithOrigins("http://localhost:8080").AllowAnyHeader().AllowAnyMethod()); 19 | 20 | // Enable CORS for the client app running on localhost 21 | app.UseCors(options => options.WithOrigins("https://localhost:7147").AllowAnyHeader().AllowAnyMethod()); 22 | 23 | if (app.Environment.IsDevelopment()) 24 | { 25 | app.UseSwagger(); 26 | app.UseSwaggerUI(); 27 | } 28 | 29 | app.UseHttpsRedirection(); 30 | 31 | app.UseAuthorization(); 32 | 33 | app.MapControllers(); 34 | 35 | app.Run(); 36 | 37 | public partial class Program { } -------------------------------------------------------------------------------- /server/src/WebAPI/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:23657", 8 | "sslPort": 44306 9 | } 10 | }, 11 | "profiles": { 12 | "http": { 13 | "commandName": "Project", 14 | "dotnetRunMessages": true, 15 | "launchBrowser": true, 16 | "launchUrl": "swagger", 17 | "applicationUrl": "http://localhost:5127", 18 | "environmentVariables": { 19 | "ASPNETCORE_ENVIRONMENT": "Development" 20 | } 21 | }, 22 | "https": { 23 | "commandName": "Project", 24 | "dotnetRunMessages": true, 25 | "launchBrowser": true, 26 | "launchUrl": "swagger", 27 | "applicationUrl": "https://localhost:7120;http://localhost:5127", 28 | "environmentVariables": { 29 | "ASPNETCORE_ENVIRONMENT": "Development" 30 | } 31 | }, 32 | "IIS Express": { 33 | "commandName": "IISExpress", 34 | "launchBrowser": true, 35 | "launchUrl": "swagger", 36 | "environmentVariables": { 37 | "ASPNETCORE_ENVIRONMENT": "Development" 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /server/src/WebAPI/RequestExamples.http: -------------------------------------------------------------------------------- 1 | @WebAPI_HostAddress = https://localhost:7120 2 | 3 | ### Get All Students 4 | GET {{WebAPI_HostAddress}}/api/students/ 5 | Accept: application/json 6 | 7 | ### Get Student By Id 8 | GET {{WebAPI_HostAddress}}/api/students/1 9 | Accept: application/json 10 | 11 | ### Create a new student 12 | POST {{WebAPI_HostAddress}}/api/students/ 13 | Content-Type: application/json 14 | 15 | { 16 | "FirstName": "Emma", 17 | "LastName": "Bovary", 18 | "Email": { 19 | "Value": "emmabovary@gmail.com" 20 | }, 21 | "BirthDate": "2004-10-01", 22 | "GitHubUsername": "emma-bovary" 23 | } 24 | -------------------------------------------------------------------------------- /server/src/WebAPI/WebAPI.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | true 8 | 9 | 10 | 11 | 12 | all 13 | runtime; build; native; contentfiles; analyzers; buildtransitive 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /server/src/WebAPI/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /server/src/WebAPI/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | // Connect to the database in the Docker container 4 | "DefaultConnection": "Host=localhost;Port=5433;Database=StudentManagementDB;Username=postgres;Password=mysecretpassword;" 5 | }, 6 | "Logging": { 7 | "LogLevel": { 8 | "Default": "Information", 9 | "Microsoft.AspNetCore": "Warning" 10 | } 11 | }, 12 | "AllowedHosts": "*" 13 | } 14 | -------------------------------------------------------------------------------- /server/tests/Application.Tests.Integration/Application.Tests.Integration.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | all 21 | 22 | 23 | runtime; build; native; contentfiles; analyzers; buildtransitive 24 | all 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /server/tests/Application.Tests.Integration/BaseHandlerTest.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using MediatR; 3 | 4 | namespace Application.Tests.Integration; 5 | 6 | public class BaseHandlerTest : IClassFixture 7 | { 8 | private readonly IServiceScope _serviceScope; 9 | protected readonly ISender _sender; 10 | protected BaseHandlerTest(IntegrationTestWebApplicationFactory factory) 11 | { 12 | _serviceScope = factory.Services.CreateScope(); 13 | _sender = _serviceScope.ServiceProvider.GetRequiredService(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /server/tests/Application.Tests.Integration/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /server/tests/Application.Tests.Integration/IntegrationTestWebApplicationFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.AspNetCore.Mvc.Testing; 3 | using Microsoft.AspNetCore.TestHost; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Infrastructure.DbContexts; 7 | using Testcontainers.PostgreSql; 8 | 9 | namespace Application.Tests.Integration; 10 | 11 | public class IntegrationTestWebApplicationFactory : WebApplicationFactory, IAsyncLifetime 12 | { 13 | private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder() 14 | .WithImage("postgres:latest") 15 | .WithDatabase("StudentManagementDB_Test") 16 | .WithUsername("postgres") 17 | .WithPassword("mysecretpassword") 18 | .Build(); 19 | 20 | public async Task InitializeAsync() 21 | { 22 | await _dbContainer.StartAsync(); 23 | 24 | using (var scope = Services.CreateScope()) 25 | { 26 | var scopedServices = scope.ServiceProvider; 27 | var cntx = scopedServices.GetRequiredService(); 28 | 29 | await cntx.Database.EnsureCreatedAsync(); 30 | } 31 | } 32 | 33 | public new async Task DisposeAsync() 34 | { 35 | await _dbContainer.StopAsync(); 36 | } 37 | 38 | protected override void ConfigureWebHost(IWebHostBuilder builder) 39 | { 40 | builder.ConfigureTestServices(services => 41 | { 42 | var descriptor = services.SingleOrDefault(s => s.ServiceType == typeof(DbContextOptions)); 43 | 44 | if (descriptor is not null) 45 | { 46 | services.Remove(descriptor); 47 | } 48 | 49 | services.AddDbContext(options => 50 | { 51 | options.UseNpgsql(_dbContainer.GetConnectionString()); 52 | }); 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /server/tests/Application.Tests.Integration/StudentHandlersTests/StudentHandlersTests.cs: -------------------------------------------------------------------------------- 1 | using Application.Students.Commands; 2 | using Application.Students.Queries; 3 | using Domain.ValueObjects; 4 | 5 | namespace Application.Tests.Integration.StudentHandlersTests; 6 | 7 | public class StudentHandlersTests : BaseHandlerTest 8 | { 9 | public StudentHandlersTests(IntegrationTestWebApplicationFactory factory) : base(factory) { } 10 | 11 | [Fact] 12 | public async void GetAllStudentsHandler_Returns_StudentResponse() 13 | { 14 | // Arrange 15 | var query = new GetAllStudentsQuery(); 16 | 17 | // Act 18 | var result = await _sender.Send(query); 19 | 20 | // Assert 21 | Assert.NotNull(result); 22 | } 23 | 24 | [Fact] 25 | public async void CreateStudentCommand_Returns_StudentResponse() 26 | { 27 | // Arrange 28 | var command = new CreateStudentCommand( 29 | FirstName: "Danial", 30 | LastName: "Moradi", 31 | Email: new Email("danial@gmail.com"), 32 | BirthDate: new DateOnly(2000, 3, 2), 33 | GitHubUsername: "dani"); 34 | 35 | // Act 36 | var result = await _sender.Send(command); 37 | 38 | // Assert 39 | Assert.NotNull(result); 40 | Assert.Equal(command.FirstName, result.FirstName); 41 | Assert.Equal(command.Email, result.Email); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /server/tests/Application.Tests.Unit/Application.Tests.Unit.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | runtime; build; native; contentfiles; analyzers; buildtransitive 19 | all 20 | 21 | 22 | runtime; build; native; contentfiles; analyzers; buildtransitive 23 | all 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /server/tests/Application.Tests.Unit/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Application.Students.Handlers.CommandHandlers; 2 | global using Application.Students.Handlers.QueryHandlers; 3 | global using Application.Students.Commands; 4 | global using Application.Students.Queries; 5 | global using Application.ResponseModels; 6 | global using Domain.ValueObjects; 7 | global using Domain.Interfaces; 8 | global using Domain.Entities; 9 | global using NSubstitute; 10 | global using Xunit; 11 | global using Bogus; 12 | -------------------------------------------------------------------------------- /server/tests/Application.Tests.Unit/StudentHandlersTests/CreateStudentHandlerTests.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Tests.Unit.StudentHandlersTests; 2 | 3 | public class CreateStudentHandlerTests 4 | { 5 | [Fact] 6 | public async void CreateStudentHandler_Returns_StudentResponse() 7 | { 8 | // Arrange 9 | var fakeRepository = Substitute.For(); 10 | 11 | var responseStudent = new Student 12 | { 13 | Id = 1, 14 | FirstName = "Sara", 15 | LastName = "Rasoulian", 16 | Email = new Email("sara@gmail.com"), 17 | BirthDate = new DateOnly(1900, 1, 1), 18 | GitHubUsername = "sara90", 19 | }; 20 | 21 | fakeRepository.Add(Arg.Any()).Returns(responseStudent); 22 | 23 | var command = new CreateStudentCommand( 24 | FirstName: "Sara", 25 | LastName: "Rasoulian", 26 | BirthDate: new DateOnly(1900, 1, 1), 27 | Email: new Email("sara@gmail.com"), 28 | GitHubUsername: "sara90"); 29 | 30 | var handler = new CreateStudentHandler(fakeRepository); 31 | 32 | // Act 33 | var result = await handler.Handle(command, default); 34 | 35 | // Assert 36 | Assert.NotNull(result); 37 | Assert.Equal(command.FirstName, result.FirstName); 38 | Assert.Equal(command.Email.Value, result.Email.Value); 39 | await fakeRepository.Received(1).SaveChanges(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /server/tests/Application.Tests.Unit/StudentHandlersTests/DeleteStudentHandlerTests.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Tests.Unit.StudentHandlersTests; 2 | 3 | public class DeleteStudentHandlerTests 4 | { 5 | [Fact] 6 | public async Task DeleteStudentHandler_With_Valid_StudentId_Deletes_Student() 7 | { 8 | // Arrange 9 | var studentId = 1; 10 | var mockRepository = Substitute.For(); 11 | var student = new Student { Id = studentId }; 12 | 13 | mockRepository.GetById(studentId).Returns(student); 14 | 15 | var handler = new DeleteStudentHandler(mockRepository); 16 | var command = new DeleteStudentCommand(studentId); 17 | 18 | // Act 19 | await handler.Handle(command, default); 20 | 21 | // Assert 22 | mockRepository.Received(1).Delete(Arg.Any()); 23 | await mockRepository.Received(1).SaveChanges(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /server/tests/Application.Tests.Unit/StudentHandlersTests/GetAllStudentsHandlerTests.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Tests.Unit.StudentHandlersTests; 2 | 3 | public class GetAllStudentsHandlerTests 4 | { 5 | [Fact] 6 | public async Task GetAllStudentsHandler_Returns_Students() 7 | { 8 | // Arrange 9 | var fakeRepository = Substitute.For(); 10 | 11 | byte fakeStudentCount = 4; 12 | var fakeStudents = new Faker().Generate(fakeStudentCount); 13 | 14 | fakeRepository.GetAll().Returns(fakeStudents); 15 | 16 | var handler = new GetAllStudentsHandler(fakeRepository); 17 | var query = new GetAllStudentsQuery(); 18 | 19 | // Act 20 | var result = await handler.Handle(query, default); 21 | 22 | // Assert 23 | Assert.NotNull(result); 24 | Assert.IsAssignableFrom>(result); 25 | Assert.Equal(fakeStudentCount, (result as List).Count); 26 | } 27 | } -------------------------------------------------------------------------------- /server/tests/Application.Tests.Unit/StudentHandlersTests/GetStudentByIdHandlerTests.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Tests.Unit.StudentHandlersTests; 2 | 3 | public class GetStudentByIdHandlerTests 4 | { 5 | [Fact] 6 | public async Task GetStudentByIdHandler_With_ValidId_Returns_StudentResponse() 7 | { 8 | // Arrange 9 | var studentId = 1; 10 | var fakeStudent = new Faker() 11 | .RuleFor(c => c.Id, studentId) 12 | .RuleFor(c => c.FirstName, f => f.Person.FirstName).Generate(); 13 | 14 | var mockRepository = Substitute.For(); 15 | mockRepository.GetById(studentId).Returns(fakeStudent); 16 | 17 | var handler = new GetStudentByIdHandler(mockRepository); 18 | var query = new GetStudentByIdQuery(studentId); 19 | 20 | // Act 21 | var result = await handler.Handle(query, default); 22 | 23 | // Assert 24 | Assert.NotNull(result); 25 | Assert.Equal(studentId, result.Id); 26 | Assert.Equal(result.FirstName, fakeStudent.FirstName); 27 | } 28 | 29 | [Fact] 30 | public async Task GetStudentByIdHandler_With_InvalidId_Returns_Null() 31 | { 32 | // Arrange 33 | long studentId = 999; 34 | 35 | var mockRepository = Substitute.For(); 36 | mockRepository.GetById(studentId) 37 | .Returns(Task.FromResult(null)); 38 | 39 | var handler = new GetStudentByIdHandler(mockRepository); 40 | var query = new GetStudentByIdQuery(studentId); 41 | 42 | // Act 43 | var result = await handler.Handle(query, default); 44 | 45 | // Assert 46 | Assert.Null(result); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /server/tests/Application.Tests.Unit/StudentHandlersTests/UpdateStudentHandlerTests.cs: -------------------------------------------------------------------------------- 1 | namespace Application.Tests.Unit.StudentHandlersTests; 2 | 3 | public class UpdateStudentHandlerTests 4 | { 5 | [Fact] 6 | public async Task UpdateStudentHandler_Returns_StudentResponse() 7 | { 8 | // Arrange 9 | var studentId = 1; 10 | var updateCommand = new UpdateStudentCommand( 11 | Id: studentId, 12 | FirstName: "Sara", 13 | LastName: "Rasoulian", 14 | BirthDate: new DateOnly(1900, 1, 1), 15 | Email: new Email("sara@gmail.com"), 16 | GitHubUsername: "sara90"); 17 | 18 | var existingStudent = new Student 19 | { 20 | Id = studentId, 21 | FirstName = "Sara", 22 | LastName = "Rasoulian", 23 | Email = new Email("sara@gmail.com"), 24 | BirthDate = new DateOnly(1900, 1, 1), 25 | GitHubUsername = "sara90", 26 | }; 27 | 28 | var mockRepository = Substitute.For(); 29 | mockRepository.GetById(studentId).Returns(existingStudent); 30 | 31 | var handler = new UpdateStudentHandler(mockRepository); 32 | 33 | // Act 34 | await handler.Handle(updateCommand, default); 35 | 36 | // Assert 37 | mockRepository.Received(1).Update(Arg.Any()); 38 | await mockRepository.Received(1).SaveChanges(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /server/tests/Domain.Tests.Unit/Domain.Tests.Unit.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | all 18 | 19 | 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | all 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /server/tests/Domain.Tests.Unit/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /server/tests/Domain.Tests.Unit/ValueObjectsTests/EmailTests.cs: -------------------------------------------------------------------------------- 1 | using Domain.ValueObjects; 2 | 3 | namespace Domain.UnitTests.ValueObjectsTests; 4 | 5 | public class EmailTests 6 | { 7 | [Fact] 8 | public void Valid_Email_Returns_Email() 9 | { 10 | // Arrange 11 | string validEmail = "example@gmail.com"; 12 | 13 | // Act 14 | Email email = new Email(validEmail); 15 | 16 | // Assert 17 | Assert.Equal(validEmail, email.Value); 18 | } 19 | 20 | [Theory] 21 | [InlineData("invalidexample.com")] 22 | [InlineData(".invalid@example.com")] 23 | [InlineData("@invalid")] 24 | [InlineData("invalid@.com")] 25 | public void Invalid_Email_Throws_InvalidDataException(string invalidEmail) 26 | { 27 | // Act 28 | Action act = () => new Email(invalidEmail); 29 | 30 | // Assert 31 | Assert.Throws(act); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /server/tests/WebAPI.Tests.Acceptance/BaseAcceptanceTest.cs: -------------------------------------------------------------------------------- 1 | using WebAPI.Tests.Acceptance.Hooks; 2 | 3 | namespace WebAPI.Tests.Acceptance; 4 | 5 | public abstract class BaseAcceptanceTest : IClassFixture 6 | { 7 | protected readonly HttpClient _httpClient; 8 | 9 | protected BaseAcceptanceTest(AcceptanceTestWebApplicationFactory factory) 10 | { 11 | _httpClient = factory.CreateDefaultClient(); 12 | } 13 | } -------------------------------------------------------------------------------- /server/tests/WebAPI.Tests.Acceptance/Drivers/StudentManagementDriver.cs: -------------------------------------------------------------------------------- 1 | using WebAPI.Tests.Acceptance.Hooks; 2 | using WebAPI.Tests.Acceptance.Dtos; 3 | using TechTalk.SpecFlow.Assist; 4 | using System.Globalization; 5 | using System.Net.Http.Json; 6 | using Domain.ValueObjects; 7 | using TechTalk.SpecFlow; 8 | 9 | namespace WebAPI.Tests.Acceptance.Drivers; 10 | 11 | public class StudentManagementDriver : BaseAcceptanceTest 12 | { 13 | public StudentManagementDriver(AcceptanceTestWebApplicationFactory factory) : base(factory) 14 | { 15 | } 16 | 17 | public List GetAllStudents() 18 | { 19 | var response = _httpClient.GetAsync("/api/students").Result; 20 | return response.Content.ReadFromJsonAsync>().Result; 21 | } 22 | 23 | public HttpResponseMessage CreateStudent(Table student) 24 | { 25 | var request = ParseCreateStudentData(student); 26 | return _httpClient.PostAsJsonAsync("/api/students", request).Result; 27 | } 28 | 29 | public HttpResponseMessage UpdateStudent(long studentId, Table student) 30 | { 31 | var request = ParseCreateStudentData(student); 32 | request.Id = studentId; 33 | 34 | return _httpClient.PutAsJsonAsync("/api/students/" + studentId, request).Result; 35 | } 36 | 37 | public HttpResponseMessage DeleteStudent(long studentId) 38 | { 39 | return _httpClient.DeleteAsync("/api/students/" + studentId).Result; 40 | } 41 | 42 | public StudentDto ParseCreateStudentData(Table student) 43 | { 44 | var request = student.CreateInstance(); 45 | request.BirthDate = DateOnly.ParseExact(student.Rows[0]["BirthDate"], "yyyy-MM-dd", CultureInfo.InvariantCulture); 46 | request.Email = new Email(student.Rows[0]["Email"]); 47 | return request; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /server/tests/WebAPI.Tests.Acceptance/Dtos/StudentDto.cs: -------------------------------------------------------------------------------- 1 | using Domain.ValueObjects; 2 | 3 | namespace WebAPI.Tests.Acceptance.Dtos; 4 | 5 | public record StudentDto 6 | { 7 | public long Id { get; set; } 8 | 9 | public string FirstName { get; set; } = null!; 10 | 11 | public string LastName { get; set; } = null!; 12 | 13 | public Email Email { get; set; } = null!; 14 | 15 | public DateOnly BirthDate { get; set; } 16 | 17 | public string GitHubUsername { get; set; } = null!; 18 | } 19 | -------------------------------------------------------------------------------- /server/tests/WebAPI.Tests.Acceptance/Features/StudentManagement.feature: -------------------------------------------------------------------------------- 1 | Feature: StudentManagement 2 | 3 | Create, Read, Update, Delete Student 4 | 5 | Background: system error codes are following 6 | | Code | Description | 7 | | 500 | Internal Server Error | 8 | 9 | Scenario: User creates, reads, updates and deletes a student 10 | Given platform has "0" students 11 | When user creates a student with following data by sending 'Create Student Command' through API 12 | | FirstName | LastName | Email | BirthDate | GitHubUsername | 13 | | John | Doe | john@doe.com | 2004-01-01 | john-doe | 14 | Then user requests to get all the students and it must return "1" student with following data 15 | | FirstName | LastName | Email | BirthDate | GitHubUsername | 16 | | John | Doe | john@doe.com | 2004-01-01 | john-doe | 17 | When user creates another student with following data by sending 'Create Student Command' through API 18 | | FirstName | LastName | Email | BirthDate | GitHubUsername | 19 | | John | Doe | john@gmail.com | 2004-01-01 | john-doe | 20 | Then user must get status code "500" 21 | When user updates student by email "john@doe.com" with following data 22 | | FirstName | LastName | Email | BirthDate | GitHubUsername | 23 | | Jane | William | jane@william.com | 2000-01-01 | jane-will | 24 | Then user requests to get all the students and it must return "1" student 25 | | FirstName | LastName | Email | BirthDate | GitHubUsername | 26 | | Jane | William | jane@william.com | 2000-01-01 | jane-will | 27 | When user deletes student by Email of "jane@william.com" 28 | Then user requests to get all students and it must return "0" students -------------------------------------------------------------------------------- /server/tests/WebAPI.Tests.Acceptance/Features/StudentManagement.feature.cs: -------------------------------------------------------------------------------- 1 | // ------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by SpecFlow (https://www.specflow.org/). 4 | // SpecFlow Version:3.9.0.0 5 | // SpecFlow Generator Version:3.9.0.0 6 | // 7 | // Changes to this file may cause incorrect behavior and will be lost if 8 | // the code is regenerated. 9 | // 10 | // ------------------------------------------------------------------------------ 11 | #region Designer generated code 12 | #pragma warning disable 13 | namespace WebAPI.Tests.Acceptance.Features 14 | { 15 | using TechTalk.SpecFlow; 16 | using System; 17 | using System.Linq; 18 | 19 | 20 | [System.CodeDom.Compiler.GeneratedCodeAttribute("TechTalk.SpecFlow", "3.9.0.0")] 21 | [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 22 | public partial class StudentManagementFeature : object, Xunit.IClassFixture, System.IDisposable 23 | { 24 | 25 | private static TechTalk.SpecFlow.ITestRunner testRunner; 26 | 27 | private static string[] featureTags = ((string[])(null)); 28 | 29 | private Xunit.Abstractions.ITestOutputHelper _testOutputHelper; 30 | 31 | #line 1 "StudentManagement.feature" 32 | #line hidden 33 | 34 | public StudentManagementFeature(StudentManagementFeature.FixtureData fixtureData, WebAPI_Tests_Acceptance_XUnitAssemblyFixture assemblyFixture, Xunit.Abstractions.ITestOutputHelper testOutputHelper) 35 | { 36 | this._testOutputHelper = testOutputHelper; 37 | this.TestInitialize(); 38 | } 39 | 40 | public static void FeatureSetup() 41 | { 42 | testRunner = TechTalk.SpecFlow.TestRunnerManager.GetTestRunner(); 43 | TechTalk.SpecFlow.FeatureInfo featureInfo = new TechTalk.SpecFlow.FeatureInfo(new System.Globalization.CultureInfo("en-US"), "Features", "StudentManagement", "Create, Read, Update, Delete Student", ProgrammingLanguage.CSharp, featureTags); 44 | testRunner.OnFeatureStart(featureInfo); 45 | } 46 | 47 | public static void FeatureTearDown() 48 | { 49 | testRunner.OnFeatureEnd(); 50 | testRunner = null; 51 | } 52 | 53 | public void TestInitialize() 54 | { 55 | } 56 | 57 | public void TestTearDown() 58 | { 59 | testRunner.OnScenarioEnd(); 60 | } 61 | 62 | public void ScenarioInitialize(TechTalk.SpecFlow.ScenarioInfo scenarioInfo) 63 | { 64 | testRunner.OnScenarioInitialize(scenarioInfo); 65 | testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testOutputHelper); 66 | } 67 | 68 | public void ScenarioStart() 69 | { 70 | testRunner.OnScenarioStart(); 71 | } 72 | 73 | public void ScenarioCleanup() 74 | { 75 | testRunner.CollectScenarioErrors(); 76 | } 77 | 78 | public virtual void FeatureBackground() 79 | { 80 | #line 5 81 | #line hidden 82 | } 83 | 84 | void System.IDisposable.Dispose() 85 | { 86 | this.TestTearDown(); 87 | } 88 | 89 | [Xunit.SkippableFactAttribute(DisplayName="User creates, reads, updates and deletes a student")] 90 | [Xunit.TraitAttribute("FeatureTitle", "StudentManagement")] 91 | [Xunit.TraitAttribute("Description", "User creates, reads, updates and deletes a student")] 92 | public void UserCreatesReadsUpdatesAndDeletesAStudent() 93 | { 94 | string[] tagsOfScenario = ((string[])(null)); 95 | System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new System.Collections.Specialized.OrderedDictionary(); 96 | TechTalk.SpecFlow.ScenarioInfo scenarioInfo = new TechTalk.SpecFlow.ScenarioInfo("User creates, reads, updates and deletes a student", null, tagsOfScenario, argumentsOfScenario, featureTags); 97 | #line 9 98 | this.ScenarioInitialize(scenarioInfo); 99 | #line hidden 100 | if ((TagHelper.ContainsIgnoreTag(tagsOfScenario) || TagHelper.ContainsIgnoreTag(featureTags))) 101 | { 102 | testRunner.SkipScenario(); 103 | } 104 | else 105 | { 106 | this.ScenarioStart(); 107 | #line 5 108 | this.FeatureBackground(); 109 | #line hidden 110 | #line 10 111 | testRunner.Given("platform has \"0\" students", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Given "); 112 | #line hidden 113 | TechTalk.SpecFlow.Table table1 = new TechTalk.SpecFlow.Table(new string[] { 114 | "FirstName", 115 | "LastName", 116 | "Email", 117 | "BirthDate", 118 | "GitHubUsername"}); 119 | table1.AddRow(new string[] { 120 | "John", 121 | "Doe", 122 | "john@doe.com", 123 | "2004-01-01", 124 | "john-doe"}); 125 | #line 11 126 | testRunner.When("user creates a student with following data by sending \'Create Student Command\' th" + 127 | "rough API", ((string)(null)), table1, "When "); 128 | #line hidden 129 | TechTalk.SpecFlow.Table table2 = new TechTalk.SpecFlow.Table(new string[] { 130 | "FirstName", 131 | "LastName", 132 | "Email", 133 | "BirthDate", 134 | "GitHubUsername"}); 135 | table2.AddRow(new string[] { 136 | "John", 137 | "Doe", 138 | "john@doe.com", 139 | "2004-01-01", 140 | "john-doe"}); 141 | #line 14 142 | testRunner.Then("user requests to get all the students and it must return \"1\" student with followi" + 143 | "ng data", ((string)(null)), table2, "Then "); 144 | #line hidden 145 | TechTalk.SpecFlow.Table table3 = new TechTalk.SpecFlow.Table(new string[] { 146 | "FirstName", 147 | "LastName", 148 | "Email", 149 | "BirthDate", 150 | "GitHubUsername"}); 151 | table3.AddRow(new string[] { 152 | "John", 153 | "Doe", 154 | "john@gmail.com", 155 | "2004-01-01", 156 | "john-doe"}); 157 | #line 17 158 | testRunner.When("user creates another student with following data by sending \'Create Student Comma" + 159 | "nd\' through API", ((string)(null)), table3, "When "); 160 | #line hidden 161 | #line 20 162 | testRunner.Then("user must get status code \"500\"", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then "); 163 | #line hidden 164 | TechTalk.SpecFlow.Table table4 = new TechTalk.SpecFlow.Table(new string[] { 165 | "FirstName", 166 | "LastName", 167 | "Email", 168 | "BirthDate", 169 | "GitHubUsername"}); 170 | table4.AddRow(new string[] { 171 | "Jane", 172 | "William", 173 | "jane@william.com", 174 | "2000-01-01", 175 | "jane-will"}); 176 | #line 21 177 | testRunner.When("user updates student by email \"john@doe.com\" with following data", ((string)(null)), table4, "When "); 178 | #line hidden 179 | TechTalk.SpecFlow.Table table5 = new TechTalk.SpecFlow.Table(new string[] { 180 | "FirstName", 181 | "LastName", 182 | "Email", 183 | "BirthDate", 184 | "GitHubUsername"}); 185 | table5.AddRow(new string[] { 186 | "Jane", 187 | "William", 188 | "jane@william.com", 189 | "2000-01-01", 190 | "jane-will"}); 191 | #line 24 192 | testRunner.Then("user requests to get all the students and it must return \"1\" student", ((string)(null)), table5, "Then "); 193 | #line hidden 194 | #line 27 195 | testRunner.When("user deletes student by Email of \"jane@william.com\"", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); 196 | #line hidden 197 | #line 28 198 | testRunner.Then("user requests to get all students and it must return \"0\" students", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then "); 199 | #line hidden 200 | } 201 | this.ScenarioCleanup(); 202 | } 203 | 204 | [System.CodeDom.Compiler.GeneratedCodeAttribute("TechTalk.SpecFlow", "3.9.0.0")] 205 | [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 206 | public class FixtureData : System.IDisposable 207 | { 208 | 209 | public FixtureData() 210 | { 211 | StudentManagementFeature.FeatureSetup(); 212 | } 213 | 214 | void System.IDisposable.Dispose() 215 | { 216 | StudentManagementFeature.FeatureTearDown(); 217 | } 218 | } 219 | } 220 | } 221 | #pragma warning restore 222 | #endregion 223 | -------------------------------------------------------------------------------- /server/tests/WebAPI.Tests.Acceptance/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /server/tests/WebAPI.Tests.Acceptance/Hooks/AcceptanceTestWebApplicationFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Microsoft.AspNetCore.Mvc.Testing; 3 | using Microsoft.AspNetCore.TestHost; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Infrastructure.DbContexts; 7 | using Testcontainers.PostgreSql; 8 | using TechTalk.SpecFlow; 9 | 10 | namespace WebAPI.Tests.Acceptance.Hooks; 11 | 12 | [Binding] 13 | public class AcceptanceTestWebApplicationFactory : WebApplicationFactory, IAsyncLifetime 14 | { 15 | private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder() 16 | .WithImage("postgres:latest") 17 | .WithDatabase("StudentManagementDB_Test") 18 | .WithUsername("postgres") 19 | .WithPassword("mysecretpassword") 20 | .Build(); 21 | 22 | [BeforeScenario] 23 | public async Task InitializeAsync() 24 | { 25 | await _dbContainer.StartAsync(); 26 | 27 | using (var scope = Services.CreateScope()) 28 | { 29 | var scopedServices = scope.ServiceProvider; 30 | var cntx = scopedServices.GetRequiredService(); 31 | 32 | await cntx.Database.EnsureCreatedAsync(); 33 | } 34 | } 35 | 36 | [AfterScenario] 37 | public new async Task DisposeAsync() 38 | { 39 | await _dbContainer.StopAsync(); 40 | } 41 | 42 | protected override void ConfigureWebHost(IWebHostBuilder builder) 43 | { 44 | builder.ConfigureTestServices(services => 45 | { 46 | var descriptor = services.SingleOrDefault(s => s.ServiceType == typeof(DbContextOptions)); 47 | 48 | if (descriptor is not null) 49 | { 50 | services.Remove(descriptor); 51 | } 52 | 53 | services.AddDbContext(options => 54 | { 55 | options.UseNpgsql(_dbContainer.GetConnectionString()); 56 | }); 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /server/tests/WebAPI.Tests.Acceptance/StepDefinitions/StudentManagementSteps.cs: -------------------------------------------------------------------------------- 1 | using WebAPI.Tests.Acceptance.Drivers; 2 | using WebAPI.Tests.Acceptance.Dtos; 3 | using TechTalk.SpecFlow; 4 | 5 | namespace WebAPI.Tests.Acceptance.StepDefinitions; 6 | 7 | [Binding] 8 | public class StudentManagementSteps 9 | { 10 | private readonly ScenarioContext _scenarioContext; 11 | private readonly StudentManagementDriver _driver; 12 | public StudentManagementSteps(ScenarioContext scenarioContext, StudentManagementDriver driver) 13 | { 14 | _scenarioContext = scenarioContext; 15 | _driver = driver; 16 | } 17 | 18 | [Given(@"platform has ""(.*)"" students")] 19 | public void GivenplatformHas0Students(string numberOfStudents) 20 | { 21 | List studentResponse = _driver.GetAllStudents(); 22 | Assert.Equal(int.Parse(numberOfStudents), studentResponse?.Count); 23 | } 24 | 25 | [When(@"user creates a student with following data by sending 'Create Student Command' through API")] 26 | public void WhenUserCreatesAStudent(Table student) 27 | { 28 | HttpResponseMessage createResponse = _driver.CreateStudent(student); 29 | createResponse.EnsureSuccessStatusCode(); 30 | } 31 | 32 | [Then(@"user requests to get all the students and it must return ""(.*)"" student with following data")] 33 | public void ThenUserGet1Student(string numberOfStudents, Table expectedStudent) 34 | { 35 | List studentResponse = _driver.GetAllStudents(); 36 | 37 | Assert.Equal(int.Parse(numberOfStudents), studentResponse?.Count); 38 | 39 | var expectedStudentResponse = _driver.ParseCreateStudentData(expectedStudent); 40 | Assert.Equal(expectedStudentResponse.FirstName, studentResponse[0].FirstName); 41 | Assert.Equal(expectedStudentResponse.LastName, studentResponse[0].LastName); 42 | Assert.Equal(expectedStudentResponse.Email.Value, studentResponse[0].Email.Value); 43 | Assert.Equal(expectedStudentResponse.BirthDate, studentResponse[0].BirthDate); 44 | Assert.Equal(expectedStudentResponse.GitHubUsername, studentResponse[0].GitHubUsername); 45 | } 46 | 47 | [When(@"user creates another student with following data by sending 'Create Student Command' through API")] 48 | public void WhenUserCreatesAStudentWithSameInfo(Table student) 49 | { 50 | HttpResponseMessage createSameStudentResponse = _driver.CreateStudent(student); 51 | _scenarioContext.Add("createSameStudentResponse", createSameStudentResponse); 52 | } 53 | 54 | [Then(@"user must get status code ""(.*)""")] 55 | public void ThenUserMustGetInternalServerError(string statusCode) 56 | { 57 | var createSameStudentResponse = _scenarioContext.Get("createSameStudentResponse"); 58 | Assert.Equal(int.Parse(statusCode), (int)createSameStudentResponse.StatusCode); 59 | } 60 | 61 | [When(@"user updates student by email ""(.*)"" with following data")] 62 | public void ThenUserUpdateStudent(string email, Table student) 63 | { 64 | List studentResponse = _driver.GetAllStudents(); 65 | Assert.Equal(email, studentResponse[0].Email.Value); 66 | 67 | HttpResponseMessage updateResponse = _driver.UpdateStudent(studentResponse[0].Id, student); 68 | updateResponse.EnsureSuccessStatusCode(); 69 | } 70 | 71 | [Then(@"user requests to get all the students and it must return ""(.*)"" student")] 72 | public void ThenUserCanRequestToGetStudents(string numberOfStudents, Table expectedStudent) 73 | { 74 | List studentResponse = _driver.GetAllStudents(); 75 | 76 | Assert.Equal(int.Parse(numberOfStudents), studentResponse?.Count); 77 | 78 | var expectedStudentResponse = _driver.ParseCreateStudentData(expectedStudent); 79 | Assert.Equal(expectedStudentResponse.FirstName, studentResponse[0].FirstName); 80 | Assert.Equal(expectedStudentResponse.LastName, studentResponse[0].LastName); 81 | Assert.Equal(expectedStudentResponse.Email.Value, studentResponse[0].Email.Value); 82 | Assert.Equal(expectedStudentResponse.BirthDate, studentResponse[0].BirthDate); 83 | Assert.Equal(expectedStudentResponse.GitHubUsername, studentResponse[0].GitHubUsername); 84 | } 85 | 86 | [When(@"user deletes student by Email of ""(.*)""")] 87 | public void WhenUserDeleteTheStudent(string email) 88 | { 89 | List studentResponse = _driver.GetAllStudents(); 90 | Assert.Equal(email, studentResponse[0].Email.Value); 91 | 92 | HttpResponseMessage deleteResponse = _driver.DeleteStudent(studentResponse[0].Id); 93 | deleteResponse.EnsureSuccessStatusCode(); 94 | } 95 | 96 | [Then(@"user requests to get all students and it must return ""(.*)"" students")] 97 | public void ThenUserQueryToGetAllStudents(string numberOfStudents) 98 | { 99 | List studentResponse = _driver.GetAllStudents(); 100 | Assert.Equal(int.Parse(numberOfStudents), studentResponse?.Count); 101 | } 102 | } -------------------------------------------------------------------------------- /server/tests/WebAPI.Tests.Acceptance/WebAPI.Tests.Acceptance.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | all 21 | 22 | 23 | runtime; build; native; contentfiles; analyzers; buildtransitive 24 | all 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /server/tests/WebAPI.Tests.Integration/BaseControllerTest.cs: -------------------------------------------------------------------------------- 1 | using Application.Tests.Integration; 2 | 3 | namespace WebAPI.Tests.Integration; 4 | 5 | public class BaseControllerTest : IClassFixture 6 | { 7 | protected readonly HttpClient _httpClient; 8 | 9 | protected BaseControllerTest(IntegrationTestWebApplicationFactory factory) 10 | { 11 | _httpClient = factory.CreateDefaultClient(); 12 | } 13 | } -------------------------------------------------------------------------------- /server/tests/WebAPI.Tests.Integration/ControllersTests/StudentsControllerTests.cs: -------------------------------------------------------------------------------- 1 | using Application.Tests.Integration; 2 | using Application.ResponseModels; 3 | using Application.RequestModels; 4 | using System.Net.Http.Json; 5 | using Domain.ValueObjects; 6 | 7 | namespace WebAPI.Tests.Integration.ControllersTests; 8 | 9 | public class StudentsControllerTests : BaseControllerTest 10 | { 11 | public StudentsControllerTests(IntegrationTestWebApplicationFactory factory) : base(factory) 12 | { 13 | } 14 | 15 | [Fact] 16 | public async Task GetAll_Returns_OK() 17 | { 18 | // Arrange 19 | 20 | // Act 21 | var response = await _httpClient.GetAsync("/api/students"); 22 | 23 | // Assert 24 | response.EnsureSuccessStatusCode(); 25 | } 26 | 27 | [Fact] 28 | public async Task Post_Returns_OK_With_StudentResponse() 29 | { 30 | // Arrange 31 | CreateStudentRequest request = new CreateStudentRequest( 32 | FirstName: "Max", 33 | LastName: "Mirzaee", 34 | Email: new Email("max@gmail.com"), 35 | BirthDate: new DateOnly(2006, 06, 01), 36 | GitHubUsername: "max-1"); 37 | 38 | // Act 39 | var response = await _httpClient.PostAsJsonAsync("/api/students", request); 40 | 41 | // Assert 42 | response.EnsureSuccessStatusCode(); 43 | 44 | var studentResponse = await response.Content.ReadFromJsonAsync(); 45 | Assert.Equal(request.FirstName, studentResponse.FirstName); 46 | Assert.Equal(request.LastName, studentResponse.LastName); 47 | Assert.Equal(request.Email.Value, studentResponse.Email.Value); 48 | Assert.Equal(request.BirthDate, studentResponse.BirthDate); 49 | Assert.Equal(request.GitHubUsername, studentResponse.GitHubUsername); 50 | } 51 | 52 | [Fact] 53 | public async void Put_Returns_Ok_With_StudentResponse() 54 | { 55 | // Arrange 56 | // Create a new student 57 | CreateStudentRequest createRequest = new CreateStudentRequest( 58 | FirstName: "Diana", 59 | LastName: "Moradi", 60 | Email: new Email("diana@gmail.com"), 61 | BirthDate: new DateOnly(2000, 1, 1), 62 | GitHubUsername: "diana" 63 | ); 64 | 65 | var postResponse = await _httpClient.PostAsJsonAsync("/api/students", createRequest); 66 | var addedStudent = await postResponse.Content.ReadFromJsonAsync(); 67 | 68 | UpdateStudentRequest updateRequest = new UpdateStudentRequest( 69 | Id: addedStudent.Id, 70 | FirstName: "Diana", 71 | LastName: "Rasouli", 72 | Email: new Email("dia@gmail.com"), 73 | BirthDate: new DateOnly(1900, 1, 1), 74 | GitHubUsername: "diana-900"); 75 | 76 | // Act 77 | var updateResponse = await _httpClient.PutAsJsonAsync("/api/students/" + addedStudent.Id, updateRequest); 78 | 79 | // Assert 80 | updateResponse.EnsureSuccessStatusCode(); 81 | postResponse.EnsureSuccessStatusCode(); 82 | 83 | var updatedStudent = await updateResponse.Content.ReadFromJsonAsync(); 84 | 85 | Assert.Equal(updateRequest.FirstName, updatedStudent.FirstName); 86 | Assert.Equal(updateRequest.LastName, updatedStudent.LastName); 87 | Assert.Equal(updateRequest.Email.Value, updatedStudent.Email.Value); 88 | Assert.Equal(updateRequest.BirthDate, updatedStudent.BirthDate); 89 | Assert.Equal(updateRequest.GitHubUsername, updatedStudent.GitHubUsername); 90 | } 91 | 92 | [Fact] 93 | public async Task Delete_With_Valid_StudentId_Returns_OK() 94 | { 95 | // Arrange 96 | // Create a new student 97 | CreateStudentRequest request = new CreateStudentRequest( 98 | FirstName: "Arsh", 99 | LastName: "Mirzaee", 100 | Email: new Email("arsh@gmail.com"), 101 | BirthDate: new DateOnly(1984, 06, 01), 102 | GitHubUsername: "arsh84"); 103 | 104 | var postResponse = await _httpClient.PostAsJsonAsync("/api/students", request); 105 | StudentResponse addedStudent = await postResponse.Content.ReadFromJsonAsync(); 106 | 107 | // Act 108 | var deleteResponse = await _httpClient.DeleteAsync("/api/students/" + addedStudent.Id); 109 | 110 | // Assert 111 | postResponse.EnsureSuccessStatusCode(); 112 | deleteResponse.EnsureSuccessStatusCode(); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /server/tests/WebAPI.Tests.Integration/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /server/tests/WebAPI.Tests.Integration/WebAPI.Tests.Integration.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | all 18 | 19 | 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | all 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | --------------------------------------------------------------------------------