├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .spectral.yml ├── .vscode ├── launch.json └── tasks.json ├── Directory.Packages.props ├── README.md ├── TrainingApi.sln ├── global.json ├── nuget.config ├── shared ├── Client.cs ├── Level.cs ├── Trainer.cs ├── TrainingApi.Shared.csproj └── TrainingDb.cs ├── src ├── Apis │ ├── ClientApis.cs │ ├── TrainerApis.cs │ └── XmlResult.cs ├── Data │ └── DataGenerator.cs ├── OpenApi │ └── OpenApiTransformersExtensions.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── Services │ ├── ClientsService.cs │ └── TrainersService.cs ├── TrainingApi.csproj ├── TrainingApi.http ├── TrainingApi.json ├── appsettings.Development.json └── appsettings.json └── tests ├── ApiApplication.cs ├── IntegrationTests.cs ├── TestDbAsyncQueryProvider.cs ├── TrainingApi.Tests.csproj ├── UnitTests.cs └── Usings.cs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build and lint APIs 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - 9.0 7 | push: 8 | branches: 9 | - 9.0 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | # Check out the repository 16 | - uses: actions/checkout@v3 17 | 18 | - name: Setup .NET (global.json) 19 | uses: actions/setup-dotnet@v3 20 | 21 | - name: Install dependencies 22 | run: dotnet restore 23 | 24 | - name: Build 25 | run: dotnet build --no-restore 26 | 27 | - name: Run Spectral 28 | # Run Spectral 29 | uses: stoplightio/spectral-action@latest 30 | with: 31 | file_glob: 'src/TrainingApi.json' 32 | spectral_ruleset: .spectral.yml 33 | 34 | - name: Test 35 | run: dotnet test --no-build --verbosity normal -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore Flax project files 2 | Binaries/ 3 | Cache/ 4 | Logs/ 5 | Output/ 6 | Screenshots/ 7 | *.HotReload.* 8 | 9 | # Ignore thumbnails created by Windows 10 | Thumbs.db 11 | 12 | # Ignore files built by Visual Studio 13 | *.obj 14 | *.exe 15 | *.pdb 16 | *.user 17 | *.aps 18 | *.pch 19 | *.vspscc 20 | *_i.c 21 | *_p.c 22 | *.ncb 23 | *.suo 24 | *.tlb 25 | *.tlh 26 | *.bak 27 | *.cache 28 | *.ilk 29 | *.log 30 | [Bb]in 31 | [Dd]ebug*/ 32 | *.lib 33 | *.sbr 34 | obj/ 35 | [Rr]elease*/ 36 | _ReSharper*/ 37 | [Tt]est[Rr]esult* 38 | .vs/ 39 | 40 | # Ignore Nuget packages folder 41 | packages/ 42 | .dotnet 43 | 44 | # General 45 | .DS_Store 46 | .AppleDouble 47 | .LSOverride 48 | 49 | # Icon must end with two \r 50 | Icon 51 | 52 | 53 | # Thumbnails 54 | ._* 55 | 56 | # Files that might appear in the root of a volume 57 | .DocumentRevisions-V100 58 | .fseventsd 59 | .Spotlight-V100 60 | .TemporaryItems 61 | .Trashes 62 | .VolumeIcon.icns 63 | .com.apple.timemachine.donotpresent 64 | 65 | # Directories potentially created on remote AFP share 66 | .AppleDB 67 | .AppleDesktop 68 | Network Trash Folder 69 | Temporary Items 70 | .apdisk -------------------------------------------------------------------------------- /.spectral.yml: -------------------------------------------------------------------------------- 1 | extends: spectral:oas 2 | rules: 3 | info-contact: off 4 | oas3-api-servers: off 5 | 6 | success-response: 7 | description: All operations should have a success response. 8 | message: Operation is missing a success response. 9 | severity: warn 10 | given: $.paths.*.*.responses 11 | then: 12 | function: schema 13 | functionOptions: 14 | schema: 15 | anyOf: 16 | - required: ["200"] 17 | - required: ["201"] 18 | - required: ["204"] 19 | 20 | error-response: 21 | description: All operations should have a error response. 22 | message: Operation is missing a error response. 23 | severity: warn 24 | given: $.paths.*.*.responses 25 | then: 26 | function: schema 27 | functionOptions: 28 | schema: 29 | anyOf: 30 | - required: ["400"] 31 | - required: ["404"] 32 | 33 | parameter-description: 34 | description: All parameters should have a description 35 | message: Parameter is missing a description. 36 | severity: warn 37 | given: $.paths.*.*.parameters[*] 38 | then: 39 | field: description 40 | function: truthy 41 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": ".NET Core Launch (web)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | "program": "${workspaceFolder}/bin/Debug/net10.0/TrainingApi.dll", 13 | "args": [], 14 | "cwd": "${workspaceFolder}", 15 | "stopAtEntry": false, 16 | "serverReadyAction": { 17 | "action": "openExternally", 18 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)" 19 | }, 20 | "env": { 21 | "ASPNETCORE_ENVIRONMENT": "Development" 22 | }, 23 | "sourceFileMap": { 24 | "/Views": "${workspaceFolder}/Views" 25 | } 26 | }, 27 | { 28 | "name": ".NET Core Attach", 29 | "type": "coreclr", 30 | "request": "attach" 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/TrainingApi.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/TrainingApi.csproj", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "--project", 36 | "${workspaceFolder}/TrainingApi.csproj" 37 | ], 38 | "problemMatcher": "$msCompile" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TrainingApi 2 | 3 | TrainingApi is a demo application showcasing the new features in minimal APIs for .NET 9. 4 | 5 | ## Run Application 6 | 7 | To run the API, navigate to the `TrainingApi` directory and execute `dotnet run`. 8 | 9 | ``` 10 | $ cd TrainingApi 11 | $ dotnet run 12 | Building... 13 | info: Microsoft.EntityFrameworkCore.Update[30100] 14 | Saved 7 entities to in-memory store. 15 | info: Microsoft.Hosting.Lifetime[14] 16 | Now listening on: http://localhost:5198 17 | info: Microsoft.Hosting.Lifetime[0] 18 | Application started. Press Ctrl+C to shut down. 19 | info: Microsoft.Hosting.Lifetime[0] 20 | Hosting environment: Development 21 | info: Microsoft.Hosting.Lifetime[0] 22 | Content root path: /Users/captainsafia/github.com/TrainingApi/TrainingApi 23 | ``` 24 | 25 | Navigate to http://localhost:5198/ to view the Scalar UI for interacting with the application. 26 | 27 | ### Run Tests 28 | 29 | Unit and integration tests are located under the `Tests` subdirectory. 30 | 31 | ``` 32 | $ cd Tests 33 | $ dotnet test 34 | ``` 35 | 36 | ## Contributing 37 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 38 | 39 | ## License 40 | [MIT](https://choosealicense.com/licenses/mit/) -------------------------------------------------------------------------------- /TrainingApi.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("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrainingApi", "src\TrainingApi.csproj", "{8C1F2529-55DE-4AC8-A816-B5E80BCB4C85}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrainingApi.Tests", "tests\TrainingApi.Tests.csproj", "{26C8B7F3-24D1-48B2-9883-6BC80E7D909C}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrainingApi.Shared", "shared\TrainingApi.Shared.csproj", "{540D803C-BDFB-4812-94DF-BF8C22E46B5F}" 11 | EndProject 12 | Global 13 | GlobalSection(SolutionProperties) = preSolution 14 | HideSolutionNode = FALSE 15 | EndGlobalSection 16 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 17 | Debug|Any CPU = Debug|Any CPU 18 | Debug|x64 = Debug|x64 19 | Debug|x86 = Debug|x86 20 | Release|Any CPU = Release|Any CPU 21 | Release|x64 = Release|x64 22 | Release|x86 = Release|x86 23 | EndGlobalSection 24 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 25 | {8C1F2529-55DE-4AC8-A816-B5E80BCB4C85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {8C1F2529-55DE-4AC8-A816-B5E80BCB4C85}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {8C1F2529-55DE-4AC8-A816-B5E80BCB4C85}.Debug|x64.ActiveCfg = Debug|Any CPU 28 | {8C1F2529-55DE-4AC8-A816-B5E80BCB4C85}.Debug|x64.Build.0 = Debug|Any CPU 29 | {8C1F2529-55DE-4AC8-A816-B5E80BCB4C85}.Debug|x86.ActiveCfg = Debug|Any CPU 30 | {8C1F2529-55DE-4AC8-A816-B5E80BCB4C85}.Debug|x86.Build.0 = Debug|Any CPU 31 | {8C1F2529-55DE-4AC8-A816-B5E80BCB4C85}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {8C1F2529-55DE-4AC8-A816-B5E80BCB4C85}.Release|Any CPU.Build.0 = Release|Any CPU 33 | {8C1F2529-55DE-4AC8-A816-B5E80BCB4C85}.Release|x64.ActiveCfg = Release|Any CPU 34 | {8C1F2529-55DE-4AC8-A816-B5E80BCB4C85}.Release|x64.Build.0 = Release|Any CPU 35 | {8C1F2529-55DE-4AC8-A816-B5E80BCB4C85}.Release|x86.ActiveCfg = Release|Any CPU 36 | {8C1F2529-55DE-4AC8-A816-B5E80BCB4C85}.Release|x86.Build.0 = Release|Any CPU 37 | {26C8B7F3-24D1-48B2-9883-6BC80E7D909C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 38 | {26C8B7F3-24D1-48B2-9883-6BC80E7D909C}.Debug|Any CPU.Build.0 = Debug|Any CPU 39 | {26C8B7F3-24D1-48B2-9883-6BC80E7D909C}.Debug|x64.ActiveCfg = Debug|Any CPU 40 | {26C8B7F3-24D1-48B2-9883-6BC80E7D909C}.Debug|x64.Build.0 = Debug|Any CPU 41 | {26C8B7F3-24D1-48B2-9883-6BC80E7D909C}.Debug|x86.ActiveCfg = Debug|Any CPU 42 | {26C8B7F3-24D1-48B2-9883-6BC80E7D909C}.Debug|x86.Build.0 = Debug|Any CPU 43 | {26C8B7F3-24D1-48B2-9883-6BC80E7D909C}.Release|Any CPU.ActiveCfg = Release|Any CPU 44 | {26C8B7F3-24D1-48B2-9883-6BC80E7D909C}.Release|Any CPU.Build.0 = Release|Any CPU 45 | {26C8B7F3-24D1-48B2-9883-6BC80E7D909C}.Release|x64.ActiveCfg = Release|Any CPU 46 | {26C8B7F3-24D1-48B2-9883-6BC80E7D909C}.Release|x64.Build.0 = Release|Any CPU 47 | {26C8B7F3-24D1-48B2-9883-6BC80E7D909C}.Release|x86.ActiveCfg = Release|Any CPU 48 | {26C8B7F3-24D1-48B2-9883-6BC80E7D909C}.Release|x86.Build.0 = Release|Any CPU 49 | {540D803C-BDFB-4812-94DF-BF8C22E46B5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 50 | {540D803C-BDFB-4812-94DF-BF8C22E46B5F}.Debug|Any CPU.Build.0 = Debug|Any CPU 51 | {540D803C-BDFB-4812-94DF-BF8C22E46B5F}.Debug|x64.ActiveCfg = Debug|Any CPU 52 | {540D803C-BDFB-4812-94DF-BF8C22E46B5F}.Debug|x64.Build.0 = Debug|Any CPU 53 | {540D803C-BDFB-4812-94DF-BF8C22E46B5F}.Debug|x86.ActiveCfg = Debug|Any CPU 54 | {540D803C-BDFB-4812-94DF-BF8C22E46B5F}.Debug|x86.Build.0 = Debug|Any CPU 55 | {540D803C-BDFB-4812-94DF-BF8C22E46B5F}.Release|Any CPU.ActiveCfg = Release|Any CPU 56 | {540D803C-BDFB-4812-94DF-BF8C22E46B5F}.Release|Any CPU.Build.0 = Release|Any CPU 57 | {540D803C-BDFB-4812-94DF-BF8C22E46B5F}.Release|x64.ActiveCfg = Release|Any CPU 58 | {540D803C-BDFB-4812-94DF-BF8C22E46B5F}.Release|x64.Build.0 = Release|Any CPU 59 | {540D803C-BDFB-4812-94DF-BF8C22E46B5F}.Release|x86.ActiveCfg = Release|Any CPU 60 | {540D803C-BDFB-4812-94DF-BF8C22E46B5F}.Release|x86.Build.0 = Release|Any CPU 61 | EndGlobalSection 62 | EndGlobal 63 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "10.0.100-preview.1.25110.2" 4 | } 5 | } -------------------------------------------------------------------------------- /nuget.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /shared/Client.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel; 3 | 4 | namespace TrainingApi.Shared; 5 | 6 | /// 7 | /// Represents a client with personal information. 8 | /// 9 | /// The unique identifier of the client, assigned by the system when the client is created. 10 | /// The first name of the client. 11 | /// The last name of the client. 12 | /// The email of the client. 13 | /// The weight of the client in pounds, rounded to the nearest pound. 14 | /// The height of the client in inches, rounded to the nearest inch. 15 | /// The date of birth of the client. 16 | public record Client( 17 | int Id, 18 | string FirstName, 19 | string LastName, 20 | string Email, 21 | int Weight, 22 | int Height, 23 | DateTime BirthDate 24 | ); 25 | -------------------------------------------------------------------------------- /shared/Level.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace TrainingApi.Shared; 4 | 5 | [JsonConverter(typeof(JsonStringEnumConverter))] 6 | public enum Level 7 | { 8 | Junior, 9 | Senior, 10 | Elite 11 | } -------------------------------------------------------------------------------- /shared/Trainer.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | 3 | namespace TrainingApi.Shared; 4 | 5 | public record Trainer(int Id, string FirstName, string LastName, string Email, Level Level, bool IsCertificationActive) 6 | { 7 | public Trainer() 8 | : this(0, "", "", "", Level.Junior, false) 9 | { 10 | } 11 | 12 | /// 13 | /// The unique identifier of the trainer, assigned by the system when the trainer is created. 14 | /// 15 | public int Id { get; set; } = Id; 16 | 17 | /// 18 | /// The first name of the trainer. 19 | /// 20 | public string FirstName { get; set; } = FirstName; 21 | 22 | /// 23 | /// The last name of the trainer. 24 | /// 25 | public string LastName { get; set; } = LastName; 26 | 27 | /// 28 | /// The email address of the trainer. 29 | /// 30 | public string Email { get; set; } = Email; 31 | 32 | /// 33 | /// The level of the trainer. 34 | /// 35 | public Level Level { get; set; } = Level; 36 | 37 | /// 38 | /// Indicates whether the trainer's certification is active. 39 | /// 40 | public bool IsCertificationActive { get; set; } = IsCertificationActive; 41 | } 42 | -------------------------------------------------------------------------------- /shared/TrainingApi.Shared.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | true 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /shared/TrainingDb.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace TrainingApi.Shared; 4 | 5 | public class TrainingDb : DbContext 6 | { 7 | // Parameterless constructor to support mocking in unit tests 8 | public TrainingDb() { } 9 | public TrainingDb(DbContextOptions options) : base(options) { } 10 | 11 | // Virtual DbSets to support mocking in unit tests 12 | public virtual DbSet Clients { get; set; } 13 | public virtual DbSet Trainers { get; set; } 14 | } -------------------------------------------------------------------------------- /src/Apis/ClientApis.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using TrainingApi.Services; 3 | using TrainingApi.Shared; 4 | 5 | namespace TrainingApi.Apis; 6 | 7 | public static class ClientApis 8 | { 9 | public static IEndpointRouteBuilder MapClientApis(this IEndpointRouteBuilder app) 10 | { 11 | var clients = app.MapGroup("/clients") 12 | .WithTags("Clients"); 13 | 14 | clients.MapGet("/{id}", ( 15 | [Description("The unique identifier of the client, assigned by the system when the client is created")] int id, 16 | ClientsService service) => service.GetClientById(id)) 17 | .WithName("GetClient") 18 | .WithDescription("Get a client"); 19 | 20 | clients.MapPut("/{id}", ( 21 | [Description("The unique identifier of the client, assigned by the system when the client is created")] int id, 22 | Client updatedClient, 23 | ClientsService service) => service.UpdateClientById(id, updatedClient)) 24 | .WithName("UpdateClient") 25 | .WithDescription("Update a client"); 26 | 27 | clients.MapPost("", (ClientsService service, Client client) => service.CreateClient(client)) 28 | .WithName("CreateClient") 29 | .WithDescription("Create a client"); 30 | 31 | clients.MapPost("/{id}", ( 32 | [Description("The unique identifier of the client, assigned by the system when the client is created")] int id, 33 | ClientsService service) => service.DeleteClientById(id)) 34 | .WithName("DeleteClient") 35 | .WithDescription("Delete a client"); 36 | 37 | return app; 38 | } 39 | } -------------------------------------------------------------------------------- /src/Apis/TrainerApis.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using TrainingApi.Services; 3 | using TrainingApi.Shared; 4 | 5 | namespace TrainingApi.Apis; 6 | 7 | public static class TrainerApis 8 | { 9 | public static IEndpointRouteBuilder MapTrainerApis(this IEndpointRouteBuilder app) 10 | { 11 | var trainers = app.MapGroup("/trainers") 12 | .RequireAuthorization("trainer_access") 13 | .WithTags("Trainers"); 14 | 15 | trainers.MapGet("/", (TrainersService service) => service.GetTrainers()) 16 | .WithName("ListTrainers") 17 | .WithDescription("List all trainers"); 18 | 19 | trainers.MapPut("/{id}", ( 20 | [Description("The unique identifier of the trainer, assigned by the system when the client is created")] int id, 21 | Trainer updatedTrainer, 22 | TrainersService service) => service.UpdateTrainerById(id, updatedTrainer)) 23 | .WithName("UpdateTrainer") 24 | .WithDescription("Update a trainer"); 25 | 26 | trainers.MapDelete("/{id}", ( 27 | [Description("The unique identifier of the trainer, assigned by the system when the client is created")] int id, 28 | TrainersService service) => service.DeleteTrainerById(id)) 29 | .WithName("DeleteTrainer") 30 | .WithDescription("Delete a trainer"); 31 | 32 | trainers.MapPost("/", (TrainersService service, Trainer trainer) => service.CreateTrainer(trainer)) 33 | .WithName("CreateTrainer") 34 | .WithDescription("Create a trainer"); 35 | 36 | return app; 37 | } 38 | } -------------------------------------------------------------------------------- /src/Apis/XmlResult.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Xml.Serialization; 3 | using Microsoft.AspNetCore.Http.Metadata; 4 | 5 | public class XmlResult : IResult, IEndpointMetadataProvider 6 | { 7 | private static readonly XmlSerializer _xmlSerializer = new(typeof(T)); 8 | 9 | private readonly T _result; 10 | 11 | public XmlResult(T result) 12 | { 13 | _result = result; 14 | } 15 | 16 | public Task ExecuteAsync(HttpContext httpContext) 17 | { 18 | // Pool this for better efficiency 19 | using var ms = new MemoryStream(); 20 | 21 | _xmlSerializer.Serialize(ms, _result); 22 | 23 | httpContext.Response.ContentType = "application/xml"; 24 | 25 | ms.Position = 0; 26 | return ms.CopyToAsync(httpContext.Response.Body); 27 | } 28 | 29 | public static void PopulateMetadata(MethodInfo method, EndpointBuilder builder) 30 | { 31 | builder.Metadata.Add(new XmlResponseTypeMetadata(typeof(T))); 32 | } 33 | } 34 | 35 | public class XmlResponseTypeMetadata : IProducesResponseTypeMetadata 36 | { 37 | public XmlResponseTypeMetadata(Type? type) 38 | { 39 | Type = type; 40 | } 41 | 42 | public Type? Type { get; set; } 43 | 44 | public int StatusCode { get; set; } = StatusCodes.Status200OK; 45 | 46 | public IEnumerable ContentTypes { get; set; } = ["application/xml"]; 47 | 48 | public string? Description { get; set; } 49 | } 50 | 51 | public static class XmlResultExtensions 52 | { 53 | public static XmlResult Xml(this IResultExtensions _, T result) => new(result); 54 | } -------------------------------------------------------------------------------- /src/Data/DataGenerator.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using Microsoft.EntityFrameworkCore; 3 | using TrainingApi.Shared; 4 | 5 | public static class DataGenerator 6 | { 7 | public static IApplicationBuilder InitializeDatabase(this IApplicationBuilder app) 8 | { 9 | var serviceProvider = app.ApplicationServices.CreateScope().ServiceProvider; 10 | var options = serviceProvider.GetRequiredService>(); 11 | using var context = new TrainingDb(options); 12 | if (context.Clients.Any()) 13 | { 14 | return app; 15 | } 16 | 17 | var clientA = new Client(1, "Vonnie", "Mawer", "vmawer0@go.com", 149, 66, DateTime.Parse("4/24/2000", CultureInfo.InvariantCulture)); 18 | var clientB = new Client(2, "Langston", "Feldberg", "lfeldberg1@hc360.com", 329, 73, DateTime.Parse("10/20/1982", CultureInfo.InvariantCulture)); 19 | var clientC = new Client(3, "Olwen", "Maeer", "omaeer3@purevolume.com", 261, 70, DateTime.Parse("8/22/1993", CultureInfo.InvariantCulture)); 20 | 21 | context.Clients.AddRange( 22 | clientA, 23 | clientB, 24 | clientC 25 | ); 26 | 27 | var trainerA = new Trainer(1, "Inna", "Spedroni", "ispedroni0@studiopress.com", Level.Junior, true); 28 | var trainerB = new Trainer(2, "Nikoletta", "Orrell", "norrell1@nydailynews.com", Level.Senior, true); 29 | var trainerC = new Trainer(3, "Briana", "Diprose", "bdiprose0@t.co", Level.Senior, true); 30 | var trainerD = new Trainer(4, "Zerk", "Riepl", "svanshin5@google.com", Level.Elite, true); 31 | 32 | context.Trainers.AddRange( 33 | trainerA, 34 | trainerB, 35 | trainerC, 36 | trainerD 37 | ); 38 | 39 | context.SaveChanges(); 40 | 41 | return app; 42 | } 43 | } -------------------------------------------------------------------------------- /src/OpenApi/OpenApiTransformersExtensions.cs: -------------------------------------------------------------------------------- 1 | 2 | using Microsoft.OpenApi.Models; 3 | using Microsoft.AspNetCore.Authentication.JwtBearer; 4 | using Microsoft.AspNetCore.OpenApi; 5 | using Microsoft.AspNetCore.Authorization; 6 | using TrainingApi.Shared; 7 | using System.Text.Json.Nodes; 8 | using Microsoft.OpenApi.Models.References; 9 | using Microsoft.OpenApi.Models.Interfaces; 10 | 11 | public static class OpenApiTransformersExtensions 12 | { 13 | public static OpenApiOptions UseJwtBearerAuthentication(this OpenApiOptions options) 14 | { 15 | var scheme = new OpenApiSecurityScheme() 16 | { 17 | Type = SecuritySchemeType.Http, 18 | Name = JwtBearerDefaults.AuthenticationScheme, 19 | Scheme = JwtBearerDefaults.AuthenticationScheme, 20 | }; 21 | options.AddDocumentTransformer((document, context, cancellationToken) => 22 | { 23 | document.Components ??= new(); 24 | document.Components.SecuritySchemes ??= new Dictionary(); 25 | document.Components.SecuritySchemes.Add(JwtBearerDefaults.AuthenticationScheme, scheme); 26 | return Task.CompletedTask; 27 | }); 28 | options.AddOperationTransformer((operation, context, cancellationToken) => 29 | { 30 | if (context.Description.ActionDescriptor.EndpointMetadata.OfType().Any()) 31 | { 32 | operation.Security = [new() {{ new OpenApiSecuritySchemeReference(JwtBearerDefaults.AuthenticationScheme), [] }}]; 33 | } 34 | return Task.CompletedTask; 35 | }); 36 | return options; 37 | } 38 | 39 | public static OpenApiOptions UseExamples(this OpenApiOptions options) 40 | { 41 | options.AddSchemaTransformer((schema, context, cancellationToken) => 42 | { 43 | if (context.JsonTypeInfo.Type == typeof(Trainer)) 44 | { 45 | schema.Example = new JsonObject 46 | { 47 | ["id"] = 1, 48 | ["firstName"] = "John", 49 | ["lastName"] = "Doe", 50 | ["email"] = "john.doe@email.com", 51 | ["level"] = "Junior", 52 | ["isCertificationActive"] = false 53 | }; 54 | } 55 | if (context.JsonTypeInfo.Type == typeof(Client)) 56 | { 57 | schema.Example = new JsonObject 58 | { 59 | ["id"] = 1, 60 | ["firstName"] = "Jane", 61 | ["lastName"] = "Smith", 62 | ["email"] ="jane.smith@email.com", 63 | ["weight"] = 60, 64 | ["height"] = 170, 65 | ["birthDate"] = "1990-01-01" 66 | }; 67 | } 68 | return Task.CompletedTask; 69 | }); 70 | return options; 71 | } 72 | 73 | public static OpenApiOptions MapType(this OpenApiOptions options, JsonSchemaType type) 74 | { 75 | options.AddSchemaTransformer((schema, context, cancellationToken) => 76 | { 77 | if (context.JsonTypeInfo.Type == typeof(T)) 78 | { 79 | schema.Type = type; 80 | } 81 | return Task.CompletedTask; 82 | }); 83 | 84 | return options; 85 | } 86 | } -------------------------------------------------------------------------------- /src/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Scalar.AspNetCore; 3 | using TrainingApi.Apis; 4 | using TrainingApi.Services; 5 | using TrainingApi.Shared; 6 | 7 | var builder = WebApplication.CreateBuilder(args); 8 | 9 | // Data and API dependencies 10 | builder.Services 11 | .AddDbContext(options => options.UseInMemoryDatabase("training")); 12 | builder.Services.AddScoped(); 13 | builder.Services.AddScoped(); 14 | // Authentication and authorization dependencies 15 | builder.Services.AddAuthentication().AddJwtBearer(); 16 | builder.Services.AddAuthorizationBuilder().AddPolicy("trainer_access", policy => 17 | policy.RequireRole("trainer").RequireClaim("permission", "admin")); 18 | // OpenAPI dependencies 19 | builder.Services.AddOpenApi(options => 20 | { 21 | options.UseJwtBearerAuthentication(); 22 | options.UseExamples(); 23 | }); 24 | 25 | var app = builder.Build(); 26 | 27 | if (app.Environment.IsDevelopment()) 28 | { 29 | // Set up OpenAPI-related endpoints 30 | app.MapOpenApi(); 31 | app.MapScalarApiReference(options => 32 | { 33 | options.DefaultFonts = false; 34 | }); 35 | // Seed the database with mock data 36 | app.InitializeDatabase(); 37 | } 38 | 39 | // Redirect for OpenAPI view 40 | app.MapGet("/", () => Results.Redirect("/scalar/v1")) 41 | .ExcludeFromDescription(); 42 | // Register /client and /trainer APIs 43 | app.MapClientApis(); 44 | app.MapTrainerApis(); 45 | 46 | app.Run(); 47 | -------------------------------------------------------------------------------- /src/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:1739", 7 | "sslPort": 44360 8 | } 9 | }, 10 | "profiles": { 11 | "http": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchBrowser": true, 15 | "applicationUrl": "http://localhost:5198", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "https": { 21 | "commandName": "Project", 22 | "dotnetRunMessages": true, 23 | "launchBrowser": true, 24 | "applicationUrl": "https://localhost:7088;http://localhost:5198", 25 | "environmentVariables": { 26 | "ASPNETCORE_ENVIRONMENT": "Development" 27 | } 28 | }, 29 | "IIS Express": { 30 | "commandName": "IISExpress", 31 | "launchBrowser": true, 32 | "environmentVariables": { 33 | "ASPNETCORE_ENVIRONMENT": "Development" 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Services/ClientsService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http.HttpResults; 2 | using Microsoft.EntityFrameworkCore; 3 | using TrainingApi.Shared; 4 | 5 | namespace TrainingApi.Services; 6 | 7 | public class ClientsService(TrainingDb trainingDb) 8 | { 9 | public async Task, NotFound>> GetClientById(int id) 10 | { 11 | var client = await trainingDb.Clients.FindAsync(id); 12 | return client is null ? TypedResults.NotFound() : TypedResults.Ok(client); 13 | } 14 | public async Task, NotFound>> UpdateClientById(int id, Client updatedClient) 15 | { 16 | var client = await trainingDb.Clients.FindAsync(id); 17 | if (client is null) return TypedResults.NotFound(); 18 | client = updatedClient; 19 | await trainingDb.SaveChangesAsync(); 20 | return TypedResults.Created($"/clients/{client.Id}", client); 21 | } 22 | 23 | public async Task, NotFound>> DeleteClientById(int id) 24 | { 25 | var client = await trainingDb.Clients.FindAsync(id); 26 | if (client is null) return TypedResults.NotFound(); 27 | trainingDb.Clients.Remove(client); 28 | return TypedResults.Ok(client); 29 | } 30 | 31 | public Results, NotFound> CreateClient(Client client) 32 | { 33 | trainingDb.Clients.Add(client); 34 | return TypedResults.Created($"/clients/{client.Id}", client); 35 | } 36 | 37 | public async Task>, NotFound>> GetClients() 38 | { 39 | var clients = await trainingDb.Clients.ToListAsync(); 40 | return TypedResults.Ok(clients); 41 | } 42 | } -------------------------------------------------------------------------------- /src/Services/TrainersService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http.HttpResults; 2 | using Microsoft.EntityFrameworkCore; 3 | using TrainingApi.Shared; 4 | 5 | namespace TrainingApi.Services; 6 | 7 | public class TrainersService(TrainingDb trainingDb) 8 | { 9 | public async Task, NotFound>> UpdateTrainerById(int id, Trainer updatedTrainer) 10 | { 11 | var trainer = await trainingDb.Trainers.FindAsync(id); 12 | if (trainer is null) return TypedResults.NotFound(); 13 | trainer = updatedTrainer; 14 | await trainingDb.SaveChangesAsync(); 15 | return TypedResults.Created($"/trainers/{trainer.Id}", trainer); 16 | } 17 | 18 | public Results, NotFound> CreateTrainer(Trainer trainer) 19 | { 20 | trainingDb.Trainers.Add(trainer); 21 | return TypedResults.Created($"/trainers/{trainer.Id}", trainer); 22 | } 23 | 24 | public async Task>, NotFound>> GetTrainers() 25 | { 26 | var trainers = await trainingDb.Trainers.ToListAsync(); 27 | return Results.Extensions.Xml(trainers); 28 | } 29 | 30 | public async Task, NotFound>> DeleteTrainerById(int id) 31 | { 32 | var trainer = await trainingDb.Trainers.FindAsync(id); 33 | if (trainer is null) return TypedResults.NotFound(); 34 | trainingDb.Trainers.Remove(trainer); 35 | return TypedResults.Ok(trainer); 36 | } 37 | 38 | public async Task, NotFound>> GetTrainerById(int id) 39 | { 40 | var trainer = await trainingDb.Trainers.FindAsync(id); 41 | return trainer is null ? TypedResults.NotFound() : TypedResults.Ok(trainer); 42 | } 43 | } -------------------------------------------------------------------------------- /src/TrainingApi.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | f5d748ed-7c5f-4b69-a11a-f2e2b88c6c7d 8 | true 9 | 10 | 11 | 12 | $(MSBuildProjectDirectory) 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | all 24 | runtime; build; native; contentfiles; analyzers 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/TrainingApi.http: -------------------------------------------------------------------------------- 1 | @HostAddress = http://localhost:5198 2 | 3 | GET {{HostAddress}}/clients/1 4 | 5 | ### 6 | 7 | GET {{HostAddress}}/clients/666 8 | 9 | -------------------------------------------------------------------------------- /src/TrainingApi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.1.1", 3 | "info": { 4 | "title": "TrainingApi | v1", 5 | "version": "1.0.0" 6 | }, 7 | "paths": { 8 | "/clients/{id}": { 9 | "get": { 10 | "tags": [ 11 | "Clients" 12 | ], 13 | "description": "Get a client", 14 | "operationId": "GetClient", 15 | "parameters": [ 16 | { 17 | "name": "id", 18 | "in": "path", 19 | "description": "The unique identifier of the client, assigned by the system when the client is created", 20 | "required": true, 21 | "schema": { 22 | "pattern": "^-?(?:0|[1-9]\\d*)$", 23 | "type": [ 24 | "integer", 25 | "string" 26 | ], 27 | "format": "int32" 28 | } 29 | } 30 | ], 31 | "responses": { 32 | "200": { 33 | "description": "OK", 34 | "content": { 35 | "application/json": { 36 | "schema": { 37 | "$ref": "#/components/schemas/Client" 38 | } 39 | } 40 | } 41 | }, 42 | "404": { 43 | "description": "Not Found" 44 | } 45 | } 46 | }, 47 | "put": { 48 | "tags": [ 49 | "Clients" 50 | ], 51 | "description": "Update a client", 52 | "operationId": "UpdateClient", 53 | "parameters": [ 54 | { 55 | "name": "id", 56 | "in": "path", 57 | "description": "The unique identifier of the client, assigned by the system when the client is created", 58 | "required": true, 59 | "schema": { 60 | "pattern": "^-?(?:0|[1-9]\\d*)$", 61 | "type": [ 62 | "integer", 63 | "string" 64 | ], 65 | "format": "int32" 66 | } 67 | } 68 | ], 69 | "requestBody": { 70 | "content": { 71 | "application/json": { 72 | "schema": { 73 | "$ref": "#/components/schemas/Client" 74 | } 75 | } 76 | }, 77 | "required": true 78 | }, 79 | "responses": { 80 | "201": { 81 | "description": "Created", 82 | "content": { 83 | "application/json": { 84 | "schema": { 85 | "$ref": "#/components/schemas/Client" 86 | } 87 | } 88 | } 89 | }, 90 | "404": { 91 | "description": "Not Found" 92 | } 93 | } 94 | }, 95 | "post": { 96 | "tags": [ 97 | "Clients" 98 | ], 99 | "description": "Delete a client", 100 | "operationId": "DeleteClient", 101 | "parameters": [ 102 | { 103 | "name": "id", 104 | "in": "path", 105 | "description": "The unique identifier of the client, assigned by the system when the client is created", 106 | "required": true, 107 | "schema": { 108 | "pattern": "^-?(?:0|[1-9]\\d*)$", 109 | "type": [ 110 | "integer", 111 | "string" 112 | ], 113 | "format": "int32" 114 | } 115 | } 116 | ], 117 | "responses": { 118 | "200": { 119 | "description": "OK", 120 | "content": { 121 | "application/json": { 122 | "schema": { 123 | "$ref": "#/components/schemas/Client" 124 | } 125 | } 126 | } 127 | }, 128 | "404": { 129 | "description": "Not Found" 130 | } 131 | } 132 | } 133 | }, 134 | "/clients": { 135 | "post": { 136 | "tags": [ 137 | "Clients" 138 | ], 139 | "description": "Create a client", 140 | "operationId": "CreateClient", 141 | "requestBody": { 142 | "content": { 143 | "application/json": { 144 | "schema": { 145 | "$ref": "#/components/schemas/Client" 146 | } 147 | } 148 | }, 149 | "required": true 150 | }, 151 | "responses": { 152 | "201": { 153 | "description": "Created", 154 | "content": { 155 | "application/json": { 156 | "schema": { 157 | "$ref": "#/components/schemas/Client" 158 | } 159 | } 160 | } 161 | }, 162 | "404": { 163 | "description": "Not Found" 164 | } 165 | } 166 | } 167 | }, 168 | "/trainers": { 169 | "get": { 170 | "tags": [ 171 | "Trainers" 172 | ], 173 | "description": "List all trainers", 174 | "operationId": "ListTrainers", 175 | "responses": { 176 | "200": { 177 | "description": "OK", 178 | "content": { 179 | "application/xml": { 180 | "schema": { 181 | "type": "array", 182 | "items": { 183 | "$ref": "#/components/schemas/Trainer" 184 | } 185 | } 186 | } 187 | } 188 | }, 189 | "404": { 190 | "description": "Not Found" 191 | } 192 | }, 193 | "security": [ 194 | { } 195 | ] 196 | }, 197 | "post": { 198 | "tags": [ 199 | "Trainers" 200 | ], 201 | "description": "Create a trainer", 202 | "operationId": "CreateTrainer", 203 | "requestBody": { 204 | "content": { 205 | "application/json": { 206 | "schema": { 207 | "$ref": "#/components/schemas/Trainer" 208 | } 209 | } 210 | }, 211 | "required": true 212 | }, 213 | "responses": { 214 | "201": { 215 | "description": "Created", 216 | "content": { 217 | "application/json": { 218 | "schema": { 219 | "$ref": "#/components/schemas/Trainer" 220 | } 221 | } 222 | } 223 | }, 224 | "404": { 225 | "description": "Not Found" 226 | } 227 | }, 228 | "security": [ 229 | { } 230 | ] 231 | } 232 | }, 233 | "/trainers/{id}": { 234 | "put": { 235 | "tags": [ 236 | "Trainers" 237 | ], 238 | "description": "Update a trainer", 239 | "operationId": "UpdateTrainer", 240 | "parameters": [ 241 | { 242 | "name": "id", 243 | "in": "path", 244 | "description": "The unique identifier of the trainer, assigned by the system when the client is created", 245 | "required": true, 246 | "schema": { 247 | "pattern": "^-?(?:0|[1-9]\\d*)$", 248 | "type": [ 249 | "integer", 250 | "string" 251 | ], 252 | "format": "int32" 253 | } 254 | } 255 | ], 256 | "requestBody": { 257 | "content": { 258 | "application/json": { 259 | "schema": { 260 | "$ref": "#/components/schemas/Trainer" 261 | } 262 | } 263 | }, 264 | "required": true 265 | }, 266 | "responses": { 267 | "201": { 268 | "description": "Created", 269 | "content": { 270 | "application/json": { 271 | "schema": { 272 | "$ref": "#/components/schemas/Trainer" 273 | } 274 | } 275 | } 276 | }, 277 | "404": { 278 | "description": "Not Found" 279 | } 280 | }, 281 | "security": [ 282 | { } 283 | ] 284 | }, 285 | "delete": { 286 | "tags": [ 287 | "Trainers" 288 | ], 289 | "description": "Delete a trainer", 290 | "operationId": "DeleteTrainer", 291 | "parameters": [ 292 | { 293 | "name": "id", 294 | "in": "path", 295 | "description": "The unique identifier of the trainer, assigned by the system when the client is created", 296 | "required": true, 297 | "schema": { 298 | "pattern": "^-?(?:0|[1-9]\\d*)$", 299 | "type": [ 300 | "integer", 301 | "string" 302 | ], 303 | "format": "int32" 304 | } 305 | } 306 | ], 307 | "responses": { 308 | "200": { 309 | "description": "OK", 310 | "content": { 311 | "application/json": { 312 | "schema": { 313 | "$ref": "#/components/schemas/Trainer" 314 | } 315 | } 316 | } 317 | }, 318 | "404": { 319 | "description": "Not Found" 320 | } 321 | }, 322 | "security": [ 323 | { } 324 | ] 325 | } 326 | } 327 | }, 328 | "components": { 329 | "schemas": { 330 | "Client": { 331 | "required": [ 332 | "id", 333 | "firstName", 334 | "lastName", 335 | "email", 336 | "weight", 337 | "height", 338 | "birthDate" 339 | ], 340 | "type": "object", 341 | "properties": { 342 | "id": { 343 | "pattern": "^-?(?:0|[1-9]\\d*)$", 344 | "type": [ 345 | "integer", 346 | "string" 347 | ], 348 | "description": "The unique identifier of the client, assigned by the system when the client is created.", 349 | "format": "int32" 350 | }, 351 | "firstName": { 352 | "type": "string", 353 | "description": "The first name of the client." 354 | }, 355 | "lastName": { 356 | "type": "string", 357 | "description": "The last name of the client." 358 | }, 359 | "email": { 360 | "type": "string", 361 | "description": "The email of the client." 362 | }, 363 | "weight": { 364 | "pattern": "^-?(?:0|[1-9]\\d*)$", 365 | "type": [ 366 | "integer", 367 | "string" 368 | ], 369 | "description": "The weight of the client in pounds, rounded to the nearest pound.", 370 | "format": "int32" 371 | }, 372 | "height": { 373 | "pattern": "^-?(?:0|[1-9]\\d*)$", 374 | "type": [ 375 | "integer", 376 | "string" 377 | ], 378 | "description": "The height of the client in inches, rounded to the nearest inch.", 379 | "format": "int32" 380 | }, 381 | "birthDate": { 382 | "type": "string", 383 | "description": "The date of birth of the client.", 384 | "format": "date-time" 385 | } 386 | }, 387 | "description": "Represents a client with personal information.", 388 | "example": { 389 | "id": 1, 390 | "firstName": "Jane", 391 | "lastName": "Smith", 392 | "email": "jane.smith@email.com", 393 | "weight": 60, 394 | "height": 170, 395 | "birthDate": "1990-01-01T00:00:00.0000000-08:00" 396 | } 397 | }, 398 | "Level": { 399 | "enum": [ 400 | "Junior", 401 | "Senior", 402 | "Elite" 403 | ], 404 | "description": "The level of the trainer." 405 | }, 406 | "Trainer": { 407 | "type": "object", 408 | "properties": { 409 | "id": { 410 | "pattern": "^-?(?:0|[1-9]\\d*)$", 411 | "type": [ 412 | "integer", 413 | "string" 414 | ], 415 | "description": "The unique identifier of the trainer, assigned by the system when the trainer is created.", 416 | "format": "int32" 417 | }, 418 | "firstName": { 419 | "type": "string", 420 | "description": "The first name of the trainer." 421 | }, 422 | "lastName": { 423 | "type": "string", 424 | "description": "The last name of the trainer." 425 | }, 426 | "email": { 427 | "type": "string", 428 | "description": "The email address of the trainer." 429 | }, 430 | "level": { 431 | "$ref": "#/components/schemas/Level" 432 | }, 433 | "isCertificationActive": { 434 | "type": "boolean", 435 | "description": "Indicates whether the trainer's certification is active." 436 | } 437 | }, 438 | "example": { 439 | "id": 1, 440 | "firstName": "John", 441 | "lastName": "Doe", 442 | "email": "john.doe@email.com", 443 | "level": "Junior", 444 | "isCertificationActive": false 445 | } 446 | } 447 | }, 448 | "securitySchemes": { 449 | "Bearer": { 450 | "type": "http", 451 | "scheme": "Bearer" 452 | } 453 | } 454 | }, 455 | "tags": [ 456 | { 457 | "name": "Clients" 458 | }, 459 | { 460 | "name": "Trainers" 461 | } 462 | ] 463 | } -------------------------------------------------------------------------------- /src/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "Authentication": { 9 | "DefaultScheme": "Bearer", 10 | "Authentication": { 11 | "Schemes": { 12 | "Bearer": { 13 | "ValidAudiences": [ 14 | "http://localhost:1739", 15 | "https://localhost:44360", 16 | "http://localhost:5198", 17 | "https://localhost:7088" 18 | ], 19 | "ValidIssuer": "dotnet-user-jwts" 20 | } 21 | } 22 | }, 23 | "Schemes": { 24 | "Bearer": { 25 | "ValidAudiences": [ 26 | "http://localhost:1739", 27 | "https://localhost:44360", 28 | "http://localhost:5198", 29 | "https://localhost:7088" 30 | ], 31 | "ValidIssuer": "dotnet-user-jwts" 32 | } 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /tests/ApiApplication.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Testing; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Hosting; 5 | using Microsoft.AspNetCore.Authentication; 6 | using TrainingApi.Shared; 7 | 8 | internal class ApiApplication : WebApplicationFactory 9 | { 10 | private readonly string _environment; 11 | 12 | public ApiApplication(string environment = "Development") 13 | { 14 | _environment = environment; 15 | } 16 | 17 | protected override IHost CreateHost(IHostBuilder builder) 18 | { 19 | builder.UseEnvironment(_environment); 20 | 21 | builder.ConfigureServices(services => 22 | { 23 | services.AddScoped(sp => 24 | { 25 | // Replace SQL with in-memory database for tests 26 | return new DbContextOptionsBuilder() 27 | .UseInMemoryDatabase("Tests") 28 | .UseApplicationServiceProvider(sp) 29 | .Options; 30 | }); 31 | }); 32 | 33 | return base.CreateHost(builder); 34 | } 35 | } -------------------------------------------------------------------------------- /tests/IntegrationTests.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Http.Json; 3 | using TrainingApi.Shared; 4 | using TrainingApi; 5 | 6 | namespace TrainingApi.Tests; 7 | 8 | public class IntegrationTests 9 | { 10 | [Fact] 11 | public async Task GET_Client_ReturnsClient() 12 | { 13 | // Arrange 14 | var app = new ApiApplication(); 15 | 16 | // Act 17 | var client = app.CreateClient(); 18 | var response = await client.GetAsync("/clients/1"); 19 | var responseBody = await response.Content.ReadAsStringAsync(); 20 | 21 | // Assert 22 | Assert.Equal(HttpStatusCode.OK, response.StatusCode); 23 | } 24 | } -------------------------------------------------------------------------------- /tests/TestDbAsyncQueryProvider.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Query; 2 | using System.Linq.Expressions; 3 | 4 | internal class TestDbAsyncQueryProvider : IAsyncQueryProvider 5 | { 6 | private readonly IQueryProvider _inner; 7 | 8 | internal TestDbAsyncQueryProvider(IQueryProvider inner) 9 | { 10 | _inner = inner; 11 | } 12 | 13 | public IQueryable CreateQuery(Expression expression) 14 | { 15 | return new TestDbAsyncEnumerable(expression); 16 | } 17 | 18 | public IQueryable CreateQuery(Expression expression) 19 | { 20 | return new TestDbAsyncEnumerable(expression); 21 | } 22 | 23 | public object Execute(Expression expression) 24 | { 25 | return CompileExpressionItem(expression); 26 | } 27 | 28 | public TResult Execute(Expression expression) 29 | { 30 | return CompileExpressionItem(expression); 31 | } 32 | 33 | public TResult ExecuteAsync(Expression expression, CancellationToken cancellationToken) 34 | { 35 | var expectedResultType = typeof(TResult).GetGenericArguments()[0]; 36 | var executionResult = typeof(IQueryProvider) 37 | .GetMethod( 38 | name: nameof(IQueryProvider.Execute), 39 | genericParameterCount: 1, 40 | types: new[] { typeof(Expression) })! 41 | .MakeGenericMethod(expectedResultType) 42 | .Invoke(this, new[] { expression }); 43 | 44 | return (TResult)typeof(Task).GetMethod(nameof(Task.FromResult))! 45 | .MakeGenericMethod(expectedResultType) 46 | .Invoke(null, new[] { executionResult })!; 47 | } 48 | 49 | private static T CompileExpressionItem(Expression expression) 50 | => Expression.Lambda>( 51 | body: new Visitor().Visit(expression) ?? throw new InvalidOperationException("Visitor returns null"), 52 | parameters: null) 53 | .Compile()(); 54 | 55 | private class Visitor : ExpressionVisitor { } 56 | } 57 | 58 | internal class TestDbAsyncEnumerable : EnumerableQuery, IAsyncEnumerable, IQueryable 59 | { 60 | public TestDbAsyncEnumerable(IEnumerable enumerable) 61 | : base(enumerable) 62 | { } 63 | 64 | public TestDbAsyncEnumerable(Expression expression) 65 | : base(expression) 66 | { } 67 | 68 | public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken= default) 69 | { 70 | return new TestDbAsyncEnumerator(this.AsEnumerable().GetEnumerator()); 71 | } 72 | 73 | IQueryProvider IQueryable.Provider 74 | { 75 | get { return new TestDbAsyncQueryProvider(this); } 76 | } 77 | } 78 | 79 | internal class TestDbAsyncEnumerator : IAsyncEnumerator 80 | { 81 | private readonly IEnumerator _inner; 82 | 83 | public TestDbAsyncEnumerator(IEnumerator inner) 84 | { 85 | _inner = inner; 86 | } 87 | 88 | public ValueTask DisposeAsync() 89 | { 90 | _inner.Dispose(); 91 | return new ValueTask(Task.CompletedTask); 92 | } 93 | 94 | public ValueTask MoveNextAsync() 95 | { 96 | return new ValueTask(_inner.MoveNext()); 97 | 98 | } 99 | 100 | public T Current 101 | { 102 | get { return _inner.Current; } 103 | } 104 | } -------------------------------------------------------------------------------- /tests/TrainingApi.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | 9 | 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 | -------------------------------------------------------------------------------- /tests/UnitTests.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using System.Net; 3 | using System.Net.Http.Json; 4 | using TrainingApi.Shared; 5 | using TrainingApi.Services; 6 | using Moq; 7 | using Microsoft.EntityFrameworkCore; 8 | using Microsoft.AspNetCore.Http; 9 | using Microsoft.AspNetCore.Http.HttpResults; 10 | 11 | public class UnitTests 12 | { 13 | [Fact] 14 | public void CreateClientReturnsCorrectResponse() 15 | { 16 | // Arrange 17 | var clientToCreate = new Client(4, "Gretchen", "Beslier", "gbeslier0@nba.com", 311, 65, DateTime.Parse("7/22/1984", CultureInfo.InvariantCulture)); 18 | var mockContext = CreateMockDbContext(); 19 | var service = new ClientsService(mockContext.Object); 20 | 21 | // Act 22 | var result = service.CreateClient(clientToCreate); 23 | 24 | // Assert 25 | var typedResult = Assert.IsType>(result.Result); 26 | Assert.Equal(StatusCodes.Status201Created, typedResult.StatusCode); 27 | Assert.Equal(clientToCreate, typedResult.Value); 28 | } 29 | 30 | private static Mock CreateMockDbContext() 31 | { 32 | var clients = new List 33 | { 34 | new Client(1, "Vonnie", "Mawer", "vmawer0@go.com", 149, 66, DateTime.Parse("4/24/2000", CultureInfo.InvariantCulture)), 35 | new Client(2, "Langston", "Feldberg", "lfeldberg1@hc360.com", 329, 73, DateTime.Parse("10/20/1982", CultureInfo.InvariantCulture)), 36 | new Client(3, "Olwen", "Maeer", "omaeer3@purevolume.com", 261, 70, DateTime.Parse("8/22/1993", CultureInfo.InvariantCulture)) 37 | }.AsQueryable(); 38 | var trainers = new List 39 | { 40 | new Trainer(1, "Inna", "Spedroni", "ispedroni0@studiopress.com", Level.Junior, true), 41 | new Trainer(2, "Nikoletta", "Orrell", "norrell1@nydailynews.com", Level.Senior, true), 42 | new Trainer(3, "Briana", "Diprose", "bdiprose0@t.co", Level.Senior, true), 43 | new Trainer(4, "Zerk", "Riepl", "svanshin5@google.com", Level.Elite, true) 44 | }.AsQueryable(); 45 | var mockClientSet = CreateMockDbSet(clients); 46 | var mockTrainerSet = CreateMockDbSet(trainers); 47 | 48 | var mockContext = new Mock(); 49 | mockContext.Setup(m => m.Clients).Returns(mockClientSet.Object); 50 | mockContext.Setup(m => m.Trainers).Returns(mockTrainerSet.Object); 51 | 52 | return mockContext; 53 | } 54 | 55 | private static Mock> CreateMockDbSet(IQueryable data) where T: class 56 | { 57 | var mockSet = new Mock>(); 58 | mockSet.As>() 59 | .Setup(m => m.GetAsyncEnumerator(new CancellationToken())) 60 | .Returns(new TestDbAsyncEnumerator(data.GetEnumerator())); 61 | 62 | mockSet.As>() 63 | .Setup(m => m.Provider) 64 | .Returns(new TestDbAsyncQueryProvider(data.Provider)); 65 | 66 | mockSet.As>().Setup(m => m.Expression).Returns(data.Expression); 67 | mockSet.As>().Setup(m => m.ElementType).Returns(data.ElementType); 68 | mockSet.As>().Setup(m => m.GetEnumerator()).Returns(data.GetEnumerator()); 69 | 70 | return mockSet; 71 | } 72 | } -------------------------------------------------------------------------------- /tests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; --------------------------------------------------------------------------------