diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index c4dc6a4c..ea68f2a9 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -25,14 +25,18 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v2 with: - dotnet-version: 9.* + dotnet-version: 10.* + include-prerelease: true + - name: Build Reason run: echo ${{github.ref}} and ${{github.event_name}} + - name: Build with dotnet run: >- dotnet build --configuration Release --nologo + - name: Run tests run: >- dotnet test @@ -40,13 +44,13 @@ jobs: --logger trx --no-build --nologo - - name: Publish test results - uses: dorny/test-reporter@v1 + + - name: Upload test results + uses: actions/upload-artifact@v4 if: always() with: - name: .NET tests - path: '**/*.trx' - reporter: dotnet-trx + name: test-results + path: '**/*.trx' # PR merged or main tag created, create the CarterTemplate NuGet package - if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/newtonsoft-') diff --git a/.github/workflows/test-results.yml b/.github/workflows/test-results.yml new file mode 100644 index 00000000..bdf238f0 --- /dev/null +++ b/.github/workflows/test-results.yml @@ -0,0 +1,26 @@ +name: Test Results + +on: + workflow_run: + workflows: [".NET Core"] + types: + - completed + +jobs: + test-results: + name: Publish Test Results + runs-on: ubuntu-latest + if: always() + permissions: + contents: read + actions: read + checks: write + + steps: + - name: Publish test results + uses: dorny/test-reporter@v2 + with: + name: .NET tests + artifact: test-results + path: 'test-results/**/*.trx' + reporter: dotnet-trx diff --git a/Carter.sln b/Carter.sln index 08932f74..c303c346 100644 --- a/Carter.sln +++ b/Carter.sln @@ -34,6 +34,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Carter.ResponseNegotiators. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Carter.Analyzers", "src\Carter.Analyzers\Carter.Analyzers.csproj", "{C15C71C0-73CE-41DA-8EEE-C66318BF3A6C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFwk", "samples\EntityFwk\EntityFwk.csproj", "{E907925B-A874-40EA-A7A8-0A41E462A465}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -164,6 +166,18 @@ Global {C15C71C0-73CE-41DA-8EEE-C66318BF3A6C}.Release|x64.Build.0 = Release|Any CPU {C15C71C0-73CE-41DA-8EEE-C66318BF3A6C}.Release|x86.ActiveCfg = Release|Any CPU {C15C71C0-73CE-41DA-8EEE-C66318BF3A6C}.Release|x86.Build.0 = Release|Any CPU + {E907925B-A874-40EA-A7A8-0A41E462A465}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E907925B-A874-40EA-A7A8-0A41E462A465}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E907925B-A874-40EA-A7A8-0A41E462A465}.Debug|x64.ActiveCfg = Debug|Any CPU + {E907925B-A874-40EA-A7A8-0A41E462A465}.Debug|x64.Build.0 = Debug|Any CPU + {E907925B-A874-40EA-A7A8-0A41E462A465}.Debug|x86.ActiveCfg = Debug|Any CPU + {E907925B-A874-40EA-A7A8-0A41E462A465}.Debug|x86.Build.0 = Debug|Any CPU + {E907925B-A874-40EA-A7A8-0A41E462A465}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E907925B-A874-40EA-A7A8-0A41E462A465}.Release|Any CPU.Build.0 = Release|Any CPU + {E907925B-A874-40EA-A7A8-0A41E462A465}.Release|x64.ActiveCfg = Release|Any CPU + {E907925B-A874-40EA-A7A8-0A41E462A465}.Release|x64.Build.0 = Release|Any CPU + {E907925B-A874-40EA-A7A8-0A41E462A465}.Release|x86.ActiveCfg = Release|Any CPU + {E907925B-A874-40EA-A7A8-0A41E462A465}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -179,6 +193,7 @@ Global {D30E87B0-39AE-41FE-8BA3-E5F9FBFFDD64} = {5D056A8A-821C-4C3C-A281-4FA7F8CE251B} {47DD3AA8-F072-41E3-AF7F-119DBBD8D14A} = {DCB5B9A0-F06D-4BDF-917D-A459C790C718} {C15C71C0-73CE-41DA-8EEE-C66318BF3A6C} = {5D056A8A-821C-4C3C-A281-4FA7F8CE251B} + {E907925B-A874-40EA-A7A8-0A41E462A465} = {35DE35A0-758D-4FDD-BDA3-67F04F65677D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {9096DE78-6327-48BA-AE0E-336F769681A7} diff --git a/README.md b/README.md index 3548e646..d56829c2 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,10 @@ Other extensions include: * `Validate / ValidateAsync` - [FluentValidation](https://github.com/JeremySkinner/FluentValidation) extensions to validate incoming HTTP requests which is not available with ASP.NET Core Minimal APIs. * `BindFile/BindFiles/BindFileAndSave/BindFilesAndSave` - Allows you to easily get access to a file/files that has been uploaded. Alternatively you can call `BindFilesAndSave` and this will save it to a path you specify. -* Routes to use in common ASP.NET Core middleware e.g., `app.UseExceptionHandler("/errorhandler");`. +* `MapPost/MapPut` - Allows Carter to validate `T` and if it fails it returns a 422 Problem Details response. +* `MapFormPost` - Allows Carter to model bind `T` when submitting a form to the route. * `IResponseNegotiator`s allow you to define how the response should look on a certain Accept header(content negotiation). Handling JSON is built in the default response but implementing an interface allows the user to choose how they want to represent resources. +* Routes to use in common ASP.NET Core middleware e.g., `app.UseExceptionHandler("/errorhandler");`. * All interface implementations for Carter components are registered into ASP.NET Core DI automatically. Implement the interface and off you go. ### Releases @@ -118,6 +120,7 @@ public class HomeModule : ICarterModule }); app.MapGet("/conneg", (HttpResponse res) => res.Negotiate(new { Name = "Dave" })); app.MapPost("/validation", HandlePost); + app.MapFormPost("/formpost", (Person model) => TypedResults.Ok(model)).DisableAntiforgery(); } private IResult HandlePost(HttpContext ctx, Person person, IDatabase database) @@ -156,7 +159,7 @@ public class Database : IDatabase ### Configuration -As mentioned earlier Carter will scan for implementations in your app and register them for DI. However, if you want a more controlled app, Carter comes with a `CarterConfigurator` that allows you to register modules, validators and response negotiators manually. +As mentioned earlier Carter will scan for implementations in your app and register them for DI. However, if you want a more controlled app, Carter comes with a `CarterConfigurator` that allows you to register modules, validators and response negotiators manually and configure validator lifetimes. Carter will use a response negotiator based on `System.Text.Json`, though it provides for custom implementations via the `IResponseNegotiator` interface. To use your own implementation of `IResponseNegotiator` (say, `CustomResponseNegotiator`), add the following line to the initial Carter configuration, in this case as part of `Program.cs`: @@ -166,16 +169,11 @@ Carter will use a response negotiator based on `System.Text.Json`, though it pro { c.WithResponseNegotiator(); c.WithModule(); - c.WithValidator() + c.WithValidator(); + c.WithDefaultValidatorLifetime(ServiceLifetime.Singleton); + c.WithValidatorServiceLifetimeFactory(t => {if t is PersonValidator...}) }); ``` -Here again, Carter already ships with a response negotiator using `Newtonsoft.Json`, so you can wire up the Newtonsoft implementation with the following line: - -```csharp - builder.Services.AddCarter(configurator: c => - { - c.WithResponseNegotiator(); - }); -``` +If you wish to use `Newtonsoft.Json` Carter already ships a response negotiator in the package `Carter.ResponseNegotiators.Newtonsoft`. Once installed, it will automatically pick it up with no registration needed. \ No newline at end of file diff --git a/samples/CarterAndMVC/CarterAndMVC.csproj b/samples/CarterAndMVC/CarterAndMVC.csproj index 5e6d251d..6e885841 100644 --- a/samples/CarterAndMVC/CarterAndMVC.csproj +++ b/samples/CarterAndMVC/CarterAndMVC.csproj @@ -1,6 +1,6 @@  - net9.0 + net10.0 CarterAndMVC Exe latest diff --git a/samples/CarterAndMVC/Program.cs b/samples/CarterAndMVC/Program.cs index c50efae9..b847fafa 100644 --- a/samples/CarterAndMVC/Program.cs +++ b/samples/CarterAndMVC/Program.cs @@ -1,19 +1,17 @@ -namespace CarterAndMVC -{ - using System.IO; - using Microsoft.AspNetCore.Hosting; +using Carter; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; - public class Program - { - public static void Main(string[] args) - { - var host = new WebHostBuilder() - .UseContentRoot(Directory.GetCurrentDirectory()) - .UseKestrel() - .UseStartup() - .Build(); +var builder = WebApplication.CreateBuilder(args); - host.Run(); - } - } -} +builder.Services.AddCarter(); +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.MapCarter(); +app.MapControllerRoute( + name: "default", + pattern: "{controller=Home}/{action=Index}/{id?}"); + +app.Run(); diff --git a/samples/CarterAndMVC/Startup.cs b/samples/CarterAndMVC/Startup.cs deleted file mode 100644 index a8cfc2b9..00000000 --- a/samples/CarterAndMVC/Startup.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace CarterAndMVC -{ - using Carter; - using Microsoft.AspNetCore.Builder; - using Microsoft.Extensions.DependencyInjection; - - public class Startup - { - public void ConfigureServices(IServiceCollection services) - { - services.AddCarter(); - services.AddControllers(); - } - - public void Configure(IApplicationBuilder app) - { - app.UseRouting(); - app.UseEndpoints(builder => - { - builder.MapDefaultControllerRoute(); - builder.MapCarter(); - }); - } - } -} diff --git a/samples/CarterSample/CarterSample.csproj b/samples/CarterSample/CarterSample.csproj index ae9a2eb8..97bf7b89 100644 --- a/samples/CarterSample/CarterSample.csproj +++ b/samples/CarterSample/CarterSample.csproj @@ -1,7 +1,7 @@  enable - net9.0 + net10.0 latest diff --git a/samples/CarterSample/Features/Actors/ActorValidator.cs b/samples/CarterSample/Features/Actors/ActorValidator.cs index 4372a0d3..dd7e5562 100644 --- a/samples/CarterSample/Features/Actors/ActorValidator.cs +++ b/samples/CarterSample/Features/Actors/ActorValidator.cs @@ -2,6 +2,7 @@ namespace CarterSample.Features.Actors; using FluentValidation; +[ValidatorLifetimeAttribute(ServiceLifetime.Scoped)] public class ActorValidator : AbstractValidator { public ActorValidator() diff --git a/samples/EntityFwk/EntityFwk.csproj b/samples/EntityFwk/EntityFwk.csproj new file mode 100644 index 00000000..06218d72 --- /dev/null +++ b/samples/EntityFwk/EntityFwk.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + enable + True + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/samples/EntityFwk/Migrations/20251008114112_InitialCreate.Designer.cs b/samples/EntityFwk/Migrations/20251008114112_InitialCreate.Designer.cs new file mode 100644 index 00000000..d0b2ccc1 --- /dev/null +++ b/samples/EntityFwk/Migrations/20251008114112_InitialCreate.Designer.cs @@ -0,0 +1,81 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntityFwk.Migrations +{ + [DbContext(typeof(BloggingContext))] + [Migration("20251008114112_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0-rc.1.25451.107"); + + modelBuilder.Entity("Blog", b => + { + b.Property("BlogId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("BlogId"); + + b.ToTable("Blogs"); + }); + + modelBuilder.Entity("Post", b => + { + b.Property("PostId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlogId") + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("PostId"); + + b.HasIndex("BlogId"); + + b.ToTable("Post"); + }); + + modelBuilder.Entity("Post", b => + { + b.HasOne("Blog", "Blog") + .WithMany("Posts") + .HasForeignKey("BlogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Blog"); + }); + + modelBuilder.Entity("Blog", b => + { + b.Navigation("Posts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/samples/EntityFwk/Migrations/20251008114112_InitialCreate.cs b/samples/EntityFwk/Migrations/20251008114112_InitialCreate.cs new file mode 100644 index 00000000..af37760f --- /dev/null +++ b/samples/EntityFwk/Migrations/20251008114112_InitialCreate.cs @@ -0,0 +1,63 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EntityFwk.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Blogs", + columns: table => new + { + BlogId = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Url = table.Column(type: "TEXT", maxLength: 255, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Blogs", x => x.BlogId); + }); + + migrationBuilder.CreateTable( + name: "Post", + columns: table => new + { + PostId = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Title = table.Column(type: "TEXT", maxLength: 50, nullable: false), + Content = table.Column(type: "TEXT", maxLength: 1000, nullable: false), + BlogId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Post", x => x.PostId); + table.ForeignKey( + name: "FK_Post_Blogs_BlogId", + column: x => x.BlogId, + principalTable: "Blogs", + principalColumn: "BlogId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Post_BlogId", + table: "Post", + column: "BlogId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Post"); + + migrationBuilder.DropTable( + name: "Blogs"); + } + } +} diff --git a/samples/EntityFwk/Migrations/BloggingContextModelSnapshot.cs b/samples/EntityFwk/Migrations/BloggingContextModelSnapshot.cs new file mode 100644 index 00000000..cd5402a5 --- /dev/null +++ b/samples/EntityFwk/Migrations/BloggingContextModelSnapshot.cs @@ -0,0 +1,78 @@ +// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EntityFwk.Migrations +{ + [DbContext(typeof(BloggingContext))] + partial class BloggingContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0-rc.1.25451.107"); + + modelBuilder.Entity("Blog", b => + { + b.Property("BlogId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("BlogId"); + + b.ToTable("Blogs"); + }); + + modelBuilder.Entity("Post", b => + { + b.Property("PostId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BlogId") + .HasColumnType("INTEGER"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("PostId"); + + b.HasIndex("BlogId"); + + b.ToTable("Post"); + }); + + modelBuilder.Entity("Post", b => + { + b.HasOne("Blog", "Blog") + .WithMany("Posts") + .HasForeignKey("BlogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Blog"); + }); + + modelBuilder.Entity("Blog", b => + { + b.Navigation("Posts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/samples/EntityFwk/MyModule.cs b/samples/EntityFwk/MyModule.cs new file mode 100644 index 00000000..b07154b5 --- /dev/null +++ b/samples/EntityFwk/MyModule.cs @@ -0,0 +1,41 @@ +namespace EntityFwk; + +using Carter; +using Microsoft.EntityFrameworkCore; + +public class MyModule : ICarterModule +{ + public void AddRoutes(IEndpointRouteBuilder app) + { + app.MapGet("/", async () => + { + await using var db = new BloggingContext(); + + // Note: This sample requires the database to be created before running. + Console.WriteLine($"Database path: {db.DbPath}."); + + // Create + Console.WriteLine("Inserting a new blog"); + db.Add(new Blog { Url = "http://blogs.msdn.com/adonet" }); + await db.SaveChangesAsync(); + + // Read + Console.WriteLine("Querying for a blog"); + var blog = await db.Blogs + .OrderBy(b => b.BlogId) + .FirstAsync(); + + // Update + Console.WriteLine("Updating the blog and adding a post"); + blog.Url = "https://devblogs.microsoft.com/dotnet"; + blog.Posts.Add( + new Post { Title = "Hello World", Content = "I wrote an app using EF Core!" }); + await db.SaveChangesAsync(); + + // Delete + Console.WriteLine("Delete the blog"); + db.Remove(blog); + await db.SaveChangesAsync(); + }); + } +} diff --git a/samples/EntityFwk/Program.cs b/samples/EntityFwk/Program.cs new file mode 100644 index 00000000..c6ce44e1 --- /dev/null +++ b/samples/EntityFwk/Program.cs @@ -0,0 +1,54 @@ +using System.ComponentModel.DataAnnotations; +using Carter; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddCarter(); +var app = builder.Build(); +app.MapCarter(); + +app.Run(); + +public class BloggingContext : DbContext +{ + public DbSet Blogs { get; set; } + + public string DbPath { get; } + + public BloggingContext() + { + var folder = Environment.SpecialFolder.LocalApplicationData; + var path = Environment.GetFolderPath(folder); + DbPath = Path.Join(path, "blogging.db"); + } + + // The following configures EF to create a Sqlite database file in the + // special "local" folder for your platform. + protected override void OnConfiguring(DbContextOptionsBuilder options) + => options.UseSqlite($"Data Source={DbPath}"); +} + +public class Blog +{ + public int BlogId { get; set; } + + [MaxLength(255)] + public string Url { get; set; } = null!; + + public List Posts { get; } = new(); +} + +public class Post +{ + public int PostId { get; set; } + + [MaxLength(50)] + public string Title { get; set; } = null!; + + [MaxLength(1000)] + public string Content { get; set; } = null!; + + public int BlogId { get; set; } + + public Blog Blog { get; set; } = new(); +} diff --git a/samples/EntityFwk/appsettings.Development.json b/samples/EntityFwk/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/samples/EntityFwk/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/EntityFwk/appsettings.json b/samples/EntityFwk/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/samples/EntityFwk/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/ValidatorOnlyProject/ValidatorOnlyProject.csproj b/samples/ValidatorOnlyProject/ValidatorOnlyProject.csproj index 98992184..6f3d32da 100644 --- a/samples/ValidatorOnlyProject/ValidatorOnlyProject.csproj +++ b/samples/ValidatorOnlyProject/ValidatorOnlyProject.csproj @@ -1,6 +1,6 @@  - net9.0 + net10.0 diff --git a/src/Carter.Analyzers/Carter.Analyzers.csproj b/src/Carter.Analyzers/Carter.Analyzers.csproj index 74d0e638..1a6bafb9 100644 --- a/src/Carter.Analyzers/Carter.Analyzers.csproj +++ b/src/Carter.Analyzers/Carter.Analyzers.csproj @@ -27,7 +27,7 @@ - + all diff --git a/src/Carter.ResponseNegotiators.Newtonsoft/Carter.ResponseNegotiators.Newtonsoft.csproj b/src/Carter.ResponseNegotiators.Newtonsoft/Carter.ResponseNegotiators.Newtonsoft.csproj index 43ac25df..59cf5e73 100644 --- a/src/Carter.ResponseNegotiators.Newtonsoft/Carter.ResponseNegotiators.Newtonsoft.csproj +++ b/src/Carter.ResponseNegotiators.Newtonsoft/Carter.ResponseNegotiators.Newtonsoft.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 Jonathan Channon Carter is framework that is a thin layer of extension methods and functionality over ASP.NET Core allowing code to be more explicit and most importantly more enjoyable. asp.net core;nancy;.net core;routing;carter diff --git a/src/Carter/Carter.csproj b/src/Carter/Carter.csproj index e4316e0a..735af314 100644 --- a/src/Carter/Carter.csproj +++ b/src/Carter/Carter.csproj @@ -1,6 +1,6 @@  - net9.0 + net10.0 Jonathan Channon Carter is framework that is a thin layer of extension methods and functionality over ASP.NET Core allowing code to be more explicit and most importantly more enjoyable. asp.net core;nancy;.net core;routing;carter diff --git a/src/Carter/CarterConfigurator.cs b/src/Carter/CarterConfigurator.cs index be785e18..18ca1585 100644 --- a/src/Carter/CarterConfigurator.cs +++ b/src/Carter/CarterConfigurator.cs @@ -2,6 +2,7 @@ namespace Carter; using System; using System.Collections.Generic; +using System.Reflection; using FluentValidation; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -16,16 +17,21 @@ internal CarterConfigurator() this.ModuleTypes = new List(); this.ValidatorTypes = new List(); this.ResponseNegotiatorTypes = new List(); - this.ValidatorServiceLifetime = ServiceLifetime.Singleton; + this.DefaultValidatorServiceLifetime = ServiceLifetime.Singleton; + this.ValidatorServiceLifetimeFactory = (validatorType) => + validatorType.GetCustomAttribute()?.Lifetime ?? + this.DefaultValidatorServiceLifetime; } internal bool ExcludeValidators; internal bool ExcludeModules; - + internal bool ExcludeResponseNegotiators; - internal ServiceLifetime ValidatorServiceLifetime; + internal ServiceLifetime DefaultValidatorServiceLifetime; + + internal Func ValidatorServiceLifetimeFactory; internal List ModuleTypes { get; } @@ -131,7 +137,7 @@ public CarterConfigurator WithEmptyValidators() this.ExcludeValidators = true; return this; } - + /// /// Do not register any modules /// @@ -141,7 +147,7 @@ public CarterConfigurator WithEmptyModules() this.ExcludeModules = true; return this; } - + /// /// Do not register any response negotiators /// @@ -153,13 +159,23 @@ public CarterConfigurator WithEmptyResponseNegotiators() } /// - /// Define the lifetime of the validator + /// Define the default lifetime of the validator /// Default: Singleton /// /// - public CarterConfigurator WithValidatorLifetime(ServiceLifetime serviceLifetime) + public CarterConfigurator WithDefaultValidatorLifetime(ServiceLifetime serviceLifetime) + { + this.DefaultValidatorServiceLifetime = serviceLifetime; + return this; + } + + /// + /// Define the validator lifetime factory for custom validator lifetimes based on their type + /// + public CarterConfigurator WithValidatorServiceLifetimeFactory( + Func validatorServiceLifetimeFactory) { - this.ValidatorServiceLifetime = serviceLifetime; + this.ValidatorServiceLifetimeFactory = validatorServiceLifetimeFactory; return this; } } diff --git a/src/Carter/CarterExtensions.cs b/src/Carter/CarterExtensions.cs index 2a0b29f4..476da222 100644 --- a/src/Carter/CarterExtensions.cs +++ b/src/Carter/CarterExtensions.cs @@ -169,43 +169,44 @@ private static void WireupCarter(this IServiceCollection services, var newModules = GetNewModules(carterConfigurator, assemblies); - //var modules = GetModules(carterConfigurator, assemblies); - var responseNegotiators = GetResponseNegotiators(carterConfigurator, assemblies); services.AddSingleton(carterConfigurator); + var validatorLocatorLifetime = ServiceLifetime.Singleton; foreach (var validator in validators) { + var validatorServiceLifetime = carterConfigurator.ValidatorServiceLifetimeFactory(validator); services.Add( new ServiceDescriptor( serviceType: typeof(IValidator), implementationType: validator, - lifetime: carterConfigurator.ValidatorServiceLifetime)); + lifetime: validatorServiceLifetime)); services.Add( - new ServiceDescriptor( - serviceType: validator, - implementationType: validator, - lifetime: carterConfigurator.ValidatorServiceLifetime)); + new ServiceDescriptor( + serviceType: validator, + implementationType: validator, + lifetime: validatorServiceLifetime)); + + if (validatorServiceLifetime > validatorLocatorLifetime) + { + validatorLocatorLifetime = validatorServiceLifetime; + } } + services.Add( - new ServiceDescriptor( - serviceType: typeof(IValidatorLocator), - implementationType: typeof(DefaultValidatorLocator), - lifetime: carterConfigurator.ValidatorServiceLifetime)); + new ServiceDescriptor( + serviceType: typeof(IValidatorLocator), + implementationType: typeof(DefaultValidatorLocator), + lifetime: validatorLocatorLifetime)); foreach (var newModule in newModules) { services.AddSingleton(typeof(ICarterModule), newModule); } - // foreach (var newModule in modules) - // { - // services.AddSingleton(typeof(CarterModule), newModule); - // } - foreach (var negotiator in responseNegotiators) { services.AddSingleton(typeof(IResponseNegotiator), negotiator); @@ -262,30 +263,6 @@ private static IEnumerable GetNewModules(CarterConfigurator carterConfigur return modules; } - private static IEnumerable GetModules(CarterConfigurator carterConfigurator, - IReadOnlyCollection assemblies) - { - // IEnumerable modules; - // if (carterConfigurator.ExcludeModules || carterConfigurator.ModuleTypes.Any()) - // { - // modules = carterConfigurator.ModuleTypes; - // } - // else - //{ - var modules = assemblies.SelectMany(x => x.GetTypes() - .Where(t => - !t.IsAbstract && - typeof(CarterModule).IsAssignableFrom(t) && - t != typeof(CarterModule) && - t.IsPublic - )); - - //carterConfigurator.ModuleTypes.AddRange(modules); - //} - - return modules; - } - private static IEnumerable GetValidators(CarterConfigurator carterConfigurator, IReadOnlyCollection assemblies) { diff --git a/src/Carter/ICarterModule.cs b/src/Carter/ICarterModule.cs index 7227af0d..84479ecf 100644 --- a/src/Carter/ICarterModule.cs +++ b/src/Carter/ICarterModule.cs @@ -9,6 +9,7 @@ namespace Carter; /// /// A base class CarterModule to define settings for all the routes in a module /// +[Obsolete("CarterModule will be removed in the next version. Please migrate to ICarterModule.")] public abstract class CarterModule : ICarterModule { internal string[] hosts = Array.Empty(); diff --git a/src/Carter/RouteExtensions.cs b/src/Carter/RouteExtensions.cs index 3199eee6..7881317b 100644 --- a/src/Carter/RouteExtensions.cs +++ b/src/Carter/RouteExtensions.cs @@ -8,6 +8,7 @@ namespace Carter; using Carter.ModelBinding; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; public static class RouteExtensions @@ -58,4 +59,20 @@ public static RouteHandlerBuilder MapPut( { return endpoints.MapPut(pattern, handler).AddEndpointFilter(async (context, next) => await RouteHandler(context, next)); } + + /// + /// Bind a form to a type without having to specify [FromForm] + /// + /// + /// The route path pattern + /// The route handler + /// The model to bind to + /// + public static RouteHandlerBuilder MapFormPost( + this IEndpointRouteBuilder endpoints, + string pattern, + Func handler) where T : class + { + return endpoints.MapPost(pattern, ([FromForm] T data) => handler(data)); + } } diff --git a/src/Carter/ValidatorLifetimeAttirubute.cs b/src/Carter/ValidatorLifetimeAttirubute.cs new file mode 100644 index 00000000..295e79b0 --- /dev/null +++ b/src/Carter/ValidatorLifetimeAttirubute.cs @@ -0,0 +1,10 @@ +namespace Carter; + +using System; +using Microsoft.Extensions.DependencyInjection; + +[AttributeUsage(AttributeTargets.Class)] +public class ValidatorLifetimeAttribute(ServiceLifetime lifetime) : Attribute +{ + public ServiceLifetime Lifetime { get; } = lifetime; +} diff --git a/template/Template.csproj b/template/Template.csproj index 0fa2c0c5..dd5725f0 100644 --- a/template/Template.csproj +++ b/template/Template.csproj @@ -1,6 +1,6 @@  - net9.0 + net10.0 Template CarterTemplate A dotnet-new template for Carter applications. diff --git a/template/content/CarterTemplate.csproj b/template/content/CarterTemplate.csproj index 8da52ef9..abfea06c 100644 --- a/template/content/CarterTemplate.csproj +++ b/template/content/CarterTemplate.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 CarterTemplate Exe diff --git a/test/Carter.ResponseNegotiators.Newtonsoft.Tests/Carter.ResponseNegotiators.Newtonsoft.Tests.csproj b/test/Carter.ResponseNegotiators.Newtonsoft.Tests/Carter.ResponseNegotiators.Newtonsoft.Tests.csproj index 2aa2aa8c..2c1c62a8 100644 --- a/test/Carter.ResponseNegotiators.Newtonsoft.Tests/Carter.ResponseNegotiators.Newtonsoft.Tests.csproj +++ b/test/Carter.ResponseNegotiators.Newtonsoft.Tests/Carter.ResponseNegotiators.Newtonsoft.Tests.csproj @@ -2,8 +2,8 @@ - - + + diff --git a/test/Carter.ResponseNegotiators.Newtonsoft.Tests/NewtonsoftJsonResponseNegotiatorTests.cs b/test/Carter.ResponseNegotiators.Newtonsoft.Tests/NewtonsoftJsonResponseNegotiatorTests.cs index 66bf6020..a470139d 100644 --- a/test/Carter.ResponseNegotiators.Newtonsoft.Tests/NewtonsoftJsonResponseNegotiatorTests.cs +++ b/test/Carter.ResponseNegotiators.Newtonsoft.Tests/NewtonsoftJsonResponseNegotiatorTests.cs @@ -5,49 +5,55 @@ namespace Carter.ResponseNegotiators.Newtonsoft.Tests using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; - using Carter.Response; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; using Xunit; using MediaTypeHeaderValue = Microsoft.Net.Http.Headers.MediaTypeHeaderValue; public class NewtonsoftJsonResponseNegotiatorTests { - public NewtonsoftJsonResponseNegotiatorTests() + private async Task SetupServer() { - this.server = new TestServer( - new WebHostBuilder() - .ConfigureServices(x => - { - x.AddRouting(); - x.AddCarter(configurator: c => + var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() // If using TestServer + .ConfigureServices(x => { - c.WithModule(); - c.WithResponseNegotiator(); - c.WithResponseNegotiator(); + x.AddRouting(); + x.AddCarter(configurator: c => + { + c.WithModule(); + c.WithResponseNegotiator(); + c.WithResponseNegotiator(); + }); + }) + .Configure(x => + { + x.UseRouting(); + x.UseEndpoints(builder => builder.MapCarter()); }); - }) - .Configure(x => - { - x.UseRouting(); - x.UseEndpoints(builder => builder.MapCarter()); - }) - ); - this.httpClient = this.server.CreateClient(); - } + }) + .Build(); - private readonly TestServer server; + await host.StartAsync(); + + this.httpClient = host.GetTestClient(); + } - private readonly HttpClient httpClient; + private HttpClient httpClient; [Theory] [InlineData("not/known")] [InlineData("utt$r-rubbish-9")] public async Task Should_fallback_to_json(string accept) { + await this.SetupServer(); this.httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept", accept); var response = await this.httpClient.GetAsync("/negotiate"); Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString()); @@ -56,6 +62,7 @@ public async Task Should_fallback_to_json(string accept) [Fact] public async Task Should_camelCase_json() { + await this.SetupServer(); this.httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); var response = await this.httpClient.GetAsync("/negotiate"); var body = await response.Content.ReadAsStringAsync(); @@ -65,6 +72,7 @@ public async Task Should_camelCase_json() [Fact] public async Task Should_fallback_to_json_even_if_no_accept_header() { + await this.SetupServer(); var response = await this.httpClient.GetAsync("/negotiate"); Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString()); } @@ -72,6 +80,7 @@ public async Task Should_fallback_to_json_even_if_no_accept_header() [Fact] public async Task Should_pick_default_json_processor_last() { + await this.SetupServer(); this.httpClient.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue("application/vnd.badger+json")); var response = await this.httpClient.GetAsync("/negotiate"); diff --git a/test/Carter.Samples.Tests/Carter.Samples.Tests.csproj b/test/Carter.Samples.Tests/Carter.Samples.Tests.csproj index 09254a46..93c4de9c 100644 --- a/test/Carter.Samples.Tests/Carter.Samples.Tests.csproj +++ b/test/Carter.Samples.Tests/Carter.Samples.Tests.csproj @@ -10,8 +10,8 @@ - - - + + + diff --git a/test/Carter.Tests/AuthorizationTests.cs b/test/Carter.Tests/AuthorizationTests.cs index 94e2c235..ff5d7bd5 100644 --- a/test/Carter.Tests/AuthorizationTests.cs +++ b/test/Carter.Tests/AuthorizationTests.cs @@ -17,20 +17,17 @@ namespace Carter.Tests; -public class AuthorizationTests : IDisposable -{ - private readonly ITestOutputHelper outputHelper; +using Microsoft.Extensions.Hosting; +public class AuthorizationTests(ITestOutputHelper outputHelper) : IDisposable +{ private TestServer server; - public AuthorizationTests(ITestOutputHelper outputHelper) => - this.outputHelper = outputHelper; - [Fact] - public void Should_contain_endpoint_with_default_authz_metadata() + public async Task Should_contain_endpoint_with_default_authz_metadata() { // Arrange and act - BuildTestServer(); + await BuildTestServer(); // Assert var endpoint = server.Services @@ -48,10 +45,10 @@ public void Should_contain_endpoint_with_default_authz_metadata() } [Fact] - public void Should_contain_endpoint_with_specific_authz_metadata() + public async Task Should_contain_endpoint_with_specific_authz_metadata() { // Arrange and act - BuildTestServer(); + await BuildTestServer(); // Assert var endpoint = server.Services @@ -79,31 +76,36 @@ public void Should_contain_endpoint_with_specific_authz_metadata() /// Builds a test server with the module specified by the type parameter. /// /// The type of Carter module to register. - private void BuildTestServer() + private async Task BuildTestServer() where TModule : AuthorizationTestModuleBase { - this.server = new TestServer( - new WebHostBuilder() - .ConfigureServices(x => - { - x.AddLogging(b => + var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() // If using TestServer + .ConfigureServices(x => { - XUnitLoggerExtensions.AddXUnit((ILoggingBuilder)b, outputHelper, x => x.IncludeScopes = true); - b.SetMinimumLevel(LogLevel.Debug); - }); - - x.AddRouting(); - x.AddCarter(configurator: c => + x.AddLogging(b => + { + b.AddXUnit(outputHelper, y => y.IncludeScopes = true); + b.SetMinimumLevel(LogLevel.Debug); + }); + + x.AddRouting(); + x.AddCarter(configurator: c => { c.WithModule(); }); + }) + .Configure(x => { - c.WithModule(); + x.UseRouting(); + x.UseEndpoints(builder => builder.MapCarter()); }); - }) - .Configure(x => - { - x.UseRouting(); - x.UseEndpoints(builder => builder.MapCarter()); - }) - ); + }) + .Build(); + + await host.StartAsync(); + + this.server = host.GetTestServer(); } public void Dispose() diff --git a/test/Carter.Tests/Carter.Tests.csproj b/test/Carter.Tests/Carter.Tests.csproj index 6586a975..e5e82dbf 100644 --- a/test/Carter.Tests/Carter.Tests.csproj +++ b/test/Carter.Tests/Carter.Tests.csproj @@ -4,7 +4,7 @@ - + diff --git a/test/Carter.Tests/CarterExtensionTests.cs b/test/Carter.Tests/CarterExtensionTests.cs index a11d2594..77e8b223 100644 --- a/test/Carter.Tests/CarterExtensionTests.cs +++ b/test/Carter.Tests/CarterExtensionTests.cs @@ -17,7 +17,7 @@ public void Should_register_assembly_scanned_modules_when_no_configurator_used() var serviceCollection = new ServiceCollection(); //When - CarterExtensions.AddCarter(serviceCollection); + serviceCollection.AddCarter(); //Then var modules = serviceCollection.Where(x => x.ServiceType == typeof(ICarterModule)); @@ -31,7 +31,7 @@ public void Should_register_modules_passed_in_by_configurator() var serviceCollection = new ServiceCollection(); //When - CarterExtensions.AddCarter(serviceCollection, configurator: configurator => configurator.WithModule()); + serviceCollection.AddCarter(configurator: configurator => configurator.WithModule()); //Then var modules = serviceCollection.Where(x => x.ServiceType == typeof(ICarterModule)); @@ -45,7 +45,7 @@ public void Should_register_multiple_modules_passed_in_by_configurator() var serviceCollection = new ServiceCollection(); //When - CarterExtensions.AddCarter(serviceCollection, configurator: configurator => configurator.WithModules(typeof(TestModule), typeof(StreamModule))); + serviceCollection.AddCarter(configurator: configurator => configurator.WithModules(typeof(TestModule), typeof(StreamModule))); //Then var modules = serviceCollection.Where(x => x.ServiceType == typeof(ICarterModule)); @@ -59,7 +59,7 @@ public void Should_register_assembly_scanned_valdators_when_no_configurator_used var serviceCollection = new ServiceCollection(); //When - CarterExtensions.AddCarter(serviceCollection); + serviceCollection.AddCarter(); //Then var validators = serviceCollection.Where(x => x.ServiceType == typeof(IValidator)); @@ -73,7 +73,7 @@ public void Should_register_validators_passed_in_by_configurator() var serviceCollection = new ServiceCollection(); //When - CarterExtensions.AddCarter(serviceCollection, configurator: configurator => configurator.WithValidator()); + serviceCollection.AddCarter(configurator: configurator => configurator.WithValidator()); //Then var validators = serviceCollection.Where(x => x.ServiceType == typeof(IValidator)); @@ -87,7 +87,7 @@ public void Should_register_multiple_validators_passed_in_by_configurator() var serviceCollection = new ServiceCollection(); //When - CarterExtensions.AddCarter(serviceCollection, configurator: configurator => configurator.WithValidators(typeof(TestModelValidator), typeof(DuplicateTestModelOne))); + serviceCollection.AddCarter(configurator: configurator => configurator.WithValidators(typeof(TestModelValidator), typeof(DuplicateTestModelOne))); //Then var validators = serviceCollection.Where(x => x.ServiceType == typeof(IValidator)); @@ -101,7 +101,7 @@ public void Should_register_assembly_scanned_responsenegotiators_when_no_configu var serviceCollection = new ServiceCollection(); //When - CarterExtensions.AddCarter(serviceCollection); + serviceCollection.AddCarter(); //Then var responsenegotiators = serviceCollection.Where(x => x.ServiceType == typeof(IResponseNegotiator)); @@ -115,7 +115,7 @@ public void Should_register_responsenegotiators_passed_in_by_configurator_and_de var serviceCollection = new ServiceCollection(); //When - CarterExtensions.AddCarter(serviceCollection, configurator: configurator => configurator.WithResponseNegotiator()); + serviceCollection.AddCarter(configurator: configurator => configurator.WithResponseNegotiator()); //Then var responsenegotiators = serviceCollection.Where(x => x.ServiceType == typeof(IResponseNegotiator)); @@ -129,7 +129,7 @@ public void Should_register_multiple_responsenegotiators_passed_in_by_configurat var serviceCollection = new ServiceCollection(); //When - CarterExtensions.AddCarter(serviceCollection, configurator: configurator => configurator.WithResponseNegotiators(typeof(TestResponseNegotiator), typeof(TestXmlResponseNegotiator))); + serviceCollection.AddCarter(configurator: configurator => configurator.WithResponseNegotiators(typeof(TestResponseNegotiator), typeof(TestXmlResponseNegotiator))); //Then var responsenegotiators = serviceCollection.Where(x => x.ServiceType == typeof(IResponseNegotiator)); @@ -143,7 +143,7 @@ public void Should_register_no_validators_passed_in_by_configurator() var serviceCollection = new ServiceCollection(); //When - CarterExtensions.AddCarter(serviceCollection, configurator: configurator => configurator.WithEmptyValidators()); + serviceCollection.AddCarter(configurator: configurator => configurator.WithEmptyValidators()); //Then var validators = serviceCollection.Where(x => x.ServiceType == typeof(IValidator)); @@ -157,7 +157,7 @@ public void Should_register_no_modules_passed_in_by_configurator() var serviceCollection = new ServiceCollection(); //When - CarterExtensions.AddCarter(serviceCollection, configurator: configurator => configurator.WithEmptyModules()); + serviceCollection.AddCarter(configurator: configurator => configurator.WithEmptyModules()); //Then var modules = serviceCollection.Where(x => x.ServiceType == typeof(ICarterModule)); @@ -171,7 +171,7 @@ public void Should_register_no_response_negotiators_passed_in_by_configurator() var serviceCollection = new ServiceCollection(); //When - CarterExtensions.AddCarter(serviceCollection, configurator: configurator => configurator.WithEmptyResponseNegotiators()); + serviceCollection.AddCarter(configurator: configurator => configurator.WithEmptyResponseNegotiators()); //Then var responseNegotiators = serviceCollection.Where(x => x.ServiceType == typeof(IResponseNegotiator)); diff --git a/test/Carter.Tests/ContentNegotiation/HttpJsonOptionsResponseNegotiatorTests.cs b/test/Carter.Tests/ContentNegotiation/HttpJsonOptionsResponseNegotiatorTests.cs index c572193a..41f2cfde 100644 --- a/test/Carter.Tests/ContentNegotiation/HttpJsonOptionsResponseNegotiatorTests.cs +++ b/test/Carter.Tests/ContentNegotiation/HttpJsonOptionsResponseNegotiatorTests.cs @@ -7,41 +7,51 @@ namespace Carter.Tests.ContentNegotiation using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; using Xunit; public class HttpJsonOptionsResponseNegotiatorTests { - public HttpJsonOptionsResponseNegotiatorTests() + private async Task SetupServer() { - var server = new TestServer( - new WebHostBuilder() - .ConfigureServices(x => - { - x.ConfigureHttpJsonOptions(jsonOptions => + var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() // If using TestServer + .ConfigureServices(x => { - jsonOptions.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.KebabCaseUpper; + x.ConfigureHttpJsonOptions(jsonOptions => + { + jsonOptions.SerializerOptions.PropertyNamingPolicy = + JsonNamingPolicy.KebabCaseUpper; + }); + + x.AddRouting(); + x.AddCarter(configurator: c => { c.WithModule(); }); + }) + .Configure(x => + { + x.UseRouting(); + x.UseEndpoints(builder => builder.MapCarter()); }); - x.AddRouting(); - x.AddCarter(configurator: c => - c.WithModule()); - }) - .Configure(x => - { - x.UseRouting(); - x.UseEndpoints(builder => builder.MapCarter()); - }) - ); - this.httpClient = server.CreateClient(); + }) + .Build(); + + await host.StartAsync(); + + this.httpClient = host.GetTestClient(); } - private readonly HttpClient httpClient; + private HttpClient httpClient; [Fact] public async Task Should_obey_httpjsonoptions() { + await this.SetupServer(); var response = await this.httpClient.GetAsync("/negotiate"); var body = await response.Content.ReadAsStringAsync(); Assert.Equal("{\"FIRST-NAME\":\"Jim\"}", body); } } -} \ No newline at end of file +} diff --git a/test/Carter.Tests/ContentNegotiation/ResponseNegotiatorTests.cs b/test/Carter.Tests/ContentNegotiation/ResponseNegotiatorTests.cs index 2834a7e2..2a93e163 100644 --- a/test/Carter.Tests/ContentNegotiation/ResponseNegotiatorTests.cs +++ b/test/Carter.Tests/ContentNegotiation/ResponseNegotiatorTests.cs @@ -10,42 +10,51 @@ namespace Carter.Tests.ContentNegotiation using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; using Xunit; using MediaTypeHeaderValue = Microsoft.Net.Http.Headers.MediaTypeHeaderValue; public class ResponseNegotiatorTests { - public ResponseNegotiatorTests() + private async Task SetupServer() { - this.server = new TestServer( - new WebHostBuilder() - .ConfigureServices(x => - { - x.AddRouting(); - x.AddCarter(configurator: c => - c.WithModule() - .WithResponseNegotiator() - .WithResponseNegotiator() - .WithResponseNegotiator()); - }) - .Configure(x => - { - x.UseRouting(); - x.UseEndpoints(builder => builder.MapCarter()); - }) - ); - this.httpClient = this.server.CreateClient(); + var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() // If using TestServer + .ConfigureServices(x => + { + x.AddRouting(); + x.AddCarter(configurator: c => + { + c.WithModule() + .WithResponseNegotiator() + .WithResponseNegotiator() + .WithResponseNegotiator(); + }); + }) + .Configure(x => + { + x.UseRouting(); + x.UseEndpoints(builder => builder.MapCarter()); + }); + }) + .Build(); + + await host.StartAsync(); + + this.httpClient = host.GetTestClient(); } - private readonly TestServer server; - - private readonly HttpClient httpClient; + private HttpClient httpClient; [Theory] [InlineData("not/known")] [InlineData("utt$r-rubbish-9")] public async Task Should_fallback_to_json(string accept) { + await this.SetupServer(); this.httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept", accept); var response = await this.httpClient.GetAsync("/negotiate"); Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString()); @@ -54,6 +63,7 @@ public async Task Should_fallback_to_json(string accept) [Fact] public async Task Should_camelCase_json() { + await this.SetupServer(); this.httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); var response = await this.httpClient.GetAsync("/negotiate"); var body = await response.Content.ReadAsStringAsync(); @@ -63,6 +73,7 @@ public async Task Should_camelCase_json() [Fact] public async Task Should_fallback_to_json_even_if_no_accept_header() { + await this.SetupServer(); var response = await this.httpClient.GetAsync("/negotiate"); Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString()); } @@ -70,6 +81,7 @@ public async Task Should_fallback_to_json_even_if_no_accept_header() [Fact] public async Task Should_pick_correctly_weighted_processor() { + await this.SetupServer(); this.httpClient.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue("application/xml", 0.5)); this.httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html", 0.3)); @@ -81,6 +93,7 @@ public async Task Should_pick_correctly_weighted_processor() [Fact] public async Task Should_pick_non_weighted_over_weighted() { + await this.SetupServer(); this.httpClient.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue("application/xml", 0.5)); this.httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html", 0.3)); @@ -93,6 +106,7 @@ public async Task Should_pick_non_weighted_over_weighted() [Fact] public async Task Should_use_appropriate_response_negotiator() { + await this.SetupServer(); this.httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("foo/bar")); var response = await this.httpClient.GetAsync("/negotiate"); var body = await response.Content.ReadAsStringAsync(); @@ -102,6 +116,7 @@ public async Task Should_use_appropriate_response_negotiator() [Fact] public async Task Should_pick_default_json_processor_last() { + await this.SetupServer(); this.httpClient.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue("application/vnd.badger+json")); var response = await this.httpClient.GetAsync("/negotiate"); diff --git a/test/Carter.Tests/ExtensionTests.cs b/test/Carter.Tests/ExtensionTests.cs index 463b91d7..fb03a35b 100644 --- a/test/Carter.Tests/ExtensionTests.cs +++ b/test/Carter.Tests/ExtensionTests.cs @@ -9,43 +9,46 @@ namespace Carter.Tests; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Xunit; using Xunit.Abstractions; -public class ExtensionTests +public class ExtensionTests(ITestOutputHelper outputHelper) { - private readonly TestServer server; + private HttpClient httpClient; - private readonly HttpClient httpClient; - - public ExtensionTests(ITestOutputHelper outputHelper) + private async Task SetupServer() { - this.server = new TestServer( - new WebHostBuilder() - .ConfigureServices(x => - { - x.AddLogging(b => + var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() // If using TestServer + .ConfigureServices(x => { - XUnitLoggerExtensions.AddXUnit((ILoggingBuilder)b, outputHelper, x => x.IncludeScopes = true); - b.SetMinimumLevel(LogLevel.Debug); + x.AddLogging(b => + { + b.AddXUnit(outputHelper, y => y.IncludeScopes = true); + b.SetMinimumLevel(LogLevel.Debug); + }); + + x.AddSingleton(); + x.AddRouting(); + x.AddCarter(configurator: c => { c.WithModule(); }); + }) + .Configure(x => + { + x.UseRouting(); + x.UseEndpoints(builder => builder.MapCarter()); }); + }) + .Build(); - x.AddSingleton(); + await host.StartAsync(); - x.AddRouting(); - x.AddCarter(configurator: c => - c.WithModule() - ); - }) - .Configure(x => - { - x.UseRouting(); - x.UseEndpoints(builder => builder.MapCarter()); - }) - ); - this.httpClient = this.server.CreateClient(); + this.httpClient = host.GetTestClient(); } //make a request to /multiquerystring @@ -56,6 +59,7 @@ public ExtensionTests(ITestOutputHelper outputHelper) [InlineData("/multiquerystring?id=1,2")] public async Task Should_return_GET_requests_with_multiple_parsed_querystring(string url) { + await this.SetupServer(); var response = await this.httpClient.GetAsync(url); var body = await response.Content.ReadAsStringAsync(); @@ -70,6 +74,7 @@ public async Task Should_return_GET_requests_with_multiple_parsed_querystring(st public async Task Should_return_GET_requests_with_multiple_parsed_querystring_with_nullable_parameters( string url) { + await this.SetupServer(); var response = await this.httpClient.GetAsync(url); var body = await response.Content.ReadAsStringAsync(); @@ -81,6 +86,7 @@ public async Task Should_return_GET_requests_with_multiple_parsed_querystring_wi [Fact] public async Task Should_return_GET_requests_with_parsed_querystring() { + await this.SetupServer(); const int idToTest = 69; var response = await this.httpClient.GetAsync($"/querystring?id={idToTest}"); @@ -93,6 +99,7 @@ public async Task Should_return_GET_requests_with_parsed_querystring() [Fact] public async Task Should_return_GET_requests_with_parsed_querystring_with_nullable_parameter() { + await this.SetupServer(); const int idToTest = 69; var response = await this.httpClient.GetAsync($"/nullablequerystring?id={idToTest}"); @@ -105,6 +112,7 @@ public async Task Should_return_GET_requests_with_parsed_querystring_with_nullab [Fact] public async Task Should_return_POST_request_body_AsStringAsync() { + await this.SetupServer(); const string content = "Hello"; var response = await this.httpClient.PostAsync("/asstringasync", new StringContent(content)); @@ -118,6 +126,7 @@ public async Task Should_return_POST_request_body_AsStringAsync() [Fact] public async Task Should_create_file_with_custom_filename() { + await this.SetupServer(); var multipartFormData = new MultipartFormDataContent(); using (var ms = new MemoryStream()) @@ -153,6 +162,7 @@ public async Task Should_create_file_with_custom_filename() [Fact] public async Task Should_create_file_with_default_filename() { + await this.SetupServer(); var multipartFormData = new MultipartFormDataContent(); using (var ms = new MemoryStream()) @@ -188,6 +198,7 @@ public async Task Should_create_file_with_default_filename() [Fact] public async Task Should_return_OK_and_path_for_bindsavefile() { + await this.SetupServer(); var multipartFormData = new MultipartFormDataContent(); using (var ms = new MemoryStream()) diff --git a/test/Carter.Tests/RouteExtensionsTests.cs b/test/Carter.Tests/RouteExtensionsTests.cs index c9c783b6..6a213942 100644 --- a/test/Carter.Tests/RouteExtensionsTests.cs +++ b/test/Carter.Tests/RouteExtensionsTests.cs @@ -1,5 +1,6 @@ namespace Carter.Tests; +using System.Collections.Generic; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; @@ -9,45 +10,49 @@ namespace Carter.Tests; using System.Text; using System.Threading.Tasks; using Carter.Tests.ModelBinding; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Xunit; using Xunit.Abstractions; -public class RouteExtensionsTests +public class RouteExtensionsTests(ITestOutputHelper outputHelper) { - private readonly TestServer server; + private HttpClient httpClient; - private readonly HttpClient httpClient; - - public RouteExtensionsTests(ITestOutputHelper outputHelper) + private async Task SetupServer() { - this.server = new TestServer( - new WebHostBuilder() - .ConfigureServices(x => - { - x.AddLogging(b => + var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() + .ConfigureServices(x => + { + x.AddLogging(b => + { + b.AddXUnit(outputHelper, y => y.IncludeScopes = true); + b.SetMinimumLevel(LogLevel.Debug); + }); + x.AddSingleton(); + x.AddRouting(); + x.AddCarter(configurator: c => + { + c.WithModule(); + c.WithValidator(); + }); + }) + .Configure(x => { - XUnitLoggerExtensions.AddXUnit((ILoggingBuilder)b, outputHelper, x => x.IncludeScopes = true); - b.SetMinimumLevel(LogLevel.Debug); + x.UseRouting(); + x.UseEndpoints(builder => builder.MapCarter()); }); + }) + .Build(); - x.AddSingleton(); + await host.StartAsync(); - x.AddRouting(); - x.AddCarter(configurator: c => - { - c.WithModule(); - c.WithValidator(); - }); - }) - .Configure(x => - { - x.UseRouting(); - x.UseEndpoints(builder => builder.MapCarter()); - }) - ); - this.httpClient = this.server.CreateClient(); + this.httpClient = host.GetTestClient(); } [Theory] @@ -55,6 +60,7 @@ public RouteExtensionsTests(ITestOutputHelper outputHelper) [InlineData("PUT")] public async Task Should_return_422_on_validation_failure(string httpMethod) { + await this.SetupServer(); var res = await this.httpClient.SendAsync(new HttpRequestMessage(new HttpMethod(httpMethod), "/endpointfilter") { Content = new StringContent(JsonConvert.SerializeObject(new TestModel()), Encoding.UTF8, "application/json") @@ -67,6 +73,7 @@ public async Task Should_return_422_on_validation_failure(string httpMethod) [InlineData("PUT")] public async Task Should_pick_type_to_validate_no_matter_of_delegate_position(string httpMethod) { + await this.SetupServer(); var res = await this.httpClient.SendAsync(new HttpRequestMessage(new HttpMethod(httpMethod), "/endpointfilter") { Content = new StringContent(JsonConvert.SerializeObject(new TestModel()), Encoding.UTF8, "application/json") @@ -79,62 +86,84 @@ public async Task Should_pick_type_to_validate_no_matter_of_delegate_position(st [InlineData("PUT")] public async Task Should_hit_route_if_validation_successful(string httpMethod) { + await this.SetupServer(); var res = await this.httpClient.SendAsync(new HttpRequestMessage(new HttpMethod(httpMethod), "/endpointfilter") { - Content = new StringContent(JsonConvert.SerializeObject(new TestModel { MyStringProperty = "hi", MyIntProperty = 123 }), Encoding.UTF8, "application/json") + Content = new StringContent( + JsonConvert.SerializeObject(new TestModel { MyStringProperty = "hi", MyIntProperty = 123 }), + Encoding.UTF8, "application/json") }); var body = await res.Content.ReadAsStringAsync(); Assert.Equal(httpMethod, body); } + + [Fact] + public async Task Should_bind_form_posts() + { + await this.SetupServer(); + var formData = new Dictionary + { + { "MyStringProperty", "hi" }, + { "MyIntProperty", "123" } + }; + + var content = new FormUrlEncodedContent(formData); + var res = await this.httpClient.PostAsync("/formpost", content); + + var body = await res.Content.ReadAsStringAsync(); + + Assert.Contains("{\"myIntProperty\":123,\"myStringProperty\":\"hi\"", body); + } } -public class NestedRouteExtensionsTests -{ - private readonly TestServer server; - private readonly HttpClient httpClient; +public class NestedRouteExtensionsTests(ITestOutputHelper outputHelper) +{ + private HttpClient httpClient; - public NestedRouteExtensionsTests(ITestOutputHelper outputHelper) + private async Task SetupServer() { - this.server = new TestServer( - new WebHostBuilder() - .ConfigureServices(x => - { - x.AddLogging(b => + var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() + .ConfigureServices(x => + { + x.AddLogging(b => + { + b.AddXUnit(outputHelper, y => y.IncludeScopes = true); + b.SetMinimumLevel(LogLevel.Debug); + }); + x.AddSingleton(); + x.AddRouting(); + x.AddCarter(configurator: c => { c.WithValidator(); }); + }) + .Configure(x => { - XUnitLoggerExtensions.AddXUnit((ILoggingBuilder)b, outputHelper, x => x.IncludeScopes = true); - b.SetMinimumLevel(LogLevel.Debug); + x.UseRouting(); + x.UseEndpoints(builder => builder.MapCarter()); }); + }) + .Build(); - x.AddSingleton(); - - x.AddRouting(); - x.AddCarter(configurator: c => { - c.WithValidator(); - } - ); - }) - .Configure(x => - { - x.UseRouting(); - x.UseEndpoints(builder => builder.MapCarter()); - }) - ); - this.httpClient = this.server.CreateClient(); + await host.StartAsync(); + + this.httpClient = host.GetTestClient(); } [Theory] [InlineData("GET")] public async Task Should_have_nested_class_registered(string httpMethod) { + await this.SetupServer(); var res = await this.httpClient.SendAsync(new HttpRequestMessage(new HttpMethod(httpMethod), "/nested") { Content = new StringContent(JsonConvert.SerializeObject(new TestModel()), Encoding.UTF8, "application/json") }); Assert.Equal(HttpStatusCode.OK, res.StatusCode); } - } internal interface IDependency diff --git a/test/Carter.Tests/StreamTests/ResponseFromStreamTests.cs b/test/Carter.Tests/StreamTests/ResponseFromStreamTests.cs index 5d881577..903c63b3 100644 --- a/test/Carter.Tests/StreamTests/ResponseFromStreamTests.cs +++ b/test/Carter.Tests/StreamTests/ResponseFromStreamTests.cs @@ -9,30 +9,37 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; using Xunit; public class ResponseFromStreamTests { - public ResponseFromStreamTests() + private async Task SetupServer() { - this.server = new TestServer( - new WebHostBuilder() - .ConfigureServices(x => - { - x.AddRouting(); - x.AddCarter(configurator: c => c.WithModule()); - }) - .Configure(x => - { - x.UseRouting(); - x.UseEndpoints(builder => builder.MapCarter()); - })); - this.httpClient = this.server.CreateClient(); + var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() // If using TestServer + .ConfigureServices(x => + { + x.AddRouting(); + x.AddCarter(configurator: c => { c.WithModule(); }); + }) + .Configure(x => + { + x.UseRouting(); + x.UseEndpoints(builder => builder.MapCarter()); + }); + }) + .Build(); + + await host.StartAsync(); + + this.httpClient = host.GetTestClient(); } - private readonly TestServer server; - - private readonly HttpClient httpClient; + private HttpClient httpClient; [Theory] [InlineData("0-2", "0-2", "012")] @@ -42,6 +49,7 @@ public ResponseFromStreamTests() public async Task Should_return_range(string range, string expectedRange, string expectedBody) { //Given & When + await this.SetupServer(); this.httpClient.DefaultRequestHeaders.Range = RangeHeaderValue.Parse($"bytes={range}"); var response = await this.httpClient.GetAsync("/downloadrange"); @@ -59,6 +67,7 @@ public async Task Should_return_range(string range, string expectedRange, string public async Task Should_return_requested_range_not_satisfiable(string range) { //Given & When + await this.SetupServer(); this.httpClient.DefaultRequestHeaders.Range = RangeHeaderValue.Parse($"bytes={range}"); var response = await this.httpClient.GetAsync("/downloadrange"); @@ -72,6 +81,7 @@ public async Task Should_return_requested_range_not_satisfiable(string range) public async Task Should_return_full_stream_on_invalid_headers(string range) { //Given + await this.SetupServer(); this.httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Range", $"bytes={range}"); //When @@ -88,6 +98,7 @@ public async Task Should_return_full_stream_on_invalid_headers(string range) public async Task Should_not_set_content_disposition_header_by_default() { //Given & When + await this.SetupServer(); var response = await this.httpClient.GetAsync("/download"); var body = await response.Content.ReadAsStringAsync(); @@ -102,6 +113,7 @@ public async Task Should_not_set_content_disposition_header_by_default() public async Task Should_set_content_type_body_acceptrange_header_content_disposition() { //Given & When + await this.SetupServer(); var response = await this.httpClient.GetAsync("/downloadwithcd"); var body = await response.Content.ReadAsStringAsync(); var filename = response.Content.Headers.ContentDisposition.FileName; diff --git a/test/Carter.Tests/TestModule.cs b/test/Carter.Tests/TestModule.cs index 1f998ce5..99cb9e35 100644 --- a/test/Carter.Tests/TestModule.cs +++ b/test/Carter.Tests/TestModule.cs @@ -104,6 +104,7 @@ public void AddRoutes(IEndpointRouteBuilder app) app.MapPost("/endpointfilter", (TestModel testModel,IDependency dependency) => "POST"); app.MapPut("/endpointfilter", (IDependency dependency, TestModel testModel) => "PUT"); + app.MapFormPost("/formpost", (TestModel model) => TypedResults.Ok(model)).DisableAntiforgery(); } } public static class NestedTestModule diff --git a/test/Directory.Build.props b/test/Directory.Build.props index 3f29c41d..7cf68b97 100644 --- a/test/Directory.Build.props +++ b/test/Directory.Build.props @@ -1,6 +1,6 @@ - net9.0 + net10.0 true latest @@ -8,9 +8,9 @@ - - - - + + + +