diff --git a/.build/.build.csproj b/.build/.build.csproj
index 2806a6fde..22e38ec00 100644
--- a/.build/.build.csproj
+++ b/.build/.build.csproj
@@ -4,10 +4,11 @@
Exe
net6.0
false
-
+
False
CS0649;CS0169
1
+ true
diff --git a/.build/Build.CI.cs b/.build/Build.CI.cs
index e2e21f5b5..e7e3857b6 100644
--- a/.build/Build.CI.cs
+++ b/.build/Build.CI.cs
@@ -70,7 +70,7 @@ internal class LocalConstants
[PrintCIEnvironment]
[UploadLogs]
[TitleEvents]
-public partial class Solution
+public partial class BuildSolution
{
public static RocketSurgeonGitHubActionsConfiguration CiIgnoreMiddleware(
RocketSurgeonGitHubActionsConfiguration configuration
diff --git a/.build/Build.cs b/.build/Build.cs
index bfa0405f6..cdcd5451c 100644
--- a/.build/Build.cs
+++ b/.build/Build.cs
@@ -1,11 +1,19 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.Sockets;
+using System.Reflection;
using Nuke.Common;
using Nuke.Common.CI;
using Nuke.Common.Execution;
using Nuke.Common.Git;
+using Nuke.Common.ProjectModel;
+using Nuke.Common.Tooling;
using Nuke.Common.Tools.DotNet;
using Nuke.Common.Tools.GitVersion;
using Nuke.Common.Tools.MSBuild;
using Rocket.Surgery.Nuke.DotNetCore;
+using Serilog;
+using NukeSolution = Nuke.Common.ProjectModel.Solution;
[PublicAPI]
[CheckBuildProjectConfigurations]
@@ -17,18 +25,19 @@
[MSBuildVerbosityMapping]
[NuGetVerbosityMapping]
[ShutdownDotNetAfterServerBuild]
-public partial class Solution : NukeBuild,
- ICanRestoreWithDotNetCore,
- ICanBuildWithDotNetCore,
- ICanTestWithDotNetCore,
- IHaveNuGetPackages,
- IHaveDataCollector,
- ICanClean,
- ICanUpdateReadme,
- IGenerateCodeCoverageReport,
- IGenerateCodeCoverageSummary,
- IGenerateCodeCoverageBadges,
- IHaveConfiguration
+public partial class BuildSolution : NukeBuild,
+ ICanRestoreWithDotNetCore,
+ ICanBuildWithDotNetCore,
+ ICanTestWithDotNetCore,
+ IComprehendSamples,
+ IHaveNuGetPackages,
+ IHaveDataCollector,
+ ICanClean,
+ ICanUpdateReadme,
+ IGenerateCodeCoverageReport,
+ IGenerateCodeCoverageSummary,
+ IGenerateCodeCoverageBadges,
+ IHaveConfiguration
{
///
/// Support plugins are available for:
@@ -39,7 +48,26 @@ public partial class Solution : NukeBuild,
///
public static int Main()
{
- return Execute(x => x.Default);
+ return Execute(x => x.Default);
+ }
+
+ public static int FindFreePort()
+ {
+ var port = 0;
+ var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ try
+ {
+ var localEP = new IPEndPoint(IPAddress.Any, 0);
+ socket.Bind(localEP);
+ localEP = (IPEndPoint)socket.LocalEndPoint;
+ port = localEP.Port;
+ }
+ finally
+ {
+ socket.Close();
+ }
+
+ return port;
}
[OptionalGitRepository] public GitRepository? GitRepository { get; }
@@ -74,6 +102,78 @@ public static int Main()
.Before(Default)
.Before(Clean);
+ public Target UpdateGraphQl => _ => _.DependentFor(Test).After(Build).Executes(
+ async () =>
+ {
+ var port = FindFreePort();
+ var tcs = new TaskCompletionSource();
+ var cts = new CancellationTokenSource();
+ cts.CancelAfter(TimeSpan.FromMinutes(1));
+ cts.Token.Register(() => tcs.TrySetCanceled());
+ var url = $"http://localhost:{port}";
+ var process1 = ProcessTasks.StartProcess(
+ "dotnet",
+ "run --no-launch-profile",
+ logOutput: true,
+ logInvocation: true,
+ timeout: Convert.ToInt32(TimeSpan.FromMinutes(1).TotalSeconds),
+ customLogger: (type, s) =>
+ {
+ if (s.Contains("Application started."))
+ {
+ tcs.TrySetResult();
+ }
+
+ if (type == OutputType.Std)
+ {
+ Log.Logger.Debug(s);
+ }
+ else
+ {
+ Log.Logger.Error(s);
+ }
+ },
+ environmentVariables: new Dictionary(EnvironmentInfo.Variables)
+ {
+ ["ASPNETCORE_URLS"] = url,
+ ["ASPNETCORE_ENVIRONMENT"] = "Development",
+ },
+ workingDirectory: this.As().SampleDirectory / "Sample.Graphql"
+ );
+
+ var process = (Process)typeof(Process2).GetField("_process", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(process1)!;
+
+ try
+ {
+ await tcs.Task;
+ DotNetTasks.DotNet(
+ $"graphql update -u {url}/graphql/",
+ this.As().TestsDirectory / "Sample.Graphql.Tests"
+ );
+ }
+ finally
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ process1.Kill();
+ }
+ else
+ {
+ ProcessTasks.StartProcess("kill", $"-s TERM {process!.Id}");
+ }
+
+ process1.WaitForExit();
+ }
+
+ if (!IsLocalBuild)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(5));
+ }
+ }
+ );
+
+ [Solution(GenerateProjects = true)] private NukeSolution Solution { get; } = null!;
+
private Target Default => _ => _
.DependsOn(Restore)
.DependsOn(Build)
@@ -81,6 +181,7 @@ public static int Main()
.DependsOn(Pack);
public Target Build => _ => _.Inherit(x => x.CoreBuild);
+ NukeSolution IHaveSolution.Solution => Solution;
[ComputedGitVersion] public GitVersion GitVersion { get; } = null!;
@@ -90,3 +191,11 @@ public static int Main()
[Parameter("Configuration to build")] public Configuration Configuration { get; } = IsLocalBuild ? Configuration.Debug : Configuration.Release;
}
+
+public static class Extensions
+{
+ public static T As(this T value)
+ {
+ return value;
+ }
+}
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 65c321684..431cc839f 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -33,6 +33,10 @@
"nukeeper": {
"version": "0.35.0",
"commands": ["nukeeper"]
+ },
+ "strawberryshake.tools": {
+ "version": "12.6.0",
+ "commands": ["dotnet-graphql"]
}
}
}
diff --git a/.editorconfig b/.editorconfig
index 36d7ac44f..39317fa90 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -259,6 +259,7 @@ dotnet_naming_style.begins_with_t.capitalization = pascal_case
dotnet_diagnostic.ide0058.severity = none
dotnet_diagnostic.cs0436.severity = none
+dotnet_diagnostic.RCS1008.severity = none
# CodeQuality
# CA1000: Do not declare static members on generic types
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 72494f875..7b1b3e397 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -110,6 +110,9 @@ jobs:
- name: ⚙ Build
run: |
dotnet nuke Build --skip
+ - name: Update Graph Ql
+ run: |
+ dotnet nuke UpdateGraphQl --skip
- name: 🚦 Test
run: |
dotnet nuke Test TriggerCodeCoverageReports GenerateCodeCoverageReportCobertura GenerateCodeCoverageBadges GenerateCodeCoverageSummary GenerateCodeCoverageReport --skip
diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json
index c79abd131..6d511bc81 100644
--- a/.nuke/build.schema.json
+++ b/.nuke/build.schema.json
@@ -100,7 +100,8 @@
"Restore",
"Test",
"Trigger_Code_Coverage_Reports",
- "TriggerCodeCoverageReports"
+ "TriggerCodeCoverageReports",
+ "UpdateGraphQl"
]
}
},
@@ -135,7 +136,8 @@
"Restore",
"Test",
"Trigger_Code_Coverage_Reports",
- "TriggerCodeCoverageReports"
+ "TriggerCodeCoverageReports",
+ "UpdateGraphQl"
]
}
},
diff --git a/.nuke/parameters.json b/.nuke/parameters.json
index aff6906b6..02baa2f67 100644
--- a/.nuke/parameters.json
+++ b/.nuke/parameters.json
@@ -1,4 +1,4 @@
{
- "$schema": "./build.schema.json",
- "Solution": "LaunchPad.sln"
-}
\ No newline at end of file
+ "$schema": "./build.schema.json",
+ "Solution": "LaunchPad.sln"
+}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 34ccb8309..d0ee8845d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -44,7 +44,11 @@
+
+
+
+
@@ -76,6 +80,7 @@
+
@@ -133,6 +138,9 @@
+
+
+
WARNING
ShowAndRun
True
- <?xml version="1.0" encoding="utf-16"?><Profile name="Full Cleanup"><CSReorderTypeMembers>True</CSReorderTypeMembers><CSCodeStyleAttributes ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeArgumentsStyle="True" ArrangeCodeBodyStyle="True" ArrangeVarStyle="True" /><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings><EmbraceInRegion>False</EmbraceInRegion><RegionName></RegionName></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><XAMLCollapseEmptyTags>False</XAMLCollapseEmptyTags><IDEA_SETTINGS><profile version="1.0">
+ <?xml version="1.0" encoding="utf-16"?><Profile name="Full Cleanup"><CSReorderTypeMembers>True</CSReorderTypeMembers><CSCodeStyleAttributes ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeArgumentsStyle="True" ArrangeCodeBodyStyle="True" ArrangeVarStyle="True" ArrangeNamespaces="True" /><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CSOptimizeUsings></CSOptimizeUsings><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><XAMLCollapseEmptyTags>False</XAMLCollapseEmptyTags><IDEA_SETTINGS><profile version="1.0">
<option name="myName" value="Full Cleanup" />
<inspection_tool class="ES6ShorthandObjectProperty" enabled="false" level="INFORMATION" enabled_by_default="false" />
<inspection_tool class="JSArrowFunctionBracesCanBeRemoved" enabled="false" level="INFORMATION" enabled_by_default="false" />
@@ -17,9 +17,77 @@
<inspection_tool class="UnnecessaryLabelOnBreakStatementJS" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="UnnecessaryLabelOnContinueStatementJS" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="UnnecessaryReturnJS" enabled="false" level="WARNING" enabled_by_default="false" />
- <inspection_tool class="UnterminatedStatementJS" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="WrongPropertyKeyValueDelimiter" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
-</profile></IDEA_SETTINGS></Profile>
+</profile></IDEA_SETTINGS><RIDER_SETTINGS><profile>
+ <Language id="CSS">
+ <Reformat>true</Reformat>
+ <Rearrange>true</Rearrange>
+ </Language>
+ <Language id="EditorConfig">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="GraphQL">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="GraphQL Endpoint">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="HTML">
+ <OptimizeImports>true</OptimizeImports>
+ <Reformat>true</Reformat>
+ <Rearrange>true</Rearrange>
+ </Language>
+ <Language id="HTTP Request">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="Handlebars">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="Ini">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="JAVA">
+ <OptimizeImports>true</OptimizeImports>
+ <Rearrange>true</Rearrange>
+ </Language>
+ <Language id="JSON">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="Jade">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="JavaScript">
+ <OptimizeImports>true</OptimizeImports>
+ <Reformat>true</Reformat>
+ <Rearrange>true</Rearrange>
+ </Language>
+ <Language id="Markdown">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="PowerShell">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="Properties">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="RELAX-NG">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="SQL">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="TOML">
+ <Reformat>true</Reformat>
+ </Language>
+ <Language id="XML">
+ <OptimizeImports>true</OptimizeImports>
+ <Reformat>true</Reformat>
+ <Rearrange>true</Rearrange>
+ </Language>
+ <Language id="yaml">
+ <Reformat>true</Reformat>
+ </Language>
+</profile></RIDER_SETTINGS><CSShortenReferences>True</CSShortenReferences><RemoveCodeRedundancies>True</RemoveCodeRedundancies></Profile>
Full Cleanup
<?xml version="1.0" encoding="utf-16"?>
<Patterns xmlns="urn:schemas-jetbrains-com:member-reordering-patterns">
diff --git a/sample/Directory.Packages.props b/sample/Directory.Packages.props
index 3bd752a4b..bd082a0e8 100644
--- a/sample/Directory.Packages.props
+++ b/sample/Directory.Packages.props
@@ -1,7 +1,11 @@
-
+
-
\ No newline at end of file
+
+
+ false
+
+
diff --git a/sample/Sample.BlazorServer/Pages/Rockets/Delete.razor.cs b/sample/Sample.BlazorServer/Pages/Rockets/Delete.razor.cs
index 43fe15445..a80964b31 100644
--- a/sample/Sample.BlazorServer/Pages/Rockets/Delete.razor.cs
+++ b/sample/Sample.BlazorServer/Pages/Rockets/Delete.razor.cs
@@ -7,7 +7,7 @@ namespace Sample.BlazorServer.Pages.Rockets;
public partial class Delete : ComponentBase
{
- [Parameter] public Guid Id { get; set; }
+ [Parameter] public RocketId Id { get; set; }
public RocketModel Model { get; set; } = null!;
diff --git a/sample/Sample.BlazorServer/Pages/Rockets/Edit.razor.cs b/sample/Sample.BlazorServer/Pages/Rockets/Edit.razor.cs
index 1458ddf06..f1d5d988c 100644
--- a/sample/Sample.BlazorServer/Pages/Rockets/Edit.razor.cs
+++ b/sample/Sample.BlazorServer/Pages/Rockets/Edit.razor.cs
@@ -1,13 +1,14 @@
using AutoMapper;
using MediatR;
using Microsoft.AspNetCore.Components;
+using Sample.Core.Models;
using Sample.Core.Operations.Rockets;
namespace Sample.BlazorServer.Pages.Rockets;
public partial class Edit : ComponentBase
{
- [Parameter] public Guid Id { get; set; }
+ [Parameter] public RocketId Id { get; set; }
public EditRocket.Request Model { get; set; } = new();
diff --git a/sample/Sample.BlazorServer/Pages/Rockets/Index.razor.cs b/sample/Sample.BlazorServer/Pages/Rockets/Index.razor.cs
index d250ab45d..aa96810e3 100644
--- a/sample/Sample.BlazorServer/Pages/Rockets/Index.razor.cs
+++ b/sample/Sample.BlazorServer/Pages/Rockets/Index.razor.cs
@@ -13,6 +13,6 @@ public partial class Index : ComponentBase
protected override async Task OnInitializedAsync()
{
- Rockets = await Mediator.Send(new ListRockets.Request());
+ Rockets = await Mediator.CreateStream(new ListRockets.Request(null)).ToListAsync();
}
}
diff --git a/sample/Sample.BlazorServer/Pages/Rockets/View.razor.cs b/sample/Sample.BlazorServer/Pages/Rockets/View.razor.cs
index 45a9abba7..8414577df 100644
--- a/sample/Sample.BlazorServer/Pages/Rockets/View.razor.cs
+++ b/sample/Sample.BlazorServer/Pages/Rockets/View.razor.cs
@@ -7,7 +7,7 @@ namespace Sample.BlazorServer.Pages.Rockets;
public partial class View : ComponentBase
{
- [Parameter] public Guid Id { get; set; }
+ [Parameter] public RocketId Id { get; set; }
public RocketModel Model { get; set; } = null!;
[Inject] private IMediator Mediator { get; set; } = null!;
diff --git a/sample/Sample.Core/DataConvention.cs b/sample/Sample.Core/DataConvention.cs
index c6ce60552..4807b6d5d 100644
--- a/sample/Sample.Core/DataConvention.cs
+++ b/sample/Sample.Core/DataConvention.cs
@@ -1,5 +1,6 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Rocket.Surgery.Conventions;
@@ -29,6 +30,7 @@ public void Register(IConventionContext context, IConfiguration configuration, I
.AddPooledDbContextFactory(
#endif
x => x
+ .ReplaceService()
.EnableDetailedErrors()
.EnableSensitiveDataLogging()
.EnableServiceProviderCaching().UseSqlite(
diff --git a/sample/Sample.Core/Domain/LaunchRecord.cs b/sample/Sample.Core/Domain/LaunchRecord.cs
index 8b9633b95..5733223d3 100644
--- a/sample/Sample.Core/Domain/LaunchRecord.cs
+++ b/sample/Sample.Core/Domain/LaunchRecord.cs
@@ -1,18 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Rocket.Surgery.LaunchPad.EntityFramework;
+using Sample.Core.Models;
namespace Sample.Core.Domain;
-public class LaunchRecord
+public class LaunchRecord // : ILaunchRecord
{
- public Guid Id { get; set; }
+ public LaunchRecordId Id { get; set; }
public string? Partner { get; set; } = null!;
public string? Payload { get; set; } = null!;
public long PayloadWeightKg { get; set; }
public DateTimeOffset? ActualLaunchDate { get; set; }
public DateTimeOffset ScheduledLaunchDate { get; set; }
- public Guid RocketId { get; set; }
+ public RocketId RocketId { get; set; }
public ReadyRocket Rocket { get; set; } = null!;
private class EntityConfiguration : IEntityTypeConfiguration
@@ -20,6 +22,19 @@ private class EntityConfiguration : IEntityTypeConfiguration
public void Configure(EntityTypeBuilder builder)
{
builder.HasKey(x => x.Id);
+ builder.Property(z => z.Id)
+ .ValueGeneratedOnAdd()
+ .HasValueGenerator(StronglyTypedIdValueGenerator.Create(LaunchRecordId.New));
}
}
}
+
+public interface ILaunchRecord
+{
+ LaunchRecordId Id { get; set; }
+ string? Partner { get; set; }
+ string? Payload { get; set; }
+ long PayloadWeightKg { get; set; }
+ DateTimeOffset? ActualLaunchDate { get; set; }
+ DateTimeOffset ScheduledLaunchDate { get; set; }
+}
diff --git a/sample/Sample.Core/Domain/ReadyRocket.cs b/sample/Sample.Core/Domain/ReadyRocket.cs
index 1f487884b..19baeadc3 100644
--- a/sample/Sample.Core/Domain/ReadyRocket.cs
+++ b/sample/Sample.Core/Domain/ReadyRocket.cs
@@ -1,14 +1,16 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Rocket.Surgery.LaunchPad.EntityFramework;
+using Sample.Core.Models;
namespace Sample.Core.Domain;
///
/// A rocket in inventory
///
-public class ReadyRocket
+public class ReadyRocket // : IReadyRocket
{
- public Guid Id { get; set; }
+ public RocketId Id { get; set; }
public string SerialNumber { get; set; } = null!;
public RocketType Type { get; set; }
@@ -19,7 +21,17 @@ private class EntityConfiguration : IEntityTypeConfiguration
public void Configure(EntityTypeBuilder builder)
{
builder.HasKey(x => x.Id);
+ builder.Property(z => z.Id)
+ .ValueGeneratedOnAdd()
+ .HasValueGenerator(StronglyTypedIdValueGenerator.Create(RocketId.New));
builder.ToTable("Rockets");
}
}
}
+
+public interface IReadyRocket
+{
+ RocketId Id { get; set; }
+ string SerialNumber { get; set; }
+ RocketType Type { get; set; }
+}
diff --git a/sample/Sample.Core/Domain/RocketDbContext.cs b/sample/Sample.Core/Domain/RocketDbContext.cs
index ec9ddcb26..c58eb5faf 100644
--- a/sample/Sample.Core/Domain/RocketDbContext.cs
+++ b/sample/Sample.Core/Domain/RocketDbContext.cs
@@ -1,4 +1,6 @@
-using Microsoft.EntityFrameworkCore;
+using System.Collections.Concurrent;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Rocket.Surgery.LaunchPad.EntityFramework;
namespace Sample.Core.Domain;
@@ -12,3 +14,60 @@ public class RocketDbContext : LpContext
public DbSet Rockets { get; set; } = null!;
public DbSet LaunchRecords { get; set; } = null!;
}
+
+public class StronglyTypedIdValueConverterSelector : ValueConverterSelector
+{
+ private static Type UnwrapNullableType(Type type)
+ {
+ if (type is null)
+ {
+ return null;
+ }
+
+ return Nullable.GetUnderlyingType(type) ?? type;
+ }
+
+ // The dictionary in the base type is private, so we need our own one here.
+ private readonly ConcurrentDictionary<(Type ModelClrType, Type ProviderClrType), ValueConverterInfo> _converters
+ = new ConcurrentDictionary<(Type ModelClrType, Type ProviderClrType), ValueConverterInfo>();
+
+ public StronglyTypedIdValueConverterSelector(ValueConverterSelectorDependencies dependencies) : base(dependencies)
+ {
+ }
+
+ public override IEnumerable Select(Type modelClrType, Type providerClrType = null)
+ {
+ var baseConverters = base.Select(modelClrType, providerClrType);
+ foreach (var converter in baseConverters)
+ {
+ yield return converter;
+ }
+
+ // Extract the "real" type T from Nullable if required
+ var underlyingModelType = UnwrapNullableType(modelClrType);
+ var underlyingProviderType = UnwrapNullableType(providerClrType);
+
+ // 'null' means 'get any value converters for the modelClrType'
+ if (underlyingProviderType is null || underlyingProviderType == typeof(Guid))
+ {
+ // Try and get a nested class with the expected name.
+ var converterType = underlyingModelType.GetNestedType("EfCoreValueConverter");
+
+ if (converterType != null)
+ {
+ yield return _converters.GetOrAdd(
+ ( underlyingModelType, typeof(Guid) ),
+ k =>
+ {
+ // Create an instance of the converter whenever it's requested.
+ Func factory =
+ info => (ValueConverter)Activator.CreateInstance(converterType, info.MappingHints);
+
+ // Build the info for our strongly-typed ID => Guid converter
+ return new ValueConverterInfo(modelClrType, typeof(Guid), factory);
+ }
+ );
+ }
+ }
+ }
+}
diff --git a/sample/Sample.Core/Domain/RocketType.cs b/sample/Sample.Core/Domain/RocketType.cs
index a04637296..629d8a937 100644
--- a/sample/Sample.Core/Domain/RocketType.cs
+++ b/sample/Sample.Core/Domain/RocketType.cs
@@ -1,8 +1,22 @@
namespace Sample.Core.Domain;
+///
+/// The available rocket types
+///
public enum RocketType
{
+ ///
+ /// Your best bet
+ ///
Falcon9,
+
+ ///
+ /// For those huge payloads
+ ///
FalconHeavy,
+
+ ///
+ /// We stole our competitors rocket platform!
+ ///
AtlasV
}
diff --git a/sample/Sample.Core/LaunchRecordFaker.cs b/sample/Sample.Core/LaunchRecordFaker.cs
index 67888c7a2..82263c0a5 100644
--- a/sample/Sample.Core/LaunchRecordFaker.cs
+++ b/sample/Sample.Core/LaunchRecordFaker.cs
@@ -1,5 +1,6 @@
using Bogus;
using Sample.Core.Domain;
+using Sample.Core.Models;
namespace Sample.Core;
@@ -7,7 +8,7 @@ public class LaunchRecordFaker : Faker
{
public LaunchRecordFaker(IReadOnlyCollection rockets)
{
- RuleFor(x => x.Id, x => x.Random.Guid());
+ RuleFor(x => x.Id, x => new LaunchRecordId(x.Random.Guid()));
RuleFor(x => x.Partner, x => x.Company.CompanyName());
RuleFor(x => x.Rocket, x => x.PickRandom(rockets.AsEnumerable()));
RuleFor(x => x.RocketId, (_, v) => v.Rocket.Id);
diff --git a/sample/Sample.Core/Models/LaunchRecordModel.cs b/sample/Sample.Core/Models/LaunchRecordModel.cs
index 8f6f67d09..9e45f8146 100644
--- a/sample/Sample.Core/Models/LaunchRecordModel.cs
+++ b/sample/Sample.Core/Models/LaunchRecordModel.cs
@@ -1,18 +1,67 @@
using AutoMapper;
using NodaTime;
using Sample.Core.Domain;
+using StronglyTypedIds;
namespace Sample.Core.Models;
+///
+/// Unique Id of a launch record
+///
+[StronglyTypedId(
+ StronglyTypedIdBackingType.Guid,
+ StronglyTypedIdConverter.SystemTextJson | StronglyTypedIdConverter.EfCoreValueConverter | StronglyTypedIdConverter.TypeConverter
+)]
+public partial struct LaunchRecordId
+{
+}
+
+///
+/// The launch record details
+///
public record LaunchRecordModel
{
- public Guid Id { get; init; }
+ ///
+ /// The launch record id
+ ///
+ public LaunchRecordId Id { get; init; }
+
+ ///
+ /// The launch partner
+ ///
public string Partner { get; init; } = null!;
+
+ ///
+ /// The payload details
+ ///
public string Payload { get; init; } = null!;
+
+ ///
+ /// The payload weight in Kg
+ ///
public long PayloadWeightKg { get; init; }
+
+ ///
+ /// The actual launch date
+ ///
+ ///
+ /// Will be updated when the launch happens
+ ///
public Instant? ActualLaunchDate { get; init; }
+
+ ///
+ /// The intended date for the launch
+ ///
public Instant ScheduledLaunchDate { get; init; }
+
+ ///
+ /// The serial number of the reusable rocket
+ ///
public string RocketSerialNumber { get; init; } = null!;
+
+ ///
+ /// The kind of rocket that will be launching
+ ///
public RocketType RocketType { get; init; }
private class Mapper : Profile
diff --git a/sample/Sample.Core/Models/RocketModel.cs b/sample/Sample.Core/Models/RocketModel.cs
index 177594327..52fdef8cb 100644
--- a/sample/Sample.Core/Models/RocketModel.cs
+++ b/sample/Sample.Core/Models/RocketModel.cs
@@ -1,12 +1,38 @@
using AutoMapper;
using Sample.Core.Domain;
+using StronglyTypedIds;
namespace Sample.Core.Models;
+///
+/// Unique Id of a rocket
+///
+[StronglyTypedId(
+ StronglyTypedIdBackingType.Guid,
+ StronglyTypedIdConverter.SystemTextJson | StronglyTypedIdConverter.EfCoreValueConverter | StronglyTypedIdConverter.TypeConverter
+)]
+public partial struct RocketId
+{
+}
+
+///
+/// The details of a given rocket
+///
public record RocketModel
{
- public Guid Id { get; init; }
+ ///
+ /// The unique rocket identifier
+ ///
+ public RocketId Id { get; init; }
+
+ ///
+ /// The serial number of the rocket
+ ///
public string Sn { get; init; } = null!;
+
+ ///
+ /// The type of the rocket
+ ///
public RocketType Type { get; init; }
private class Mapper : Profile
diff --git a/sample/Sample.Core/Operations/LaunchRecords/CreateLaunchRecord.cs b/sample/Sample.Core/Operations/LaunchRecords/CreateLaunchRecord.cs
index 61d48872e..560e04569 100644
--- a/sample/Sample.Core/Operations/LaunchRecords/CreateLaunchRecord.cs
+++ b/sample/Sample.Core/Operations/LaunchRecords/CreateLaunchRecord.cs
@@ -4,25 +4,58 @@
using NodaTime;
using Rocket.Surgery.LaunchPad.Foundation;
using Sample.Core.Domain;
+using Sample.Core.Models;
namespace Sample.Core.Operations.LaunchRecords;
[PublicAPI]
public static class CreateLaunchRecord
{
+ ///
+ /// Create a launch record
+ ///
public record Request : IRequest
{
+ ///
+ /// The rocket to use
+ ///
+ public RocketId RocketId { get; set; } // TODO: Make generator that can be used to create a writable view model
+
+ ///
+ /// The launch partner
+ ///
public string? Partner { get; set; } = null!; // TODO: Make generator that can be used to create a writable view model
+
+ ///
+ /// The launch partners payload
+ ///
public string? Payload { get; set; } = null!; // TODO: Make generator that can be used to create a writable view model
+
+ ///
+ /// The payload weight
+ ///
public double PayloadWeightKg { get; set; } // TODO: Make generator that can be used to create a writable view model
+
+ ///
+ /// The actual launch date
+ ///
public Instant? ActualLaunchDate { get; set; } // TODO: Make generator that can be used to create a writable view model
+
+ ///
+ /// The intended launch date
+ ///
public Instant ScheduledLaunchDate { get; set; } // TODO: Make generator that can be used to create a writable view model
- public Guid RocketId { get; set; } // TODO: Make generator that can be used to create a writable view model
}
+ ///
+ /// The launch record creation response
+ ///
public record Response
{
- public Guid Id { get; init; }
+ ///
+ /// The id of the new launch record
+ ///
+ public LaunchRecordId Id { get; init; }
}
private class Mapper : Profile
diff --git a/sample/Sample.Core/Operations/LaunchRecords/DeleteLaunchRecord.cs b/sample/Sample.Core/Operations/LaunchRecords/DeleteLaunchRecord.cs
index 34a568cbe..b835c1828 100644
--- a/sample/Sample.Core/Operations/LaunchRecords/DeleteLaunchRecord.cs
+++ b/sample/Sample.Core/Operations/LaunchRecords/DeleteLaunchRecord.cs
@@ -2,15 +2,22 @@
using MediatR;
using Rocket.Surgery.LaunchPad.Foundation;
using Sample.Core.Domain;
+using Sample.Core.Models;
namespace Sample.Core.Operations.LaunchRecords;
[PublicAPI]
public static class DeleteLaunchRecord
{
+ ///
+ /// The request to delete a launch record
+ ///
public record Request : IRequest
{
- public Guid Id { get; init; }
+ ///
+ /// The launch record to delete
+ ///
+ public LaunchRecordId Id { get; init; }
}
[UsedImplicitly]
diff --git a/sample/Sample.Core/Operations/LaunchRecords/EditLaunchRecord.cs b/sample/Sample.Core/Operations/LaunchRecords/EditLaunchRecord.cs
index ef0b82e55..4bf29c68b 100644
--- a/sample/Sample.Core/Operations/LaunchRecords/EditLaunchRecord.cs
+++ b/sample/Sample.Core/Operations/LaunchRecords/EditLaunchRecord.cs
@@ -12,26 +12,45 @@ namespace Sample.Core.Operations.LaunchRecords;
[PublicAPI]
public static partial class EditLaunchRecord
{
- // TODO: Make generator that can be used to map this directly
- public static Request CreateRequest(Guid id, Model model, IMapper mapper)
+ ///
+ /// The launch record update request
+ ///
+ public partial record Request : IRequest
{
- return mapper.Map(model, new Request { Id = id });
- }
+ ///
+ /// The launch record to update
+ ///
+ public LaunchRecordId Id { get; init; }
- public record Model
- {
+ ///
+ /// The updated launch partner
+ ///
public string Partner { get; set; } = null!; // TODO: Make generator that can be used to create a writable view model
+
+ ///
+ /// The updated launch payload
+ ///
public string Payload { get; set; } = null!; // TODO: Make generator that can be used to create a writable view model
+
+ ///
+ /// The updated payload weight
+ ///
public double PayloadWeightKg { get; set; } // TODO: Make generator that can be used to create a writable view model
+
+ ///
+ /// The updated actual launch date
+ ///
public Instant? ActualLaunchDate { get; set; } // TODO: Make generator that can be used to create a writable view model
+
+ ///
+ /// The scheduled launch date
+ ///
public Instant ScheduledLaunchDate { get; set; } // TODO: Make generator that can be used to create a writable view model
- public Guid RocketId { get; set; } // TODO: Make generator that can be used to create a writable view model
- }
- [InheritFrom(typeof(Model))]
- public partial record Request : Model, IRequest
- {
- public Guid Id { get; init; }
+ ///
+ /// The update rocket id
+ ///
+ public RocketId RocketId { get; set; } // TODO: Make generator that can be used to create a writable view model
}
private class Mapper : Profile
@@ -42,43 +61,39 @@ public Mapper()
.ForMember(x => x.Rocket, x => x.Ignore())
.ForMember(x => x.Id, x => x.Ignore())
;
- CreateMap()
- .ForMember(z => z.Id, z => z.Ignore());
}
}
- private class ModelValidator : AbstractValidator
+ private class Validator : AbstractValidator
{
- public ModelValidator()
+ public Validator()
{
+ RuleFor(z => z.Id)
+ .NotEmpty()
+ .NotNull();
+
RuleFor(x => x.Partner)
.NotEmpty()
.NotNull();
+
RuleFor(x => x.RocketId)
.NotEmpty()
.NotNull();
+
RuleFor(x => x.Payload)
.NotEmpty()
.NotNull();
+
RuleFor(x => x.ActualLaunchDate);
+
RuleFor(x => x.ScheduledLaunchDate)
.NotNull();
+
RuleFor(x => x.PayloadWeightKg)
.GreaterThanOrEqualTo(0d);
}
}
- private class Validator : AbstractValidator
- {
- public Validator()
- {
- Include(new ModelValidator());
- RuleFor(z => z.Id)
- .NotEmpty()
- .NotNull();
- }
- }
-
private class Handler : IRequestHandler
{
private readonly RocketDbContext _dbContext;
diff --git a/sample/Sample.Core/Operations/LaunchRecords/GetLaunchRecord.cs b/sample/Sample.Core/Operations/LaunchRecords/GetLaunchRecord.cs
index 728cdcb1b..62c9b2514 100644
--- a/sample/Sample.Core/Operations/LaunchRecords/GetLaunchRecord.cs
+++ b/sample/Sample.Core/Operations/LaunchRecords/GetLaunchRecord.cs
@@ -11,9 +11,15 @@ namespace Sample.Core.Operations.LaunchRecords;
[PublicAPI]
public static class GetLaunchRecord
{
+ ///
+ /// The request to get a launch record
+ ///
public record Request : IRequest
{
- public Guid Id { get; init; }
+ ///
+ /// The launch record to find
+ ///
+ public LaunchRecordId Id { get; init; }
}
private class Validator : AbstractValidator
diff --git a/sample/Sample.Core/Operations/LaunchRecords/ListLaunchRecords.cs b/sample/Sample.Core/Operations/LaunchRecords/ListLaunchRecords.cs
index 0ee1e83d7..3d434cf18 100644
--- a/sample/Sample.Core/Operations/LaunchRecords/ListLaunchRecords.cs
+++ b/sample/Sample.Core/Operations/LaunchRecords/ListLaunchRecords.cs
@@ -11,14 +11,18 @@ namespace Sample.Core.Operations.LaunchRecords;
[PublicAPI]
public static class ListLaunchRecords
{
+ ///
+ /// The launch record search
+ ///
+ /// The rocket type
// TODO: Paging model!
- public record Request : IRequest>;
+ public record Request(RocketType? RocketType) : IStreamRequest;
private class Validator : AbstractValidator
{
}
- private class Handler : IRequestHandler>
+ private class Handler : IStreamRequestHandler
{
private readonly RocketDbContext _dbContext;
private readonly IMapper _mapper;
@@ -29,15 +33,19 @@ public Handler(RocketDbContext dbContext, IMapper mapper)
_mapper = mapper;
}
- public async Task> Handle(Request request, CancellationToken cancellationToken)
+ public IAsyncEnumerable Handle(Request request, CancellationToken cancellationToken)
{
- return (
- await _dbContext.LaunchRecords
- .Include(x => x.Rocket)
- .ProjectTo(_mapper.ConfigurationProvider)
- .ToListAsync(cancellationToken)
- )
- .ToArray();
+ var query = _dbContext.LaunchRecords
+ .Include(x => x.Rocket)
+ .AsQueryable();
+ if (request.RocketType.HasValue)
+ {
+ query = query.Where(z => z.Rocket.Type == request.RocketType);
+ }
+
+ return query
+ .ProjectTo(_mapper.ConfigurationProvider)
+ .ToAsyncEnumerable();
}
}
}
diff --git a/sample/Sample.Core/Operations/Rockets/CreateRocket.cs b/sample/Sample.Core/Operations/Rockets/CreateRocket.cs
index 5744f3ef4..63833a024 100644
--- a/sample/Sample.Core/Operations/Rockets/CreateRocket.cs
+++ b/sample/Sample.Core/Operations/Rockets/CreateRocket.cs
@@ -4,21 +4,38 @@
using Microsoft.EntityFrameworkCore;
using Rocket.Surgery.LaunchPad.Foundation;
using Sample.Core.Domain;
+using Sample.Core.Models;
namespace Sample.Core.Operations.Rockets;
[PublicAPI]
public static class CreateRocket
{
+ ///
+ /// The operation to create a new rocket record
+ ///
public record Request : IRequest
{
+ ///
+ /// The serial number of the rocket
+ ///
public string SerialNumber { get; set; } = null!; // TODO: Make generator that can be used to create a writable view model
+
+ ///
+ /// The type of rocket
+ ///
public RocketType Type { get; set; } // TODO: Make generator that can be used to create a writable view model
}
+ ///
+ /// The identifier of the rocket that was created
+ ///
public record Response
{
- public Guid Id { get; init; }
+ ///
+ /// The rocket id
+ ///
+ public RocketId Id { get; init; }
}
private class Mapper : Profile
diff --git a/sample/Sample.Core/Operations/Rockets/DeleteRocket.cs b/sample/Sample.Core/Operations/Rockets/DeleteRocket.cs
index 106d8921b..3e5581736 100644
--- a/sample/Sample.Core/Operations/Rockets/DeleteRocket.cs
+++ b/sample/Sample.Core/Operations/Rockets/DeleteRocket.cs
@@ -2,15 +2,22 @@
using MediatR;
using Rocket.Surgery.LaunchPad.Foundation;
using Sample.Core.Domain;
+using Sample.Core.Models;
namespace Sample.Core.Operations.Rockets;
[PublicAPI]
public static class DeleteRocket
{
+ ///
+ /// The request to remove a rocket from the system
+ ///
public record Request : IRequest
{
- public Guid Id { get; init; }
+ ///
+ /// The rocket id
+ ///
+ public RocketId Id { get; init; }
}
private class Validator : AbstractValidator
diff --git a/sample/Sample.Core/Operations/Rockets/EditRocket.cs b/sample/Sample.Core/Operations/Rockets/EditRocket.cs
index a823e673a..c759e343f 100644
--- a/sample/Sample.Core/Operations/Rockets/EditRocket.cs
+++ b/sample/Sample.Core/Operations/Rockets/EditRocket.cs
@@ -10,19 +10,25 @@ namespace Sample.Core.Operations.Rockets;
[PublicAPI]
public static partial class EditRocket
{
- public record Model
+ ///
+ /// The edit operation to update a rocket
+ ///
+ public record Request : IRequest
{
- // TODO: Make generator that can be used to create a writable view model
- public string SerialNumber { get; set; } = null!;
+ ///
+ /// The rocket id
+ ///
+ public RocketId Id { get; init; }
- // TODO: Make generator that can be used to create a writable view model
- public RocketType Type { get; set; }
- }
+ ///
+ /// The serial number of the rocket
+ ///
+ public string SerialNumber { get; set; } = null!; // TODO: Make generator that can be used to create a writable view model
- [InheritFrom(typeof(Model))]
- public partial record Request : Model, IRequest
- {
- public Guid Id { get; init; }
+ ///
+ /// The type of the rocket
+ ///
+ public RocketType Type { get; set; } // TODO: Make generator that can be used to create a writable view model
}
private class Mapper : Profile
@@ -39,10 +45,14 @@ public Mapper()
}
}
- private class ModelValidator : AbstractValidator
+ private class RequestValidator : AbstractValidator
{
- public ModelValidator()
+ public RequestValidator()
{
+ RuleFor(x => x.Id)
+ .NotEmpty()
+ .NotNull();
+
RuleFor(x => x.Type)
.NotNull()
.IsInEnum();
@@ -54,17 +64,6 @@ public ModelValidator()
}
}
- private class RequestValidator : AbstractValidator
- {
- public RequestValidator()
- {
- Include(new ModelValidator());
- RuleFor(x => x.Id)
- .NotEmpty()
- .NotNull();
- }
- }
-
private class Handler : IRequestHandler
{
private readonly RocketDbContext _dbContext;
diff --git a/sample/Sample.Core/Operations/Rockets/GetRocket.cs b/sample/Sample.Core/Operations/Rockets/GetRocket.cs
index b77534e30..bf5ac0813 100644
--- a/sample/Sample.Core/Operations/Rockets/GetRocket.cs
+++ b/sample/Sample.Core/Operations/Rockets/GetRocket.cs
@@ -10,9 +10,15 @@ namespace Sample.Core.Operations.Rockets;
[PublicAPI]
public static class GetRocket
{
+ ///
+ /// Request to fetch information about a rocket
+ ///
public record Request : IRequest
{
- public Guid Id { get; set; }
+ ///
+ /// The rocket id
+ ///
+ public RocketId Id { get; set; }
}
private class Validator : AbstractValidator
diff --git a/sample/Sample.Core/Operations/Rockets/GetRocketLaunchRecord.cs b/sample/Sample.Core/Operations/Rockets/GetRocketLaunchRecord.cs
new file mode 100644
index 000000000..f6b3f13f0
--- /dev/null
+++ b/sample/Sample.Core/Operations/Rockets/GetRocketLaunchRecord.cs
@@ -0,0 +1,67 @@
+using AutoMapper;
+using FluentValidation;
+using MediatR;
+using Rocket.Surgery.LaunchPad.Foundation;
+using Sample.Core.Domain;
+using Sample.Core.Models;
+
+namespace Sample.Core.Operations.Rockets;
+
+[PublicAPI]
+public static class GetRocketLaunchRecord
+{
+ public record Request : IRequest
+ {
+ ///
+ /// The rocket id
+ ///
+ public RocketId Id { get; init; }
+
+ ///
+ /// The launch record id
+ ///
+ public LaunchRecordId LaunchRecordId { get; init; }
+ }
+
+ private class Validator : AbstractValidator
+ {
+ public Validator()
+ {
+ RuleFor(x => x.Id)
+ .NotEmpty()
+ .NotNull();
+ RuleFor(x => x.LaunchRecordId)
+ .NotEmpty()
+ .NotNull();
+ }
+ }
+
+ private class Handler : IRequestHandler
+ {
+ private readonly RocketDbContext _dbContext;
+ private readonly IMapper _mapper;
+
+ public Handler(RocketDbContext dbContext, IMapper mapper)
+ {
+ _dbContext = dbContext;
+ _mapper = mapper;
+ }
+
+ public async Task Handle(Request request, CancellationToken cancellationToken)
+ {
+ var rocket = await _dbContext.Rockets.FindAsync(new object[] { request.Id }, cancellationToken);
+ if (rocket == null)
+ {
+ throw new NotFoundException();
+ }
+
+ var launchRecord = _dbContext.LaunchRecords.FindAsync(new object[] { request.LaunchRecordId }, cancellationToken);
+ if (launchRecord == null)
+ {
+ throw new NotFoundException();
+ }
+
+ return _mapper.Map(launchRecord);
+ }
+ }
+}
diff --git a/sample/Sample.Core/Operations/Rockets/GetRocketLaunchRecords.cs b/sample/Sample.Core/Operations/Rockets/GetRocketLaunchRecords.cs
new file mode 100644
index 000000000..1042123fe
--- /dev/null
+++ b/sample/Sample.Core/Operations/Rockets/GetRocketLaunchRecords.cs
@@ -0,0 +1,62 @@
+using System.Runtime.CompilerServices;
+using AutoMapper;
+using AutoMapper.QueryableExtensions;
+using FluentValidation;
+using MediatR;
+using Microsoft.EntityFrameworkCore;
+using Rocket.Surgery.LaunchPad.Foundation;
+using Sample.Core.Domain;
+using Sample.Core.Models;
+
+namespace Sample.Core.Operations.Rockets;
+
+[PublicAPI]
+public static class GetRocketLaunchRecords
+{
+ public record Request : IStreamRequest
+ {
+ ///
+ /// The rocket id
+ ///
+ public RocketId Id { get; init; }
+ }
+
+ private class Validator : AbstractValidator
+ {
+ public Validator()
+ {
+ RuleFor(x => x.Id)
+ .NotEmpty()
+ .NotNull();
+ }
+ }
+
+ private class Handler : IStreamRequestHandler
+ {
+ private readonly RocketDbContext _dbContext;
+ private readonly IMapper _mapper;
+
+ public Handler(RocketDbContext dbContext, IMapper mapper)
+ {
+ _dbContext = dbContext;
+ _mapper = mapper;
+ }
+
+ public async IAsyncEnumerable Handle(Request request, [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ var rocket = await _dbContext.Rockets.FindAsync(new object[] { request.Id }, cancellationToken);
+ if (rocket == null)
+ {
+ throw new NotFoundException();
+ }
+
+ var query = _dbContext.LaunchRecords.AsQueryable()
+ .Where(z => z.RocketId == rocket.Id)
+ .ProjectTo(_mapper.ConfigurationProvider);
+ await foreach (var item in query.AsAsyncEnumerable().WithCancellation(cancellationToken))
+ {
+ yield return item;
+ }
+ }
+ }
+}
diff --git a/sample/Sample.Core/Operations/Rockets/ListRockets.cs b/sample/Sample.Core/Operations/Rockets/ListRockets.cs
index 29aca47c8..05d638b60 100644
--- a/sample/Sample.Core/Operations/Rockets/ListRockets.cs
+++ b/sample/Sample.Core/Operations/Rockets/ListRockets.cs
@@ -12,13 +12,17 @@ namespace Sample.Core.Operations.Rockets;
public static class ListRockets
{
// TODO: Paging model!
- public record Request : IRequest>;
+ ///
+ /// The request to search for different rockets
+ ///
+ /// The type of the rocket
+ public record Request(RocketType? RocketType) : IStreamRequest;
private class Validator : AbstractValidator
{
}
- private class Handler : IRequestHandler>
+ private class Handler : IStreamRequestHandler
{
private readonly RocketDbContext _dbContext;
private readonly IMapper _mapper;
@@ -29,12 +33,15 @@ public Handler(RocketDbContext dbContext, IMapper mapper)
_mapper = mapper;
}
- public async Task> Handle(Request request, CancellationToken cancellationToken)
+ public IAsyncEnumerable Handle(Request request, CancellationToken cancellationToken)
{
- return await _dbContext.Rockets
- .ProjectTo(_mapper.ConfigurationProvider)
- .ToListAsync(cancellationToken)
- .ConfigureAwait(false);
+ var query = _dbContext.Rockets.AsQueryable();
+ if (request.RocketType.HasValue)
+ {
+ query = query.Where(z => z.Type == request.RocketType);
+ }
+
+ return query.ProjectTo(_mapper.ConfigurationProvider).AsAsyncEnumerable();
}
}
}
diff --git a/sample/Sample.Core/RocketFaker.cs b/sample/Sample.Core/RocketFaker.cs
index 5ae2b3c44..68934f933 100644
--- a/sample/Sample.Core/RocketFaker.cs
+++ b/sample/Sample.Core/RocketFaker.cs
@@ -1,5 +1,6 @@
using Bogus;
using Sample.Core.Domain;
+using Sample.Core.Models;
namespace Sample.Core;
@@ -7,7 +8,7 @@ public class RocketFaker : Faker
{
public RocketFaker()
{
- RuleFor(x => x.Id, x => x.Random.Guid());
+ RuleFor(x => x.Id, x => new RocketId(x.Random.Guid()));
RuleFor(x => x.Type, x => x.PickRandom());
RuleFor(x => x.SerialNumber, x => x.Vehicle.Vin());
}
diff --git a/sample/Sample.Core/Sample.Core.csproj b/sample/Sample.Core/Sample.Core.csproj
index e9fa5f925..6969c4d05 100644
--- a/sample/Sample.Core/Sample.Core.csproj
+++ b/sample/Sample.Core/Sample.Core.csproj
@@ -2,18 +2,26 @@
netstandard2.1;net6.0
+ true
+
+
-
+
diff --git a/sample/Sample.Graphql/ConfigureLaunchRecordType.cs b/sample/Sample.Graphql/ConfigureLaunchRecordType.cs
index 71dfb15a4..09b08f08e 100644
--- a/sample/Sample.Graphql/ConfigureLaunchRecordType.cs
+++ b/sample/Sample.Graphql/ConfigureLaunchRecordType.cs
@@ -3,6 +3,7 @@
using HotChocolate.Types.Pagination;
using Rocket.Surgery.LaunchPad.EntityFramework.HotChocolate;
using Sample.Core.Domain;
+using Sample.Core.Models;
namespace Sample.Graphql;
@@ -12,7 +13,7 @@ public override void Configure(IObjectFieldDescriptor fieldDescriptor)
{
fieldDescriptor
.UsePaging(
- typeof(ObjectType),
+ typeof(ObjectType),
options: new PagingOptions
{
DefaultPageSize = 10,
@@ -21,7 +22,7 @@ public override void Configure(IObjectFieldDescriptor fieldDescriptor)
}
)
.UseOffsetPaging(
- typeof(ObjectType),
+ typeof(ObjectType),
options: new PagingOptions
{
DefaultPageSize = 10,
@@ -29,14 +30,14 @@ public override void Configure(IObjectFieldDescriptor fieldDescriptor)
MaxPageSize = 20
}
)
- .UseProjection()
- .UseFiltering()
+// .UseProjection()
+ .UseFiltering(x => x.Ignore(x => x.ActualLaunchDate))
.UseSorting();
}
- private class LaunchRecordSort : SortInputType
+ private class LaunchRecordSort : SortInputType
{
- protected override void Configure(ISortInputTypeDescriptor descriptor)
+ protected override void Configure(ISortInputTypeDescriptor descriptor)
{
descriptor.BindFieldsExplicitly();
descriptor.Field(z => z.Partner);
diff --git a/sample/Sample.Graphql/ConfigureReadyRocketType.cs b/sample/Sample.Graphql/ConfigureReadyRocketType.cs
index 49841ac7b..cc82a985d 100644
--- a/sample/Sample.Graphql/ConfigureReadyRocketType.cs
+++ b/sample/Sample.Graphql/ConfigureReadyRocketType.cs
@@ -3,6 +3,7 @@
using HotChocolate.Types.Pagination;
using Rocket.Surgery.LaunchPad.EntityFramework.HotChocolate;
using Sample.Core.Domain;
+using Sample.Core.Models;
namespace Sample.Graphql;
@@ -12,7 +13,7 @@ public override void Configure(IObjectFieldDescriptor fieldDescriptor)
{
fieldDescriptor
.UsePaging(
- typeof(ObjectType),
+ typeof(ObjectType),
options: new PagingOptions
{
DefaultPageSize = 10,
@@ -25,13 +26,13 @@ public override void Configure(IObjectFieldDescriptor fieldDescriptor)
.UseSorting();
}
- private class RocketSort : SortInputType
+ private class RocketSort : SortInputType
{
- protected override void Configure(ISortInputTypeDescriptor descriptor)
+ protected override void Configure(ISortInputTypeDescriptor descriptor)
{
descriptor.BindFieldsExplicitly();
descriptor.Field(z => z.Type);
- descriptor.Field(z => z.SerialNumber);
+ descriptor.Field(z => z.Sn);
}
}
}
diff --git a/sample/Sample.Graphql/Program.cs b/sample/Sample.Graphql/Program.cs
index 3d407d490..8b4c3cb8a 100644
--- a/sample/Sample.Graphql/Program.cs
+++ b/sample/Sample.Graphql/Program.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyModel;
using Rocket.Surgery.Conventions;
using Rocket.Surgery.Hosting;
+using Rocket.Surgery.LaunchPad.HotChocolate;
namespace Sample.Graphql;
@@ -17,7 +18,23 @@ public static void Main(string[] args)
public static IHostBuilder CreateHostBuilder(string[] args)
{
return Host.CreateDefaultBuilder(args)
- .LaunchWith(RocketBooster.ForDependencyContext(DependencyContext.Default), z => z.WithConventionsFrom(GetConventions))
+ .LaunchWith(
+ RocketBooster.ForDependencyContext(DependencyContext.Default),
+ z => z
+ .WithConventionsFrom(GetConventions)
+ .Set(
+ new RocketChocolateOptions
+ {
+ RequestPredicate = type =>
+ type is { IsNested: true, DeclaringType: { } }
+ && !( type.Name.StartsWith("Get", StringComparison.Ordinal)
+ || type.Name.StartsWith(
+ "List", StringComparison.Ordinal
+ ) ),
+ IncludeAssemblyInfoQuery = true
+ }
+ )
+ )
.ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); });
}
}
diff --git a/sample/Sample.Graphql/Properties/launchSettings.json b/sample/Sample.Graphql/Properties/launchSettings.json
new file mode 100644
index 000000000..3e7303144
--- /dev/null
+++ b/sample/Sample.Graphql/Properties/launchSettings.json
@@ -0,0 +1,30 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:9140",
+ "sslPort": 44304
+ }
+ },
+ "profiles": {
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "launchUrl": "graphql",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "Sample.Restful": {
+ "commandName": "Project",
+ "launchBrowser": true,
+ "launchUrl": "graphql",
+ "applicationUrl": "https://localhost:5001;http://localhost:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/sample/Sample.Graphql/Sample.Graphql.csproj b/sample/Sample.Graphql/Sample.Graphql.csproj
index 35ff6d19f..ac16dc6ae 100644
--- a/sample/Sample.Graphql/Sample.Graphql.csproj
+++ b/sample/Sample.Graphql/Sample.Graphql.csproj
@@ -2,6 +2,7 @@
net6.0
+ Sample.Graphql.Executable
@@ -12,6 +13,12 @@
+
+
+
+
+
+
diff --git a/sample/Sample.Graphql/Startup.cs b/sample/Sample.Graphql/Startup.cs
index 827264283..c6e3336e3 100644
--- a/sample/Sample.Graphql/Startup.cs
+++ b/sample/Sample.Graphql/Startup.cs
@@ -1,6 +1,11 @@
-using Rocket.Surgery.LaunchPad.AspNetCore;
-using Rocket.Surgery.LaunchPad.EntityFramework.HotChocolate;
+using HotChocolate;
+using HotChocolate.Data.Filters;
+using HotChocolate.Data.Sorting;
+using HotChocolate.Types;
+using HotChocolate.Types.Pagination;
+using Rocket.Surgery.LaunchPad.AspNetCore;
using Sample.Core.Domain;
+using Sample.Core.Models;
using Serilog;
namespace Sample.Graphql;
@@ -13,23 +18,33 @@ public void ConfigureServices(IServiceCollection services)
{
services
.AddGraphQLServer()
- .AddDefaultTransactionScopeHandler()
+// .AddDefaultTransactionScopeHandler()
.AddQueryType()
.AddMutationType()
+ .ModifyRequestOptions(
+ options => { options.IncludeExceptionDetails = true; }
+ )
+ .AddTypeConverter(source => source.Value)
+ .AddTypeConverter(source => new RocketId(source))
+ .AddTypeConverter(source => source.Value)
+ .AddTypeConverter(source => new LaunchRecordId(source))
.ConfigureSchema(
s =>
{
- s.AddType(
- new ConfigureConfigureEntityFrameworkContextQueryType(
- new ConfigureReadyRocketType(),
- new ConfigureLaunchRecordType()
- )
- );
+ s.AddType();
+ s.AddType();
+ s.AddType();
+
+ s.BindClrType();
+ s.BindClrType();
+ s.BindRuntimeType(ScalarNames.UUID);
+ s.BindRuntimeType(ScalarNames.UUID);
}
)
.AddSorting()
.AddFiltering()
- .AddProjections();
+ .AddProjections()
+ .AddConvention();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
@@ -52,3 +67,189 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
);
}
}
+
+[ExtendObjectType(OperationTypeNames.Query)]
+public class QueryType : ObjectTypeExtension
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor
+ .Field(t => t.GetLaunchRecords(default))
+ .UsePaging>>(
+ options: new PagingOptions
+ {
+ DefaultPageSize = 10,
+ IncludeTotalCount = true,
+ MaxPageSize = 20
+ }
+ )
+ .UseOffsetPaging>>(
+ options: new PagingOptions
+ {
+ DefaultPageSize = 10,
+ IncludeTotalCount = true,
+ MaxPageSize = 20
+ }
+ )
+ .UseProjection()
+ .UseFiltering()
+ .UseSorting();
+ descriptor
+ .Field(t => t.GetRockets(default))
+ .UsePaging>>(
+ options: new PagingOptions
+ {
+ DefaultPageSize = 10,
+ IncludeTotalCount = true,
+ MaxPageSize = 20
+ }
+ )
+ .UseOffsetPaging>>(
+ options: new PagingOptions
+ {
+ DefaultPageSize = 10,
+ IncludeTotalCount = true,
+ MaxPageSize = 20
+ }
+ )
+ .UseProjection()
+ .UseFiltering()
+ .UseSorting();
+ }
+}
+
+internal class LaunchRecordSort : SortInputType
+{
+ protected override void Configure(ISortInputTypeDescriptor descriptor)
+ {
+ descriptor.BindFieldsExplicitly();
+ descriptor.Field(z => z.Partner);
+ descriptor.Field(z => z.Payload);
+ descriptor.Field(z => z.PayloadWeightKg);
+ descriptor.Field(z => z.ActualLaunchDate);
+ descriptor.Field(z => z.ScheduledLaunchDate);
+ }
+}
+
+internal class RocketSort : SortInputType
+{
+ protected override void Configure(ISortInputTypeDescriptor descriptor)
+ {
+ descriptor.BindFieldsExplicitly();
+ descriptor.Field(z => z.Type);
+ descriptor.Field(z => z.SerialNumber);
+ }
+}
+
+[ExtendObjectType(OperationTypeNames.Query)]
+public class Query
+{
+ public IQueryable GetLaunchRecords([Service] RocketDbContext dbContext)
+ {
+ return dbContext.LaunchRecords;
+ }
+
+ public IQueryable GetRockets([Service] RocketDbContext dbContext)
+ {
+ return dbContext.Rockets;
+ }
+}
+
+public class CustomFilterConventionExtension : FilterConventionExtension
+{
+ protected override void Configure(IFilterConventionDescriptor descriptor)
+ {
+ base.Configure(descriptor);
+
+ descriptor
+ .BindRuntimeType()
+ .BindRuntimeType();
+ }
+}
+
+public class LaunchRecordBase : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor.Name(nameof(LaunchRecordBase));
+ descriptor.Field(z => z.Rocket).Ignore();
+ descriptor.Field(z => z.RocketId).Ignore();
+ }
+}
+
+public class ReadyRocketBase : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+ descriptor.Name(nameof(ReadyRocketBase));
+ descriptor.Field(z => z.LaunchRecords).Ignore();
+ }
+}
+
+public class ReadyRocketType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+// descriptor.Implements>();
+ descriptor.Field(z => z.LaunchRecords)
+ .Type>>>()
+ .UseFiltering()
+ .UseSorting();
+// descriptor.Ignore(z => z.LaunchRecords);
+ }
+}
+
+internal class LaunchRecordSortBase : SortInputType
+{
+ protected override void Configure(ISortInputTypeDescriptor descriptor)
+ {
+ descriptor.Field(nameof(LaunchRecord.Partner));
+ descriptor.Field(nameof(LaunchRecord.Payload));
+ descriptor.Field(nameof(LaunchRecord.PayloadWeightKg));
+ descriptor.Field(nameof(LaunchRecord.ActualLaunchDate));
+ descriptor.Field(nameof(LaunchRecord.ScheduledLaunchDate));
+ }
+}
+
+public class LaunchRecordType : ObjectType
+{
+ protected override void Configure(IObjectTypeDescriptor descriptor)
+ {
+// descriptor.Implements>();
+ descriptor.Field(z => z.Rocket)
+ .Type>();
+ descriptor.Field(z => z.RocketId).Ignore();
+// descriptor.Ignore(z => z.LaunchRecords);
+ }
+}
+
+public class StronglyTypedIdOperationFilterInputType
+ : FilterInputType
+ , IComparableOperationFilterInputType
+{
+ public StronglyTypedIdOperationFilterInputType()
+ {
+ }
+
+ public StronglyTypedIdOperationFilterInputType(Action configure)
+ : base(configure)
+ {
+ }
+
+ protected override void Configure(IFilterInputTypeDescriptor descriptor)
+ {
+ descriptor.Operation(DefaultFilterOperations.Equals)
+ .Type();
+
+ descriptor.Operation(DefaultFilterOperations.NotEquals)
+ .Type();
+
+ descriptor.Operation(DefaultFilterOperations.In)
+ .Type>();
+
+ descriptor.Operation(DefaultFilterOperations.NotIn)
+ .Type>();
+
+ descriptor.AllowAnd(false).AllowOr(false);
+ }
+}
diff --git a/sample/Sample.Grpc/Protos/rockets.proto b/sample/Sample.Grpc/Protos/rockets.proto
index 39ca28699..fd0050245 100644
--- a/sample/Sample.Grpc/Protos/rockets.proto
+++ b/sample/Sample.Grpc/Protos/rockets.proto
@@ -34,7 +34,7 @@ message GetLaunchRecordRequest
}
message ListLaunchRecordsRequest {
-
+ NullableRocketType RocketType = 1;
}
message ListLaunchRecordsResponse {
@@ -72,6 +72,12 @@ enum RocketType {
FalconHeavy = 1;
AtlasV = 2;
}
+message NullableRocketType {
+ oneof kind {
+ google.protobuf.NullValue null = 1;
+ RocketType data = 2;
+ }
+}
message CreateRocketRequest {
string SerialNumber = 1;
@@ -107,6 +113,7 @@ message RocketModel {
message ListRocketsRequest
{
+ NullableRocketType RocketType = 1;
}
message ListRocketsResponse
@@ -120,7 +127,7 @@ package greet;
service LaunchRecords {
// Sends a greeting
rpc GetLaunchRecords (GetLaunchRecordRequest) returns (LaunchRecordModel);
- rpc ListLaunchRecords (ListLaunchRecordsRequest) returns (ListLaunchRecordsResponse);
+ rpc ListLaunchRecords (ListLaunchRecordsRequest) returns (stream LaunchRecordModel);
rpc CreateLaunchRecord (CreateLaunchRecordRequest) returns (CreateLaunchRecordResponse);
rpc EditLaunchRecord (UpdateLaunchRecordRequest) returns (LaunchRecordModel);
rpc DeleteLaunchRecord (DeleteLaunchRecordRequest) returns (google.protobuf.Empty);
@@ -129,7 +136,7 @@ service LaunchRecords {
// The greeting service definition.
service Rockets {
rpc GetRockets (GetRocketRequest) returns (RocketModel);
- rpc ListRockets (ListRocketsRequest) returns (ListRocketsResponse);
+ rpc ListRockets (ListRocketsRequest) returns (stream RocketModel);
rpc CreateRocket (CreateRocketRequest) returns (CreateRocketResponse);
rpc EditRocket (UpdateRocketRequest) returns (RocketModel);
rpc DeleteRocket(DeleteRocketRequest) returns (google.protobuf.Empty);
diff --git a/sample/Sample.Grpc/Services/LaunchRecordsService.cs b/sample/Sample.Grpc/Services/LaunchRecordsService.cs
index 0e65d80f9..adc57c1d8 100644
--- a/sample/Sample.Grpc/Services/LaunchRecordsService.cs
+++ b/sample/Sample.Grpc/Services/LaunchRecordsService.cs
@@ -46,14 +46,15 @@ public override async Task GetLaunchRecords(GetLaunchRecordRe
return _mapper.Map(response);
}
- public override async Task ListLaunchRecords(ListLaunchRecordsRequest request, ServerCallContext context)
+ public override async Task ListLaunchRecords(
+ ListLaunchRecordsRequest request, IServerStreamWriter responseStream, ServerCallContext context
+ )
{
var mRequest = _mapper.Map(request);
- var response = await _mediator.Send(mRequest, context.CancellationToken);
- return new ListLaunchRecordsResponse
+ await foreach (var item in _mediator.CreateStream(mRequest, context.CancellationToken))
{
- Results = { response.Select(_mapper.Map) }
- };
+ await responseStream.WriteAsync(_mapper.Map(item));
+ }
}
[UsedImplicitly]
diff --git a/sample/Sample.Grpc/Services/RocketsService.cs b/sample/Sample.Grpc/Services/RocketsService.cs
index 04be3e0f1..c6b91d575 100644
--- a/sample/Sample.Grpc/Services/RocketsService.cs
+++ b/sample/Sample.Grpc/Services/RocketsService.cs
@@ -3,6 +3,7 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MediatR;
+using Sample.Core.Models;
using Sample.Core.Operations.Rockets;
namespace Sample.Grpc.Services;
@@ -42,13 +43,13 @@ public override async Task GetRockets(GetRocketRequest request, Ser
return _mapper.Map(response);
}
- public override async Task ListRockets(ListRocketsRequest request, ServerCallContext context)
+ public override async Task ListRockets(ListRocketsRequest request, IServerStreamWriter responseStream, ServerCallContext context)
{
- var response = await _mediator.Send(_mapper.Map(request), context.CancellationToken);
- return new ListRocketsResponse
+ var mRequest = _mapper.Map(request);
+ await foreach (var item in _mediator.CreateStream(mRequest, context.CancellationToken))
{
- Results = { response.Select(_mapper.Map) }
- };
+ await responseStream.WriteAsync(_mapper.Map(item));
+ }
}
[UsedImplicitly]
@@ -63,6 +64,21 @@ public Mapper()
CreateMap();
CreateMap();
CreateMap();
+ CreateMap().ConvertUsing(x => x.Value.ToString());
+ CreateMap().ConvertUsing(x => new RocketId(Guid.Parse(x)));
+ CreateMap().ConvertUsing(x => x.Value.ToString());
+ CreateMap().ConvertUsing(x => new LaunchRecordId(Guid.Parse(x)));
+
+ CreateMap().ConvertUsing(
+ static ts =>
+ ts != null && ts.KindCase == NullableRocketType.KindOneofCase.Data
+ ? (Core.Domain.RocketType)ts.Data
+ : default
+ );
+ CreateMap().ConvertUsing(
+ static ts =>
+ new NullableRocketType { Data = (RocketType)ts }
+ );
}
}
diff --git a/sample/Sample.Pages/Pages/Rockets/Index.cshtml.cs b/sample/Sample.Pages/Pages/Rockets/Index.cshtml.cs
index d579e7134..ddfbe9305 100644
--- a/sample/Sample.Pages/Pages/Rockets/Index.cshtml.cs
+++ b/sample/Sample.Pages/Pages/Rockets/Index.cshtml.cs
@@ -20,6 +20,6 @@ public RocketIndexModel(IMediator mediator)
public async Task OnGet()
{
- Rockets = await _mediator.Send(new ListRockets.Request());
+ Rockets = await _mediator.CreateStream(new ListRockets.Request(null)).ToListAsync(HttpContext.RequestAborted);
}
}
diff --git a/sample/Sample.Pages/Pages/Rockets/View.cshtml.cs b/sample/Sample.Pages/Pages/Rockets/View.cshtml.cs
index 4dbc236cb..8162d4f0c 100644
--- a/sample/Sample.Pages/Pages/Rockets/View.cshtml.cs
+++ b/sample/Sample.Pages/Pages/Rockets/View.cshtml.cs
@@ -9,7 +9,7 @@ public class RocketViewModel : MediatorPageModel
{
[UsedImplicitly]
[BindProperty(SupportsGet = true)]
- public Guid Id { get; set; }
+ public RocketId Id { get; set; }
public RocketModel Rocket { get; set; } = null!;
diff --git a/sample/Sample.Restful.Client/Sample.Restful.Client.csproj b/sample/Sample.Restful.Client/Sample.Restful.Client.csproj
index 5ccbe753e..7861938a3 100644
--- a/sample/Sample.Restful.Client/Sample.Restful.Client.csproj
+++ b/sample/Sample.Restful.Client/Sample.Restful.Client.csproj
@@ -7,7 +7,7 @@
@@ -18,6 +18,8 @@
ClassName="{controller}Client"
CodeGenerator="NSwagCSharp"
Namespace="$(RootNamespace)"
+ PrivateAssets="All"
+ ReferenceOutputAssembly="false"
>
/generateClientClasses:true /generateClientInterfaces:true /injectHttpClient:true /disposeHttpClient:false /generateExceptionClasses:true /wrapDtoExceptions:true /useBaseUrl:false /generateBaseUrlProperty:false /operationGenerationMode:"MultipleClientsFromFirstTagAndOperationId" /generateOptionalParameters:true /generateJsonMethods:false /enforceFlagEnums:true /parameterArrayType:"System.Collections.Generic.IEnumerable" /parameterDictionaryType:"System.Collections.Generic.IDictionary" /responseArrayType:"System.Collections.Generic.ICollection" /responseDictionaryType:"System.Collections.Generic.IDictionary" /wrapResponses:true /generateResponseClasses:true /responseClass:"Response" /requiredPropertiesMustBeDefined:true /dateType:"System.DateTimeOffset" /dateTimeType:"System.DateTimeOffset" /timeType:"System.TimeSpan" /timeSpanType:"System.TimeSpan" /arrayType:"System.Collections.ObjectModel.Collection" /arrayInstanceType:"System.Collections.ObjectModel.Collection" /dictionaryType:"System.Collections.Generic.IDictionary" /dictionaryInstanceType:"System.Collections.Generic.Dictionary" /arrayBaseType:"System.Collections.ObjectModel.Collection" /dictionaryBaseType:"System.Collections.Generic.Dictionary" /classStyle:"Poco" /generateDefaultValues:true /generateDataAnnotations:true /generateImmutableArrayProperties:true /generateImmutableDictionaryProperties:true /generateDtoTypes:true /generateOptionalPropertiesAsNullable:true
diff --git a/sample/Sample.Restful.Client/Test1.cs b/sample/Sample.Restful.Client/Test1.cs
new file mode 100644
index 000000000..64b3eb596
--- /dev/null
+++ b/sample/Sample.Restful.Client/Test1.cs
@@ -0,0 +1,5 @@
+namespace JetBrains.Annotations;
+
+public class Test1
+{
+}
diff --git a/sample/Sample.Restful/Controllers/LaunchRecordController.cs b/sample/Sample.Restful/Controllers/LaunchRecordController.cs
index 6319af8eb..c0f13b685 100644
--- a/sample/Sample.Restful/Controllers/LaunchRecordController.cs
+++ b/sample/Sample.Restful/Controllers/LaunchRecordController.cs
@@ -7,40 +7,48 @@
namespace Sample.Restful.Controllers;
[Route("[controller]")]
-public class LaunchRecordController : RestfulApiController
+public partial class LaunchRecordController : RestfulApiController
{
+ ///
+ /// Search for launch records
+ ///
+ /// The search context
+ ///
[HttpGet]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public Task>> ListLaunchRecords()
- {
- return Send(new ListLaunchRecords.Request(), x => Ok(x));
- }
+ public partial IAsyncEnumerable ListLaunchRecords(ListLaunchRecords.Request request);
+ ///
+ /// Load details of a specific launch record
+ ///
+ /// The request context
+ ///
[HttpGet("{id:guid}")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public Task> GetLaunchRecord([BindRequired] [FromRoute] GetLaunchRecord.Request request)
- {
- return Send(request, x => Ok(x));
- }
+ public partial Task> GetLaunchRecord(GetLaunchRecord.Request request);
+ ///
+ /// Create a new launch record
+ ///
+ /// The launch record details
+ ///
[HttpPost]
- public Task> CreateLaunchRecord([BindRequired] [FromBody] CreateLaunchRecord.Request request)
- {
- return Send(
- request,
- x => CreatedAtAction(nameof(GetLaunchRecord), new { id = x.Id }, x)
- );
- }
+ [Created(nameof(GetLaunchRecord))]
+ public partial Task> CreateLaunchRecord(CreateLaunchRecord.Request request);
+ ///
+ /// Update a given launch record
+ ///
+ /// The id of the launch record
+ /// The request details
+ ///
[HttpPut("{id:guid}")]
- public Task UpdateLaunchRecord([BindRequired] [FromRoute] Guid id, [BindRequired] [FromBody] EditLaunchRecord.Model model)
- {
- return Send(new EditLaunchRecord.Request { Id = id }.With(model), NoContent);
- }
+ // ReSharper disable once RouteTemplates.ParameterTypeAndConstraintsMismatch
+ public partial Task EditLaunchRecord([BindRequired] [FromRoute] LaunchRecordId id, EditLaunchRecord.Request model);
+ ///
+ /// Remove a launch record
+ ///
+ ///
+ ///
[HttpDelete("{id:guid}")]
- public Task RemoveLaunchRecord([BindRequired] [FromRoute] DeleteLaunchRecord.Request request)
- {
- return Send(request);
- }
+ public partial Task DeleteLaunchRecord(DeleteLaunchRecord.Request request);
}
diff --git a/sample/Sample.Restful/Controllers/RocketController.cs b/sample/Sample.Restful/Controllers/RocketController.cs
index 709511713..e25189fcd 100644
--- a/sample/Sample.Restful/Controllers/RocketController.cs
+++ b/sample/Sample.Restful/Controllers/RocketController.cs
@@ -7,40 +7,64 @@
namespace Sample.Restful.Controllers;
[Route("[controller]")]
-public class RocketController : RestfulApiController
+public partial class RocketController : RestfulApiController
{
+ ///
+ /// Search for rockets
+ ///
+ /// The search context
+ ///
[HttpGet]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public Task>> ListRockets()
- {
- return Send(new ListRockets.Request(), x => Ok(x));
- }
+ public partial IAsyncEnumerable ListRockets(ListRockets.Request request);
+ ///
+ /// Load details of a specific rocket
+ ///
+ /// The request context
+ ///
[HttpGet("{id:guid}")]
- [ProducesResponseType(StatusCodes.Status200OK)]
- public Task> GetRocket([BindRequired] [FromRoute] GetRocket.Request request)
- {
- return Send(request, x => Ok(x));
- }
+ public partial Task> GetRocket(GetRocket.Request request);
+ ///
+ /// Create a new rocket
+ ///
+ /// The rocket details
+ ///
[HttpPost]
- public Task> CreateRocket([BindRequired] [FromBody] CreateRocket.Request request)
- {
- return Send(
- request,
- x => CreatedAtAction(nameof(GetRocket), new { id = x.Id }, x)
- );
- }
+ [Created(nameof(GetRocket))]
+ public partial Task> CreateRocket(CreateRocket.Request request);
+ ///
+ /// Update a given rocket
+ ///
+ /// The id of the rocket
+ /// The request details
+ ///
[HttpPut("{id:guid}")]
- public Task UpdateRocket([BindRequired] Guid id, [BindRequired] [FromBody] EditRocket.Model model)
- {
- return Send(new EditRocket.Request { Id = id }.With(model), NoContent);
- }
+ // ReSharper disable once RouteTemplates.ParameterTypeAndConstraintsMismatch
+ public partial Task> EditRocket([BindRequired] [FromRoute] RocketId id, [BindRequired] [FromBody] EditRocket.Request model);
+ ///
+ /// Remove a rocket
+ ///
+ ///
+ ///
[HttpDelete("{id:guid}")]
- public Task RemoveRocket([BindRequired] [FromRoute] DeleteRocket.Request request)
- {
- return Send(request);
- }
+ public partial Task RemoveRocket([BindRequired] [FromRoute] DeleteRocket.Request request);
+
+ ///
+ /// Get the launch records for a given rocket
+ ///
+ ///
+ [HttpGet("{id:guid}/launch-records")]
+ // ReSharper disable once RouteTemplates.ParameterTypeAndConstraintsMismatch
+ public partial IAsyncEnumerable GetRocketLaunchRecords(GetRocketLaunchRecords.Request request);
+
+ ///
+ /// Get a specific launch record for a given rocket
+ ///
+ ///
+ [HttpGet("{id:guid}/launch-records/{launchRecordId:guid}")]
+ // ReSharper disable once RouteTemplates.ParameterTypeAndConstraintsMismatch
+ public partial Task> GetRocketLaunchRecord(GetRocketLaunchRecord.Request request);
}
diff --git a/sample/Sample.Restful/Sample.Restful.csproj b/sample/Sample.Restful/Sample.Restful.csproj
index 482660c47..768f4ce19 100644
--- a/sample/Sample.Restful/Sample.Restful.csproj
+++ b/sample/Sample.Restful/Sample.Restful.csproj
@@ -3,6 +3,7 @@
netcoreapp3.1;net6.0
true
true
+ true
diff --git a/sample/Sample.Worker/Worker.cs b/sample/Sample.Worker/Worker.cs
index 152f4754f..e6ffab8fd 100644
--- a/sample/Sample.Worker/Worker.cs
+++ b/sample/Sample.Worker/Worker.cs
@@ -19,7 +19,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
- var items = await _mediator.Invoke(m => m.Send(new ListRockets.Request(), stoppingToken));
+ var items = await _mediator.Invoke(m => m.CreateStream(new ListRockets.Request(null), stoppingToken)).ToListAsync();
_logger.LogInformation("Items: {@Items}", items);
await Task.Delay(1000, stoppingToken).ConfigureAwait(false);
}
diff --git a/src/Analyzers/Composition/ApiConventionNameMatchBehavior.cs b/src/Analyzers/Composition/ApiConventionNameMatchBehavior.cs
new file mode 100644
index 000000000..a08da13a7
--- /dev/null
+++ b/src/Analyzers/Composition/ApiConventionNameMatchBehavior.cs
@@ -0,0 +1,35 @@
+namespace Rocket.Surgery.LaunchPad.Analyzers.Composition;
+
+///
+/// The behavior for matching the name of a convention parameter or method.
+///
+internal enum ApiConventionNameMatchBehavior
+{
+ ///
+ /// Matches any name. Use this if the parameter does not need to be matched.
+ ///
+ Any,
+
+ ///
+ /// The parameter or method name must exactly match the convention.
+ ///
+ Exact,
+
+ ///
+ /// The parameter or method name in the convention is a proper prefix.
+ ///
+ /// Casing is used to delineate words in a given name. For instance, with this behavior
+ /// the convention name "Get" will match "Get", "GetPerson" or "GetById", but not "getById", "Getaway".
+ ///
+ ///
+ Prefix,
+
+ ///
+ /// The parameter or method name in the convention is a proper suffix.
+ ///
+ /// Casing is used to delineate words in a given name. For instance, with this behavior
+ /// the convention name "id" will match "id", or "personId" but not "grid" or "personid".
+ ///
+ ///
+ Suffix,
+}
diff --git a/src/Analyzers/Composition/ApiConventionTypeMatchBehavior.cs b/src/Analyzers/Composition/ApiConventionTypeMatchBehavior.cs
new file mode 100644
index 000000000..f2bf20f11
--- /dev/null
+++ b/src/Analyzers/Composition/ApiConventionTypeMatchBehavior.cs
@@ -0,0 +1,18 @@
+namespace Rocket.Surgery.LaunchPad.Analyzers.Composition;
+
+///
+/// The behavior for matching the type of a convention parameter.
+///
+internal enum ApiConventionTypeMatchBehavior
+{
+ ///
+ /// Matches any type. Use this if the parameter does not need to be matched.
+ ///
+ Any,
+
+ ///
+ /// The parameter in the convention is the exact type or a subclass of the type
+ /// specified in the convention.
+ ///
+ AssignableFrom,
+}
diff --git a/src/Analyzers/Composition/IRestfulApiMethodMatcher.cs b/src/Analyzers/Composition/IRestfulApiMethodMatcher.cs
new file mode 100644
index 000000000..d7906506f
--- /dev/null
+++ b/src/Analyzers/Composition/IRestfulApiMethodMatcher.cs
@@ -0,0 +1,10 @@
+namespace Rocket.Surgery.LaunchPad.Analyzers.Composition;
+
+internal interface IRestfulApiMethodMatcher
+{
+ RestfulApiMethod Method { get; }
+ ApiConventionNameMatchBehavior NameMatch { get; }
+ string[] Names { get; }
+ IDictionary Parameters { get; }
+ bool IsMatch(ActionModel actionModel);
+}
diff --git a/src/Analyzers/Composition/IRestfulApiParameterMatcher.cs b/src/Analyzers/Composition/IRestfulApiParameterMatcher.cs
new file mode 100644
index 000000000..a28741546
--- /dev/null
+++ b/src/Analyzers/Composition/IRestfulApiParameterMatcher.cs
@@ -0,0 +1,13 @@
+using Microsoft.CodeAnalysis;
+
+namespace Rocket.Surgery.LaunchPad.Analyzers.Composition;
+
+internal interface IRestfulApiParameterMatcher
+{
+ Index ParameterIndex { get; }
+ ApiConventionNameMatchBehavior NameMatch { get; }
+ string[] Names { get; }
+ ApiConventionTypeMatchBehavior TypeMatch { get; }
+ INamedTypeSymbol? Type { get; }
+ bool IsMatch(ActionModel actionModel);
+}
diff --git a/src/Analyzers/Composition/Index.cs b/src/Analyzers/Composition/Index.cs
new file mode 100644
index 000000000..1497e74c7
--- /dev/null
+++ b/src/Analyzers/Composition/Index.cs
@@ -0,0 +1,153 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Globalization;
+using System.Runtime.CompilerServices;
+
+// ReSharper disable once CheckNamespace
+namespace System;
+
+/// Represent a type can be used to index a collection either from the start or the end.
+///
+/// Index is used by the C# compiler to support the new index syntax
+///
+/// int[] someArray = new int[5] { 1, 2, 3, 4, 5 } ;
+/// int lastElement = someArray[^1]; // lastElement = 5
+///
+///
+internal readonly struct Index : IEquatable
+{
+ private readonly int _value;
+
+ /// Construct an Index using a value and indicating if the index is from the start or from the end.
+ /// The index value. it has to be zero or positive number.
+ /// Indicating if the index is from the start or from the end.
+ ///
+ /// If the Index constructed from the end, index value 1 means pointing at the last element and index value 0 means pointing at beyond last element.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public Index(int value, bool fromEnd = false)
+ {
+ if (value < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), value, "Non-negative number required.");
+ }
+
+ if (fromEnd)
+ _value = ~value;
+ else
+ _value = value;
+ }
+
+ // The following private constructors mainly created for perf reason to avoid the checks
+ private Index(int value)
+ {
+ _value = value;
+ }
+
+ /// Create an Index pointing at first element.
+ public static Index Start => new Index(0);
+
+ /// Create an Index pointing at beyond last element.
+ public static Index End => new Index(~0);
+
+ /// Create an Index from the start at the position indicated by the value.
+ /// The index value from the start.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static Index FromStart(int value)
+ {
+ if (value < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), value, "Non-negative number required.");
+ }
+
+ return new Index(value);
+ }
+
+ /// Create an Index from the end at the position indicated by the value.
+ /// The index value from the end.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static Index FromEnd(int value)
+ {
+ if (value < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(value), value, "Non-negative number required.");
+ }
+
+ return new Index(~value);
+ }
+
+ /// Returns the index value.
+ public int Value
+ {
+ get
+ {
+ if (_value < 0)
+ return ~_value;
+ return _value;
+ }
+ }
+
+ /// Indicates whether the index is from the start or the end.
+ public bool IsFromEnd => _value < 0;
+
+ /// Calculate the offset from the start using the giving collection length.
+ /// The length of the collection that the Index will be used with. length has to be a positive value
+ ///
+ /// For performance reason, we don't validate the input length parameter and the returned offset value against negative values.
+ /// we don't validate either the returned offset is greater than the input length.
+ /// It is expected Index will be used with collections which always have non negative length/count. If the returned offset is negative and
+ /// then used to index a collection will get out of range exception which will be same affect as the validation.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public int GetOffset(int length)
+ {
+ var offset = _value;
+ if (IsFromEnd)
+ {
+ // offset = length - (~value)
+ // offset = length + (~(~value) + 1)
+ // offset = length + value + 1
+
+ offset += length + 1;
+ }
+
+ return offset;
+ }
+
+ /// Indicates whether the current Index object is equal to another object of the same type.
+ /// An object to compare with this object
+ public override bool Equals(object? value)
+ {
+ return value is Index && _value == ( (Index)value )._value;
+ }
+
+ /// Indicates whether the current Index object is equal to another Index object.
+ /// An object to compare with this object
+ public bool Equals(Index other)
+ {
+ return _value == other._value;
+ }
+
+ /// Returns the hash code for this instance.
+ public override int GetHashCode()
+ {
+ return _value;
+ }
+
+ /// Converts integer number to an Index.
+ public static implicit operator Index(int value)
+ {
+ return FromStart(value);
+ }
+
+ /// Converts the value of the current Index object to its equivalent string representation.
+ public override string ToString()
+ {
+ if (IsFromEnd)
+ return $"^{( (uint)Value ).ToString(CultureInfo.InvariantCulture)}";
+
+ return ( (uint)Value ).ToString(CultureInfo.InvariantCulture);
+ }
+}
diff --git a/src/Analyzers/Composition/MatcherDefaults.cs b/src/Analyzers/Composition/MatcherDefaults.cs
new file mode 100644
index 000000000..2dc1c111f
--- /dev/null
+++ b/src/Analyzers/Composition/MatcherDefaults.cs
@@ -0,0 +1,69 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+
+namespace Rocket.Surgery.LaunchPad.Analyzers.Composition;
+
+internal static class MatcherDefaults
+{
+ ///
+ /// The cache of default status codes for a given method type.
+ ///
+ public static Dictionary MethodStatusCodeMap { get; } = new Dictionary
+ {
+ [RestfulApiMethod.List] = StatusCodes.Status200OK,
+ [RestfulApiMethod.Read] = StatusCodes.Status200OK,
+ [RestfulApiMethod.Create] = StatusCodes.Status201Created,
+ [RestfulApiMethod.Update] = StatusCodes.Status200OK,
+ [RestfulApiMethod.Delete] = StatusCodes.Status204NoContent,
+ };
+
+ public static ImmutableArray GetMatchers(Compilation compilation)
+ {
+ return DefaultMatchers(compilation).Where(z => z.IsValid()).OfType().ToImmutableArray();
+ }
+
+ private static IEnumerable DefaultMatchers(Compilation compilation)
+ {
+ var IBaseRequest = compilation.GetTypeByMetadataName("MediatR.IBaseRequest")!;
+ var IStreamRequest = compilation.GetTypeByMetadataName("MediatR.IStreamRequest`1")!;
+ yield return new RestfulApiMethodBuilder(RestfulApiMethod.List)
+ .MatchPrefix("List", "Search")
+ .MatchParameterType(^1, IBaseRequest);
+ yield return new RestfulApiMethodBuilder(RestfulApiMethod.List)
+ .MatchPrefix("List", "Search")
+ .MatchParameterType(^1, IStreamRequest);
+ yield return new RestfulApiMethodBuilder(RestfulApiMethod.Read)
+ .MatchPrefix("Get", "Find", "Fetch", "Read")
+ .MatchParameterType(^1, IBaseRequest);
+ yield return new RestfulApiMethodBuilder(RestfulApiMethod.Read)
+ .MatchPrefix("Get", "Find", "Fetch", "Read")
+ .MatchParameterType(^1, IStreamRequest);
+ yield return new RestfulApiMethodBuilder(RestfulApiMethod.Create)
+ .MatchPrefix("Post", "Create", "Add")
+ .MatchParameterType(^1, IBaseRequest);
+ yield return new RestfulApiMethodBuilder(RestfulApiMethod.Create)
+ .MatchPrefix("Post", "Create", "Add")
+ .MatchParameterType(^1, IStreamRequest);
+ yield return new RestfulApiMethodBuilder(RestfulApiMethod.Update)
+ .MatchPrefix("Put", "Edit", "Update")
+ .MatchParameterSuffix(^2, "id")
+ .MatchParameterType(^1, IBaseRequest);
+ yield return new RestfulApiMethodBuilder(RestfulApiMethod.Update)
+ .MatchPrefix("Put", "Edit", "Update")
+ .MatchParameterSuffix(^2, "id")
+ .MatchParameterType(^1, IStreamRequest);
+ yield return new RestfulApiMethodBuilder(RestfulApiMethod.Update)
+ .MatchPrefix("Put", "Edit", "Update")
+ .MatchParameterSuffix(^2, "id")
+ .MatchParameterSuffix(^1, "model", "request");
+ yield return new RestfulApiMethodBuilder(RestfulApiMethod.Delete)
+ .MatchPrefix("Delete", "Remove")
+ .MatchParameterType(^1, IBaseRequest);
+ yield return new RestfulApiMethodBuilder(RestfulApiMethod.Delete)
+ .MatchPrefix("Delete", "Remove")
+ .MatchParameterType(^1, IStreamRequest);
+ yield return new RestfulApiMethodBuilder(RestfulApiMethod.Delete)
+ .MatchPrefix("Delete", "Remove")
+ .MatchParameterSuffix(^1, "id");
+ }
+}
diff --git a/src/Analyzers/Composition/RestfulApiMethod.cs b/src/Analyzers/Composition/RestfulApiMethod.cs
new file mode 100644
index 000000000..214901296
--- /dev/null
+++ b/src/Analyzers/Composition/RestfulApiMethod.cs
@@ -0,0 +1,32 @@
+namespace Rocket.Surgery.LaunchPad.Analyzers.Composition;
+
+///
+/// Restful api method types
+///
+internal enum RestfulApiMethod
+{
+ ///
+ /// This method returns a list or paged list
+ ///
+ List,
+
+ ///
+ /// This method creates a new item
+ ///
+ Create,
+
+ ///
+ /// This method returns a single item
+ ///
+ Read,
+
+ ///
+ /// This method updates a single item
+ ///
+ Update,
+
+ ///
+ /// This method removes an item
+ ///
+ Delete
+}
diff --git a/src/Analyzers/Composition/RestfulApiMethodBuilder.cs b/src/Analyzers/Composition/RestfulApiMethodBuilder.cs
new file mode 100644
index 000000000..5ea2fc637
--- /dev/null
+++ b/src/Analyzers/Composition/RestfulApiMethodBuilder.cs
@@ -0,0 +1,274 @@
+using Microsoft.CodeAnalysis;
+
+namespace Rocket.Surgery.LaunchPad.Analyzers.Composition;
+
+///
+/// This class allows you to define methods default conventions when using the RestfulApiController
+/// This allows the response codes to be automatically inferred based on the rules defined.
+///
+internal class RestfulApiMethodBuilder : IRestfulApiMethodMatcher
+{
+ private static RestfulApiParameterMatcher DefaultMatcher(Index index)
+ {
+ return new RestfulApiParameterMatcher(
+ index,
+ ApiConventionNameMatchBehavior.Any,
+ Array.Empty(),
+ ApiConventionTypeMatchBehavior.Any,
+ null
+ );
+ }
+
+ private readonly RestfulApiMethod _method;
+
+ private readonly IDictionary _parameters = new Dictionary();
+ private int? _parameterCount;
+ private ApiConventionNameMatchBehavior _nameMatchBehavior = ApiConventionNameMatchBehavior.Any;
+ private string[] _names = Array.Empty();
+
+ ///
+ /// Create a method build for the given .
+ ///
+ ///
+ public RestfulApiMethodBuilder(RestfulApiMethod method)
+ {
+ _method = method;
+ }
+
+ ///
+ /// Match against one of the given suffixes
+ ///
+ /// The first suffix
+ /// Any additional suffixes
+ ///
+ public RestfulApiMethodBuilder MatchSuffix(string value, params string[] values)
+ {
+ _names = new[] { value }.Concat(values).ToArray();
+ _nameMatchBehavior = ApiConventionNameMatchBehavior.Suffix;
+
+ return this;
+ }
+
+ ///
+ /// Match against one of the given prefixes
+ ///
+ /// The first prefix
+ /// Any additional prefixes
+ ///
+ public RestfulApiMethodBuilder MatchPrefix(string value, params string[] values)
+ {
+ _names = new[] { value }.Concat(values).ToArray();
+ _nameMatchBehavior = ApiConventionNameMatchBehavior.Prefix;
+
+ return this;
+ }
+
+ ///
+ /// Match based one of the given method names
+ ///
+ /// The first name
+ /// Any additional names
+ ///
+ public RestfulApiMethodBuilder MatchName(string value, params string[] values)
+ {
+ _names = new[] { value }.Concat(values).ToArray();
+ _nameMatchBehavior = ApiConventionNameMatchBehavior.Exact;
+
+ return this;
+ }
+
+ ///
+ /// Matched on the names of the parameter at the given index
+ ///
+ ///
+ /// The index can be either positive or negative to allow for comparing against first or last parameters
+ ///
+ /// The parameter, maybe a positive or negative
+ /// The first name
+ /// Any additional names
+ ///
+ public RestfulApiMethodBuilder MatchParameterName(Index parameter, string value, params string[] values)
+ {
+ if (!_parameters.TryGetValue(parameter, out var item))
+ {
+ item = _parameters[parameter] = DefaultMatcher(parameter);
+ }
+
+ _parameters[parameter] = new RestfulApiParameterMatcher(
+ parameter,
+ ApiConventionNameMatchBehavior.Exact,
+ new[] { value }.Concat(values).ToArray(),
+ item.TypeMatch,
+ item.Type
+ );
+
+ return this;
+ }
+
+ ///
+ /// Match only if a parameter exists at the given index
+ ///
+ ///
+ /// The index can be either positive or negative to allow for comparing against first or last parameters
+ ///
+ /// The parameter, maybe a positive or negative
+ ///
+ public RestfulApiMethodBuilder HasParameter(Index parameter)
+ {
+ if (!_parameters.TryGetValue(parameter, out var item))
+ {
+ item = _parameters[parameter] = DefaultMatcher(parameter);
+ }
+
+ _parameters[parameter] =
+ new RestfulApiParameterMatcher(
+ parameter,
+ item.NameMatch,
+ item.Names.Length > 0 ? item.Names : Array.Empty(),
+ item.TypeMatch,
+ item.Type
+ );
+
+ return this;
+ }
+
+ ///
+ /// Match only if there are a given number of parameters
+ ///
+ ///
+ ///
+ public RestfulApiMethodBuilder MatchParameterCount(int count)
+ {
+ _parameterCount = count;
+ return this;
+ }
+
+ ///
+ /// Match against the prefixes for the given parameter at the given index
+ ///
+ ///
+ /// The index can be either positive or negative to allow for comparing against first or last parameters
+ ///
+ /// The parameter, maybe a positive or negative
+ /// The first prefix
+ /// Any additional prefixes
+ ///
+ public RestfulApiMethodBuilder MatchParameterPrefix(Index parameter, string value, params string[] values)
+ {
+ if (!_parameters.TryGetValue(parameter, out var item))
+ {
+ item = _parameters[parameter] = DefaultMatcher(parameter);
+ }
+
+ _parameters[parameter] = new RestfulApiParameterMatcher(
+ parameter,
+ ApiConventionNameMatchBehavior.Prefix,
+ new[] { value }.Concat(values).ToArray(),
+ item.TypeMatch,
+ item.Type
+ );
+
+ return this;
+ }
+
+ ///
+ /// Match against the suffixes for the given parameter at the given index
+ ///
+ ///
+ /// The index can be either positive or negative to allow for comparing against first or last parameters
+ ///
+ /// The parameter, maybe a positive or negative
+ /// The first suffix
+ /// Any additional suffixes
+ ///
+ public RestfulApiMethodBuilder MatchParameterSuffix(Index parameter, string value, params string[] values)
+ {
+ if (!_parameters.TryGetValue(parameter, out var item))
+ {
+ item = _parameters[parameter] = DefaultMatcher(parameter);
+ }
+
+ _parameters[parameter] = new RestfulApiParameterMatcher(
+ parameter,
+ ApiConventionNameMatchBehavior.Suffix,
+ new[] { value }.Concat(values).ToArray(),
+ item.TypeMatch,
+ item.Type
+ );
+
+ return this;
+ }
+
+ ///
+ /// Match the type parameter at the given index.
+ ///
+ ///
+ /// The index can be either positive or negative to allow for comparing against first or last parameters
+ ///
+ /// The parameter, maybe a positive or negative
+ /// The type of the parameter
+ ///
+ public RestfulApiMethodBuilder MatchParameterType(Index parameter, INamedTypeSymbol type)
+ {
+ if (!_parameters.TryGetValue(parameter, out var item))
+ {
+ item = _parameters[parameter] = DefaultMatcher(parameter);
+ }
+
+ _parameters[parameter] = new RestfulApiParameterMatcher(
+ parameter,
+ item.NameMatch,
+ item.Names,
+ ApiConventionTypeMatchBehavior.AssignableFrom,
+ type
+ );
+
+ return this;
+ }
+
+ internal bool IsValid()
+ {
+ return _names.Length > 0;
+ }
+
+ internal bool IsMatch(ActionModel actionModel)
+ {
+ var nameMatch = _nameMatchBehavior switch
+ {
+ ApiConventionNameMatchBehavior.Exact => _names.Any(name => actionModel.ActionName.Equals(name, StringComparison.OrdinalIgnoreCase)),
+ ApiConventionNameMatchBehavior.Prefix => _names.Any(name => actionModel.ActionName.StartsWith(name, StringComparison.OrdinalIgnoreCase)),
+ ApiConventionNameMatchBehavior.Suffix => _names.Any(name => actionModel.ActionName.EndsWith(name, StringComparison.OrdinalIgnoreCase)),
+ _ => true
+ };
+
+ if (!nameMatch)
+ return false;
+
+ var parameters = actionModel.ActionMethod.Parameters;
+ if (_parameterCount.HasValue && parameters.Length != _parameterCount.Value)
+ {
+ return false;
+ }
+
+ if (parameters.Length >= _parameters.Count)
+ {
+ return _parameters.Values.All(z => z.IsMatch(actionModel));
+ }
+
+ return false;
+ }
+
+ RestfulApiMethod IRestfulApiMethodMatcher.Method => _method;
+ ApiConventionNameMatchBehavior IRestfulApiMethodMatcher.NameMatch => _nameMatchBehavior;
+ string[] IRestfulApiMethodMatcher.Names => _names;
+ IDictionary IRestfulApiMethodMatcher.Parameters => _parameters;
+
+ bool IRestfulApiMethodMatcher.IsMatch(ActionModel actionModel)
+ {
+ return IsMatch(actionModel);
+ }
+}
+
+internal record ActionModel(Compilation Compilation, string ActionName, IMethodSymbol ActionMethod)
+{
+}
diff --git a/src/Analyzers/Composition/RestfulApiParameterMatcher.cs b/src/Analyzers/Composition/RestfulApiParameterMatcher.cs
new file mode 100644
index 000000000..93ed8eda1
--- /dev/null
+++ b/src/Analyzers/Composition/RestfulApiParameterMatcher.cs
@@ -0,0 +1,82 @@
+using Microsoft.CodeAnalysis;
+
+#pragma warning disable 1591
+#pragma warning disable CA1801
+
+namespace Rocket.Surgery.LaunchPad.Analyzers.Composition;
+
+internal class RestfulApiParameterMatcher : IRestfulApiParameterMatcher
+{
+ public RestfulApiParameterMatcher(
+ Index parameterIndex,
+ ApiConventionNameMatchBehavior nameMatch,
+ string[] names,
+ ApiConventionTypeMatchBehavior typeMatch,
+ INamedTypeSymbol? type
+ )
+ {
+ ParameterIndex = parameterIndex;
+ NameMatch = nameMatch;
+ Names = names;
+ TypeMatch = typeMatch;
+ Type = type;
+ }
+
+ public Index ParameterIndex { get; }
+ public ApiConventionNameMatchBehavior NameMatch { get; }
+ public string[] Names { get; }
+ public ApiConventionTypeMatchBehavior TypeMatch { get; }
+ public INamedTypeSymbol? Type { get; }
+
+ public bool IsMatch(ActionModel actionModel)
+ {
+ var parameters = actionModel.ActionMethod.Parameters;
+ var offset = ParameterIndex.GetOffset(parameters.Length);
+ if (offset >= 0 && offset < parameters.Length)
+ {
+ var parameter = parameters[ParameterIndex];
+ if (TypeMatch == ApiConventionTypeMatchBehavior.AssignableFrom && Type != null)
+ {
+ if (Type.IsGenericType)
+ {
+ static bool CheckType(ITypeSymbol symbol, INamedTypeSymbol type)
+ {
+ if (symbol is not INamedTypeSymbol namedTypeSymbol)
+ {
+ return false;
+ }
+
+ return namedTypeSymbol.IsGenericType && SymbolEqualityComparer.Default.Equals(namedTypeSymbol.OriginalDefinition, type);
+ }
+
+ var parameterIs = CheckType(parameter.Type, Type);
+ if (!parameterIs)
+ {
+ parameterIs = parameter.Type.AllInterfaces.Any(inter => CheckType(inter, Type));
+ }
+
+ if (!parameterIs)
+ {
+ return false;
+ }
+ }
+ else if (!actionModel.Compilation.HasImplicitConversion(parameter.Type, Type))
+ {
+ return false;
+ }
+ }
+
+ return NameMatch switch
+ {
+ ApiConventionNameMatchBehavior.Exact => Names.Any(name => parameter.Name!.Equals(name, StringComparison.OrdinalIgnoreCase)),
+ ApiConventionNameMatchBehavior.Prefix => Names.Any(name => parameter.Name!.StartsWith(name, StringComparison.OrdinalIgnoreCase)),
+ ApiConventionNameMatchBehavior.Suffix => Names.Any(name => parameter.Name!.EndsWith(name, StringComparison.OrdinalIgnoreCase)),
+ _ => true
+ };
+ }
+
+ return false;
+ }
+}
+
+// IApiDescriptionProvider
diff --git a/src/Analyzers/Composition/StatusCodes.cs b/src/Analyzers/Composition/StatusCodes.cs
new file mode 100644
index 000000000..d615a6a21
--- /dev/null
+++ b/src/Analyzers/Composition/StatusCodes.cs
@@ -0,0 +1,80 @@
+namespace Rocket.Surgery.LaunchPad.Analyzers.Composition;
+
+// ReSharper disable InconsistentNaming
+// ReSharper disable IdentifierTypo
+#pragma warning disable CS1591
+[PublicAPI]
+internal static class StatusCodes
+{
+ public const int Status100Continue = 100;
+ public const int Status101SwitchingProtocols = 101;
+ public const int Status102Processing = 102;
+
+ public const int Status200OK = 200;
+ public const int Status201Created = 201;
+ public const int Status202Accepted = 202;
+ public const int Status203NonAuthoritative = 203;
+ public const int Status204NoContent = 204;
+ public const int Status205ResetContent = 205;
+ public const int Status206PartialContent = 206;
+ public const int Status207MultiStatus = 207;
+ public const int Status208AlreadyReported = 208;
+ public const int Status226IMUsed = 226;
+
+ public const int Status300MultipleChoices = 300;
+ public const int Status301MovedPermanently = 301;
+ public const int Status302Found = 302;
+ public const int Status303SeeOther = 303;
+ public const int Status304NotModified = 304;
+ public const int Status305UseProxy = 305;
+ public const int Status306SwitchProxy = 306; // RFC 2616, removed
+ public const int Status307TemporaryRedirect = 307;
+ public const int Status308PermanentRedirect = 308;
+
+ public const int Status400BadRequest = 400;
+ public const int Status401Unauthorized = 401;
+ public const int Status402PaymentRequired = 402;
+ public const int Status403Forbidden = 403;
+ public const int Status404NotFound = 404;
+ public const int Status405MethodNotAllowed = 405;
+ public const int Status406NotAcceptable = 406;
+ public const int Status407ProxyAuthenticationRequired = 407;
+ public const int Status408RequestTimeout = 408;
+ public const int Status409Conflict = 409;
+ public const int Status410Gone = 410;
+ public const int Status411LengthRequired = 411;
+ public const int Status412PreconditionFailed = 412;
+ public const int Status413RequestEntityTooLarge = 413; // RFC 2616, renamed
+ public const int Status413PayloadTooLarge = 413; // RFC 7231
+ public const int Status414RequestUriTooLong = 414; // RFC 2616, renamed
+ public const int Status414UriTooLong = 414; // RFC 7231
+ public const int Status415UnsupportedMediaType = 415;
+ public const int Status416RequestedRangeNotSatisfiable = 416; // RFC 2616, renamed
+ public const int Status416RangeNotSatisfiable = 416; // RFC 7233
+ public const int Status417ExpectationFailed = 417;
+ public const int Status418ImATeapot = 418;
+ public const int Status419AuthenticationTimeout = 419; // Not defined in any RFC
+ public const int Status421MisdirectedRequest = 421;
+ public const int Status422UnprocessableEntity = 422;
+ public const int Status423Locked = 423;
+ public const int Status424FailedDependency = 424;
+ public const int Status426UpgradeRequired = 426;
+ public const int Status428PreconditionRequired = 428;
+ public const int Status429TooManyRequests = 429;
+ public const int Status431RequestHeaderFieldsTooLarge = 431;
+ public const int Status451UnavailableForLegalReasons = 451;
+
+ public const int Status500InternalServerError = 500;
+ public const int Status501NotImplemented = 501;
+ public const int Status502BadGateway = 502;
+ public const int Status503ServiceUnavailable = 503;
+ public const int Status504GatewayTimeout = 504;
+ public const int Status505HttpVersionNotsupported = 505;
+ public const int Status506VariantAlsoNegotiates = 506;
+ public const int Status507InsufficientStorage = 507;
+ public const int Status508LoopDetected = 508;
+ public const int Status510NotExtended = 510;
+ public const int Status511NetworkAuthenticationRequired = 511;
+}
+
+#pragma warning restore CS1591
diff --git a/src/Analyzers/ControllerActionBodyGenerator.cs b/src/Analyzers/ControllerActionBodyGenerator.cs
new file mode 100644
index 000000000..e0e03799e
--- /dev/null
+++ b/src/Analyzers/ControllerActionBodyGenerator.cs
@@ -0,0 +1,741 @@
+using System.Collections.Immutable;
+using System.Globalization;
+using System.Text;
+using System.Text.RegularExpressions;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Rocket.Surgery.LaunchPad.Analyzers.Composition;
+using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory;
+
+namespace Rocket.Surgery.LaunchPad.Analyzers;
+
+///
+/// Generator to automatically implement partial methods for controllers to call mediatr methods
+///
+[Generator]
+public class ControllerActionBodyGenerator : IIncrementalGenerator
+{
+ ///
+ /// Same as Pascalize except that the first character is lower case
+ ///
+ ///
+ ///
+ private static string Camelize(string input)
+ {
+ var word = Pascalize(input);
+#pragma warning disable CA1308
+ return word.Length > 0 ? string.Concat(word.Substring(0, 1).ToLower(CultureInfo.InvariantCulture), word.Substring(1)) : word;
+#pragma warning restore CA1308
+ }
+
+ ///
+ /// By default, pascalize converts strings to UpperCamelCase also removing underscores
+ ///
+ ///
+ ///
+ private static string Pascalize(string input)
+ {
+ return Regex.Replace(input, "(?:^|_| +)(.)", match => match.Groups[1].Value.ToUpper(CultureInfo.InvariantCulture));
+ }
+
+ private static MethodDeclarationSyntax? GenerateMethod(
+ SourceProductionContext context,
+ IRestfulApiMethodMatcher? matcher,
+ IReadOnlyDictionary statusCodeMap,
+ MethodDeclarationSyntax syntax,
+ IMethodSymbol symbol,
+ IParameterSymbol parameter,
+ ImmutableArray<(MethodDeclarationSyntax method, IMethodSymbol symbol, IRestfulApiMethodMatcher? matcher, IParameterSymbol? request)> members
+ )
+ {
+ var otherParams = symbol.Parameters.Remove(parameter);
+ var parameterType = (INamedTypeSymbol)parameter.Type;
+ var isUnit = parameterType.AllInterfaces.Any(z => z.MetadataName == "IRequest");
+ var isUnitResult = symbol.ReturnType is INamedTypeSymbol { Arity: 1 } nts && nts.TypeArguments[0].MetadataName == "ActionResult";
+ var isRecord = parameterType.IsRecord;
+ var isStream = symbol.ReturnType.MetadataName == "IAsyncEnumerable`1";
+
+ var newSyntax = syntax
+ .WithParameterList(
+ syntax.ParameterList.RemoveNodes(
+ syntax.ParameterList.Parameters.SelectMany(z => z.AttributeLists), SyntaxRemoveOptions.KeepNoTrivia
+ )!
+ )
+ .WithAttributeLists(List())
+ ;
+
+ if (!isStream)
+ {
+ newSyntax = newSyntax
+ .AddModifiers(Token(SyntaxKind.AsyncKeyword));
+ }
+
+ var block = Block();
+ var resultName = parameter.Name == "result" ? "r" : "result";
+ var sendRequestExpression = isStream ? streamMediatorRequest(IdentifierName(parameter.Name)) : sendMediatorRequest(IdentifierName(parameter.Name));
+
+ if (otherParams.Length > 0)
+ {
+ var declaredParam = newSyntax.ParameterList.Parameters.Single(z => z.Identifier.Text == parameter.Name);
+ var parameterProperties = parameterType.MemberNames
+ .Join(
+ otherParams, z => z, z => z.Name, (memberName, symbol) => ( memberName, symbol ),
+ StringComparer.OrdinalIgnoreCase
+ )
+ .ToArray();
+
+ var mapping = parameterType.MemberNames.ToLookup(z => z, StringComparer.OrdinalIgnoreCase);
+ var failed = false;
+ foreach (var param in otherParams)
+ {
+ if (!mapping[param.Name].Any())
+ {
+ context.ReportDiagnostic(
+ Diagnostic.Create(
+ GeneratorDiagnostics.ParameterMustBeAPropertyOfTheRequest,
+ param.Locations.First(),
+ param.Locations.Skip(1),
+ param.Name,
+ parameterType.Name
+ )
+ );
+ failed = true;
+ }
+ else if (parameterType.GetMembers(mapping[param.Name].First()).First() is IPropertySymbol property)
+ {
+ if (!SymbolEqualityComparer.IncludeNullability.Equals(property.Type, param.Type))
+ {
+ context.ReportDiagnostic(
+ Diagnostic.Create(
+ GeneratorDiagnostics.ParameterMustBeSameTypeAsTheRelatedProperty,
+ param.Locations.First(),
+ param.Locations.Skip(1),
+ param.Name,
+ param.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat),
+ parameterType.Name,
+ property.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)
+ )
+ );
+ failed = true;
+ }
+ }
+ }
+
+ if (failed)
+ {
+ return null;
+ }
+
+ var bindingMembers = parameterType.GetMembers()
+ .Where(z => z is IPropertySymbol { IsImplicitlyDeclared: false } ps && ps.Name != "EqualityContract")
+ .Select(z => z.Name)
+ .Except(parameterProperties.Select(z => z.memberName))
+ .ToArray();
+
+ var newParam = declaredParam.AddAttributeLists(
+ AttributeList(
+ SeparatedList(
+ new[]
+ {
+ Attribute(
+ IdentifierName("Bind"), AttributeArgumentList(
+ SeparatedList(
+ bindingMembers
+ .Select(z => AttributeArgument(LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(z))))
+ )
+ )
+ ),
+ Attribute(
+ IdentifierName("CustomizeValidator")
+ )
+ .WithArgumentList(
+ AttributeArgumentList(
+ SingletonSeparatedList(
+ AttributeArgument(
+ LiteralExpression(
+ SyntaxKind.StringLiteralExpression,
+ Literal(string.Join(",", bindingMembers))
+ )
+ )
+ .WithNameEquals(
+ NameEquals(
+ IdentifierName("Properties")
+ )
+ )
+ )
+ )
+ )
+ }
+ )
+ )
+ );
+
+ newSyntax = newSyntax.WithParameterList(newSyntax.ParameterList.ReplaceNode(declaredParam, newParam));
+
+
+ if (parameterType.IsRecord)
+ {
+ var withExpression = WithExpression(
+ IdentifierName(parameter.Name),
+ InitializerExpression(
+ SyntaxKind.WithInitializerExpression,
+ SeparatedList(
+ parameterProperties.Select(
+ tuple => AssignmentExpression(
+ SyntaxKind.SimpleAssignmentExpression,
+ IdentifierName(tuple.memberName),
+ IdentifierName(tuple.symbol.Name)
+ )
+ )
+ )
+ )
+ );
+ sendRequestExpression = isStream ? streamMediatorRequest(withExpression) : sendMediatorRequest(withExpression);
+ }
+ else
+ {
+ foreach (var (memberName, s) in parameterProperties)
+ {
+ block = block.AddStatements(
+ ExpressionStatement(
+ AssignmentExpression(
+ SyntaxKind.SimpleAssignmentExpression,
+ MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ IdentifierName(parameter.Name),
+ IdentifierName(memberName)
+ ),
+ IdentifierName(s.Name)
+ )
+ )
+ );
+ }
+ }
+ }
+
+ if (isUnit)
+ {
+ block = block.AddStatements(ExpressionStatement(sendRequestExpression));
+ }
+ else
+ {
+ block = block
+ .AddStatements(
+ LocalDeclarationStatement(
+ VariableDeclaration(IdentifierName(Identifier(TriviaList(), SyntaxKind.VarKeyword, "var", "var", TriviaList())))
+ .WithVariables(
+ SingletonSeparatedList(VariableDeclarator(Identifier(resultName)).WithInitializer(EqualsValueClause(sendRequestExpression)))
+ )
+ )
+ );
+ }
+
+ var knownStatusCodes = symbol.GetAttributes()
+ .Where(z => z.AttributeClass?.Name == "ProducesResponseTypeAttribute")
+ .SelectMany(
+ z =>
+ {
+ var namedArguments = z.NamedArguments
+ .Where(
+ x => x is
+ {
+ Key: "StatusCode", Value: { Kind: TypedConstantKind.Primitive, Value: int }
+ }
+ )
+ .Select(z => (int)z.Value.Value!);
+ var constructorArguments = z
+ .ConstructorArguments.Where(z => z is { Kind: TypedConstantKind.Primitive, Value: int })
+ .Select(z => (int)z.Value!);
+
+ return namedArguments.Concat(constructorArguments);
+ }
+ )
+ .ToImmutableHashSet();
+
+ var hasSuccess = knownStatusCodes.Any(z => z is >= 200 and < 300);
+ var hasDefault = symbol.GetAttribute("ProducesDefaultResponseTypeAttribute") is { };
+
+ {
+ var attributes = parameter.GetAttributes().ToLookup(z => z.AttributeClass?.Name ?? "");
+ var declaredParam = newSyntax.ParameterList.Parameters.Single(z => z.Identifier.Text == parameter.Name);
+ var newParam = declaredParam;
+
+ if (matcher is { })
+ {
+ switch (matcher)
+ {
+ case { Method: RestfulApiMethod.List }:
+ {
+ if (!attributes["FromQueryAttribute"].Any()) newParam = newParam.AddAttributeLists(createSimpleAttribute("FromQuery"));
+ break;
+ }
+
+ case { Method: RestfulApiMethod.Update }:
+ case { Method: RestfulApiMethod.Create }:
+ {
+ if (!attributes["BindRequiredAttribute"].Any()) newParam = newParam.AddAttributeLists(createSimpleAttribute("BindRequired"));
+ if (!attributes["FromBodyAttribute"].Any()) newParam = newParam.AddAttributeLists(createSimpleAttribute("FromBody"));
+ break;
+ }
+
+ case { Method: RestfulApiMethod.Read }:
+ case { Method: RestfulApiMethod.Delete }:
+ {
+ if (!attributes["BindRequiredAttribute"].Any()) newParam = newParam.AddAttributeLists(createSimpleAttribute("BindRequired"));
+ if (!attributes["FromRouteAttribute"].Any()) newParam = newParam.AddAttributeLists(createSimpleAttribute("FromRoute"));
+ break;
+ }
+ }
+ }
+
+ newSyntax = newSyntax.WithParameterList(newSyntax.ParameterList.ReplaceNode(declaredParam, newParam));
+ }
+
+ if (!hasDefault)
+ {
+ newSyntax = newSyntax.AddAttributeLists(createSimpleAttribute("ProducesDefaultResponseType"));
+ }
+
+ var statusCode = hasSuccess ? knownStatusCodes.First(z => z is >= 200 and < 300) : StatusCodes.Status200OK;
+
+ if (!hasSuccess)
+ {
+ if (isUnitResult)
+ {
+ statusCode = StatusCodes.Status204NoContent;
+ }
+ else if (matcher is { })
+ {
+ statusCode = statusCodeMap[matcher.Method];
+ }
+
+ newSyntax = newSyntax.AddAttributeLists(produces(statusCode));
+ }
+
+ if (!knownStatusCodes.Contains(StatusCodes.Status404NotFound) && matcher?.Method != RestfulApiMethod.List)
+ {
+ newSyntax = newSyntax.AddAttributeLists(produces(StatusCodes.Status404NotFound, "ProblemDetails"));
+ }
+
+ if (!knownStatusCodes.Contains(StatusCodes.Status400BadRequest))
+ {
+ newSyntax = newSyntax.AddAttributeLists(produces(StatusCodes.Status400BadRequest, "ProblemDetails"));
+ }
+
+ if (!knownStatusCodes.Contains(StatusCodes.Status422UnprocessableEntity))
+ {
+ newSyntax = newSyntax.AddAttributeLists(
+ produces(StatusCodes.Status422UnprocessableEntity, "FluentValidationProblemDetails")
+ );
+ }
+
+
+ if (isStream)
+ {
+ block = block.AddStatements(ReturnStatement(IdentifierName(resultName)));
+ }
+ else
+ {
+ if (symbol.GetAttribute("CreatedAttribute") is { ConstructorArguments: { Length: 1 } } created)
+ {
+ var actionName = (string)created.ConstructorArguments[0].Value!;
+ var relatedMember = members.Single(z => z.symbol.Name == actionName);
+ var routeValues = getRouteValues(parameterType, resultName, relatedMember);
+
+ block = block.AddStatements(
+ ReturnStatement(routeResult("CreatedAtActionResult", resultName, actionName, routeValues))
+ );
+ }
+ else if (symbol.GetAttribute("AcceptedAttribute") is { ConstructorArguments: { Length: 1 } } accepted)
+ {
+ var actionName = (string)accepted.ConstructorArguments[0].Value!;
+ var relatedMember = members.Single(z => z.symbol.Name == actionName);
+ var routeValues = getRouteValues(parameterType, resultName, relatedMember);
+
+ block = block.AddStatements(
+ ReturnStatement(
+ routeResult("AcceptedAtActionResult", resultName, actionName, routeValues)
+ )
+ );
+ }
+ else
+ {
+ block = block.AddStatements(ReturnStatement(isUnitResult ? statusCodeResult(statusCode) : objectResult(resultName, statusCode)));
+ }
+ }
+
+ return newSyntax
+ .WithBody(block.NormalizeWhitespace())
+ .WithSemicolonToken(Token(SyntaxKind.None));
+
+ static ImmutableArray availableRouteParameters(
+ (MethodDeclarationSyntax method, IMethodSymbol symbol, IRestfulApiMethodMatcher? matcher, IParameterSymbol? request) relatedMember
+ )
+ {
+ var parameterNames = relatedMember.symbol.Parameters.Remove(relatedMember.request).Select(z => z.Name);
+ parameterNames = parameterNames.Concat(
+ relatedMember.request?.Type.GetMembers()
+ .Where(z => z is IPropertySymbol { IsImplicitlyDeclared: false } ps && ps.Name != "EqualityContract")
+ .Select(z => z.Name) ?? Enumerable.Empty()
+ );
+ return parameterNames.Select(Camelize).Distinct().ToImmutableArray();
+ }
+
+ static ExpressionSyntax getRouteValues(
+ INamedTypeSymbol parameterType,
+ string resultName,
+ (MethodDeclarationSyntax method, IMethodSymbol symbol, IRestfulApiMethodMatcher? matcher, IParameterSymbol? request) relatedMember
+ )
+ {
+ var parameterNames = availableRouteParameters(relatedMember);
+ var response = parameterType.AllInterfaces.First(z => z is { Name: "IRequest", Arity: 1 }).TypeArguments[0];
+
+ var properties = response.GetMembers().Select(z => z.Name);
+ var routeValues = parameterNames.Join(
+ properties,
+ z => z,
+ z => z,
+ (right, left) => AnonymousObjectMemberDeclarator(
+ MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ IdentifierName(resultName),
+ IdentifierName(left)
+ )
+ )
+ .WithNameEquals(
+ NameEquals(
+ IdentifierName(right)
+ )
+ ),
+ StringComparer.OrdinalIgnoreCase
+ );
+ return AnonymousObjectCreationExpression(SeparatedList(routeValues));
+ }
+
+ static ExpressionSyntax objectResult(string contentName, int statusCode)
+ {
+ return ObjectCreationExpression(IdentifierName("ObjectResult"))
+ .WithArgumentList(ArgumentList(SingletonSeparatedList(Argument(IdentifierName(contentName)))))
+ .WithInitializer(
+ InitializerExpression(
+ SyntaxKind.ObjectInitializerExpression,
+ SingletonSeparatedList(
+ AssignmentExpression(
+ SyntaxKind.SimpleAssignmentExpression,
+ IdentifierName("StatusCode"),
+ LiteralExpression(
+ SyntaxKind.NumericLiteralExpression,
+ Literal(statusCode)
+ )
+ )
+ )
+ )
+ );
+ }
+
+ static ExpressionSyntax routeResult(
+ string resultType, string resultName, string actionName, ExpressionSyntax routeValues, string? controllerName = null
+ )
+ {
+ return ObjectCreationExpression(IdentifierName(resultType))
+ .WithArgumentList(
+ ArgumentList(
+ SeparatedList(
+ new[]
+ {
+ Argument(
+ LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(actionName))
+ ),
+ Argument(
+ controllerName is null
+ ? LiteralExpression(SyntaxKind.NullLiteralExpression)
+ : LiteralExpression(SyntaxKind.StringLiteralExpression, Literal(controllerName))
+ ),
+ Argument(routeValues),
+ Argument(IdentifierName(resultName))
+ }
+ )
+ )
+ );
+ }
+
+ static ExpressionSyntax statusCodeResult(int statusCode)
+ {
+ return ObjectCreationExpression(IdentifierName("StatusCodeResult"))
+ .WithArgumentList(
+ ArgumentList(
+ SingletonSeparatedList(
+ Argument(
+ LiteralExpression(
+ SyntaxKind.NumericLiteralExpression,
+ Literal(statusCode)
+ )
+ )
+ )
+ )
+ );
+ }
+
+ static AttributeListSyntax produces(int statusCode, string? responseType = null)
+ {
+ if (responseType is { })
+ {
+ return AttributeList(
+ SingletonSeparatedList(
+ Attribute(
+ IdentifierName("ProducesResponseType"),
+ AttributeArgumentList(
+ SeparatedList(
+ new[]
+ {
+ AttributeArgument(TypeOfExpression(ParseName(responseType))),
+ AttributeArgument(LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal(statusCode)))
+ }
+ )
+ )
+ )
+ )
+ );
+ }
+
+ return AttributeList(
+ SingletonSeparatedList(
+ Attribute(
+ IdentifierName("ProducesResponseType"),
+ AttributeArgumentList(
+ SingletonSeparatedList(
+ AttributeArgument(LiteralExpression(SyntaxKind.NumericLiteralExpression, Literal(statusCode)))
+ )
+ )
+ )
+ )
+ );
+ }
+
+ static AttributeListSyntax createSimpleAttribute(string name)
+ {
+ return AttributeList(
+ SingletonSeparatedList(
+ Attribute(
+ IdentifierName(name)
+ )
+ )
+ );
+ }
+
+
+ static ExpressionSyntax sendMediatorRequest(ExpressionSyntax nameSyntax)
+ {
+ return AwaitExpression(
+ InvocationExpression(
+ MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ InvocationExpression(
+ MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ IdentifierName("Mediator"),
+ IdentifierName("Send")
+ )
+ )
+ .WithArgumentList(
+ ArgumentList(
+ SeparatedList(
+ new[]
+ {
+ Argument(nameSyntax),
+ Argument(
+ MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ IdentifierName("HttpContext"),
+ IdentifierName("RequestAborted")
+ )
+ )
+ }
+ )
+ )
+ ),
+ IdentifierName("ConfigureAwait")
+ )
+ )
+ .WithArgumentList(
+ ArgumentList(
+ SingletonSeparatedList(
+ Argument(
+ LiteralExpression(
+ SyntaxKind.FalseLiteralExpression
+ )
+ )
+ )
+ )
+ )
+ );
+ }
+
+
+ static ExpressionSyntax streamMediatorRequest(ExpressionSyntax nameSyntax)
+ {
+ return InvocationExpression(
+ MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ IdentifierName("Mediator"),
+ IdentifierName("CreateStream")
+ )
+ )
+ .WithArgumentList(
+ ArgumentList(
+ SeparatedList(
+ new[]
+ {
+ Argument(nameSyntax),
+ Argument(
+ MemberAccessExpression(
+ SyntaxKind.SimpleMemberAccessExpression,
+ IdentifierName("HttpContext"),
+ IdentifierName("RequestAborted")
+ )
+ )
+ }
+ )
+ )
+ );
+ }
+ }
+
+ private void GenerateMethods(
+ SourceProductionContext context,
+ ((ClassDeclarationSyntax syntax, INamedTypeSymbol symbol, SemanticModel semanticModel) Left, Compilation Right) valueTuple
+ )
+ {
+ var (syntax, symbol, semanticModel) = valueTuple.Left;
+ var compilation = valueTuple.Right;
+ var matchers = MatcherDefaults.GetMatchers(compilation);
+ var members = syntax.Members
+ .OfType()
+ .Where(z => z.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
+ .Select(
+ method =>
+ {
+ var methodSymbol = semanticModel.GetDeclaredSymbol(method);
+ // ReSharper disable once UseNullPropagationWhenPossible
+ if (methodSymbol is null) return default;
+
+ var actionModel = new ActionModel(compilation, methodSymbol.Name, methodSymbol);
+ var matcher = matchers.FirstOrDefault(z => z.IsMatch(actionModel));
+ var request = methodSymbol.Parameters.FirstOrDefault(p => p.Type.AllInterfaces.Any(i => i.MetadataName == "IRequest`1"));
+ var streamRequest = methodSymbol.Parameters.FirstOrDefault(
+ p => p.Type.AllInterfaces.Any(i => i.MetadataName == "IStreamRequest`1")
+ );
+ return ( method, symbol: methodSymbol!, matcher, request: request ?? streamRequest );
+ }
+ )
+ .Where(z => z is { symbol: { }, method: { } })
+ .ToImmutableArray();
+
+ var newClass = syntax.WithMembers(List())
+ .WithConstraintClauses(List())
+ .WithAttributeLists(List())
+ .WithBaseList(null)
+ ;
+
+
+ foreach (var (method, methodSymbol, matcher, request) in members)
+ {
+ if (request != null)
+ {
+ var methodBody = GenerateMethod(context, matcher, MatcherDefaults.MethodStatusCodeMap, method, methodSymbol, request, members);
+ if (methodBody is null) continue;
+ newClass = newClass.AddMembers(methodBody);
+ }
+ }
+
+ var additionalUsings = new[]
+ {
+ "Microsoft.AspNetCore.Mvc",
+ "FluentValidation.AspNetCore",
+ "Rocket.Surgery.LaunchPad.AspNetCore.Validation"
+ };
+
+ var usings = syntax.SyntaxTree.GetCompilationUnitRoot().Usings;
+ foreach (var additionalUsing in additionalUsings)
+ {
+ if (usings.Any(z => z.Name.ToString() == additionalUsing)) continue;
+ usings = usings.Add(UsingDirective(ParseName(additionalUsing)));
+ }
+
+ var cu = CompilationUnit(
+ List(),
+ List(usings),
+ List(),
+ SingletonList(
+ symbol.ContainingNamespace.IsGlobalNamespace
+ ? newClass.ReparentDeclaration(context, syntax)
+ : NamespaceDeclaration(ParseName(symbol.ContainingNamespace.ToDisplayString()))
+ .WithMembers(SingletonList(newClass.ReparentDeclaration(context, syntax)))
+ )
+ )
+ .WithLeadingTrivia()
+ .WithTrailingTrivia()
+ .WithLeadingTrivia(Trivia(NullableDirectiveTrivia(Token(SyntaxKind.EnableKeyword), true)))
+ .WithTrailingTrivia(Trivia(NullableDirectiveTrivia(Token(SyntaxKind.RestoreKeyword), true)), CarriageReturnLineFeed);
+
+ context.AddSource($"{newClass.Identifier.Text}_Methods.cs", cu.NormalizeWhitespace().GetText(Encoding.UTF8));
+ }
+
+ ///
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ var syntaxProvider = context
+ .SyntaxProvider
+ .CreateSyntaxProvider(
+ (node, _) => node is ClassDeclarationSyntax cds
+ && cds.BaseList?.Types.FirstOrDefault()?.Type.GetSyntaxName() == "RestfulApiController"
+ && cds.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword))
+ && cds.Members.Any(
+ z => z is MethodDeclarationSyntax && z.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword))
+ ),
+ (syntaxContext, _) =>
+ (
+ syntax: (ClassDeclarationSyntax)syntaxContext.Node,
+ symbol: syntaxContext.SemanticModel.GetDeclaredSymbol((ClassDeclarationSyntax)syntaxContext.Node, _)!,
+ semanticModel: syntaxContext.SemanticModel
+ )
+ )
+ .Where(z => z.symbol is { })
+ ;
+
+ context.RegisterPostInitializationOutput(
+ initializationContext =>
+ {
+ initializationContext.AddSource(
+ "Attributes.cs", @"
+using System;
+
+namespace Rocket.Surgery.LaunchPad.AspNetCore
+{
+ [AttributeUsage(AttributeTargets.Method)]
+ class CreatedAttribute : Attribute
+ {
+ public CreatedAttribute(string methodName){}
+ }
+ [AttributeUsage(AttributeTargets.Method)]
+ class AcceptedAttribute : Attribute
+ {
+ public AcceptedAttribute(string methodName){}
+ }
+}"
+ );
+ }
+ );
+
+ context.RegisterSourceOutput(syntaxProvider.Combine(context.CompilationProvider), GenerateMethods);
+ }
+}
+
+internal static class SymbolExtensions
+{
+ public static AttributeData? GetAttribute(this ISymbol symbol, string attributeClassName)
+ {
+ return symbol.GetAttributes().FirstOrDefault(z => z.AttributeClass?.Name == attributeClassName);
+ }
+}
diff --git a/src/Analyzers/GeneratorDiagnostics.cs b/src/Analyzers/GeneratorDiagnostics.cs
index 671f7cf2d..6a4f2094a 100644
--- a/src/Analyzers/GeneratorDiagnostics.cs
+++ b/src/Analyzers/GeneratorDiagnostics.cs
@@ -21,4 +21,22 @@ internal static class GeneratorDiagnostics
DiagnosticSeverity.Error,
true
);
+
+ public static DiagnosticDescriptor ParameterMustBeAPropertyOfTheRequest { get; } = new(
+ "LPAD0003",
+ "The parameter must map to a property of the request object",
+ "The parameter {0} map to a property of the request {1} object",
+ "LaunchPad",
+ DiagnosticSeverity.Error,
+ true
+ );
+
+ public static DiagnosticDescriptor ParameterMustBeSameTypeAsTheRelatedProperty { get; } = new(
+ "LPAD0004",
+ "The parameter type and property type must match",
+ "The parameter {0} type {1} must match the property {2} type {3}",
+ "LaunchPad",
+ DiagnosticSeverity.Error,
+ true
+ );
}
diff --git a/src/Analyzers/InheritFromGenerator.cs b/src/Analyzers/InheritFromGenerator.cs
index 89929bfd7..46fc68b0f 100644
--- a/src/Analyzers/InheritFromGenerator.cs
+++ b/src/Analyzers/InheritFromGenerator.cs
@@ -109,8 +109,10 @@ AttributeData[] attributes
List(declaration.SyntaxTree.GetCompilationUnitRoot().Usings),
List(),
SingletonList(
- NamespaceDeclaration(ParseName(symbol.ContainingNamespace.ToDisplayString()))
- .WithMembers(SingletonList(classToInherit.ReparentDeclaration(context, declaration)))
+ symbol.ContainingNamespace.IsGlobalNamespace
+ ? classToInherit.ReparentDeclaration(context, declaration)
+ : NamespaceDeclaration(ParseName(symbol.ContainingNamespace.ToDisplayString()))
+ .WithMembers(SingletonList(classToInherit.ReparentDeclaration(context, declaration)))
)
)
.WithLeadingTrivia()
diff --git a/src/Analyzers/MutableGenerator.cs b/src/Analyzers/MutableGenerator.cs
index 965c2530c..7482c2929 100644
--- a/src/Analyzers/MutableGenerator.cs
+++ b/src/Analyzers/MutableGenerator.cs
@@ -6,29 +6,10 @@ namespace Rocket.Surgery.LaunchPad.Analyzers;
/// Generator to convert an immutable type into a mutable one
///
[Generator]
-public class MutableGenerator : ISourceGenerator
+public class MutableGenerator : IIncrementalGenerator
{
///
- public void Initialize(GeneratorInitializationContext context)
+ public void Initialize(IncrementalGeneratorInitializationContext context)
{
- context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
- }
-
- ///
- public void Execute(GeneratorExecutionContext context)
- {
- if (context.SyntaxReceiver is not SyntaxReceiver)
- {
-// return;
- }
-
-// var compliation = ( context.Compilation as CSharpCompilation )!;
- }
-
- private class SyntaxReceiver : ISyntaxReceiver
- {
- public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
- {
- }
}
}
diff --git a/src/Analyzers/RecordInterop.cs b/src/Analyzers/RecordInterop.cs
new file mode 100644
index 000000000..50871349a
--- /dev/null
+++ b/src/Analyzers/RecordInterop.cs
@@ -0,0 +1,23 @@
+#pragma warning disable MA0048 // File name must match type name
+#define INTERNAL_RECORD_ATTRIBUTES
+#if NETSTANDARD || NETCOREAPP
+using System.ComponentModel;
+
+// ReSharper disable once CheckNamespace
+namespace System.Runtime.CompilerServices
+{
+ ///
+ /// Reserved to be used by the compiler for tracking metadata.
+ /// This class should not be used by developers in source code.
+ ///
+ [EditorBrowsable(EditorBrowsableState.Never)]
+#if INTERNAL_RECORD_ATTRIBUTES
+ internal
+#else
+ public
+#endif
+ static class IsExternalInit
+ {
+ }
+}
+#endif
diff --git a/src/AspNetCore/Composition/IValidationActionResultFactory.cs b/src/AspNetCore/Composition/IValidationActionResultFactory.cs
deleted file mode 100644
index c699129f3..000000000
--- a/src/AspNetCore/Composition/IValidationActionResultFactory.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using Microsoft.AspNetCore.Mvc;
-
-namespace Rocket.Surgery.LaunchPad.AspNetCore.Composition;
-
-///
-/// A factory used to created an Action Result that will be returned for validation results
-///
-public interface IValidationActionResultFactory
-{
- ///
- /// The status code
- ///
- int StatusCode { get; }
-
- ///
- /// The factory method
- ///
- ///
- ///
- ActionResult CreateActionResult(ValidationProblemDetails problemDetails);
-}
diff --git a/src/AspNetCore/Composition/RestfulApiActionModelConvention.cs b/src/AspNetCore/Composition/RestfulApiActionModelConvention.cs
index 8942fd639..5b5cfe37f 100644
--- a/src/AspNetCore/Composition/RestfulApiActionModelConvention.cs
+++ b/src/AspNetCore/Composition/RestfulApiActionModelConvention.cs
@@ -1,14 +1,19 @@
-using Microsoft.AspNetCore.Http;
+using System.Collections.Concurrent;
+using FluentValidation.AspNetCore;
+using MediatR;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.Extensions.Options;
+using Microsoft.OpenApi.Models;
using Rocket.Surgery.LaunchPad.AspNetCore.Validation;
+using Swashbuckle.AspNetCore.SwaggerGen;
namespace Rocket.Surgery.LaunchPad.AspNetCore.Composition;
-internal class RestfulApiActionModelConvention : IActionModelConvention
+internal class RestfulApiActionModelConvention : IActionModelConvention, ISchemaFilter
{
private static string? GetHttpMethod(ActionModel action)
{
@@ -26,6 +31,70 @@ internal class RestfulApiActionModelConvention : IActionModelConvention
return httpMethods[0];
}
+ private static void ExtractParameterDetails(ActionModel action)
+ {
+ var requestParameter = action.Parameters.FirstOrDefault(
+ z => z.ParameterInfo?.ParameterType.GetInterfaces().Any(
+ i => i.IsGenericType &&
+ ( typeof(IRequest<>) == i.GetGenericTypeDefinition()
+ || typeof(IStreamRequest<>) == i.GetGenericTypeDefinition() )
+ ) == true
+ )
+ ;
+ if (requestParameter is null) return;
+
+ // Likely hit the generator, no point running from here
+ if (requestParameter.Attributes.Count(z => z is BindAttribute or CustomizeValidatorAttribute) == 2)
+ {
+ _propertiesToHideFromOpenApi.TryAdd(
+ requestParameter.ParameterType,
+ requestParameter.ParameterType.GetProperties().Select(z => z.Name).Except(
+ requestParameter.Attributes.OfType().SelectMany(z => z.Include)
+ ).ToArray()
+ );
+ return;
+ }
+
+ var index = action.Parameters.IndexOf(requestParameter);
+ var newAttributes = requestParameter.Attributes.ToList();
+ var otherParams = action.Parameters
+ .Except(new[] { requestParameter })
+ .Select(z => z.ParameterName)
+ .ToArray();
+
+ var propertyAndFieldNames = requestParameter.ParameterType
+ .GetProperties()
+ .Select(z => z.Name)
+ .ToArray();
+ var bindNames = propertyAndFieldNames
+ .Except(otherParams, StringComparer.OrdinalIgnoreCase)
+ .ToArray();
+
+ if (propertyAndFieldNames.Length != bindNames.Length)
+ {
+ var ignoreBindings = propertyAndFieldNames.Except(bindNames, StringComparer.OrdinalIgnoreCase).ToArray();
+ newAttributes.Add(new BindAttribute(bindNames));
+ action.Properties.Add(
+ typeof(CustomizeValidatorAttribute),
+ bindNames
+ );
+ _propertiesToHideFromOpenApi.TryAdd(requestParameter.ParameterType, ignoreBindings);
+
+ var model = action.Parameters[index] = new ParameterModel(requestParameter.ParameterInfo, newAttributes)
+ {
+ Action = requestParameter.Action,
+ BindingInfo = requestParameter.BindingInfo,
+ ParameterName = requestParameter.ParameterName
+ };
+ foreach (var item in requestParameter.Properties)
+ {
+ model.Properties.Add(item);
+ }
+ }
+ }
+
+ private static readonly ConcurrentDictionary _propertiesToHideFromOpenApi = new();
+
private readonly ILookup _matchers;
private readonly RestfulApiOptions _options;
@@ -43,7 +112,7 @@ ActionModel actionModel
var providerLookup = actionModel.Filters.OfType()
.ToLookup(x => x.StatusCode);
- var hasSuccess = providerLookup.Any(z => z.Key >= 200 && z.Key < 300);
+ var hasSuccess = providerLookup.Any(z => z.Key is >= 200 and < 300);
var match = _matchers
.SelectMany(z => z)
.FirstOrDefault(x => x.IsMatch(actionModel));
@@ -82,10 +151,10 @@ ActionModel actionModel
actionModel.Filters.Add(new ProducesResponseTypeAttribute(typeof(ProblemDetails), StatusCodes.Status400BadRequest));
}
- if (!providerLookup[_options.ValidationActionResultFactory.StatusCode].Any())
+ if (!providerLookup[_options.ValidationStatusCode].Any())
{
actionModel.Filters.Add(
- new ProducesResponseTypeAttribute(typeof(FluentValidationProblemDetails), _options.ValidationActionResultFactory.StatusCode)
+ new ProducesResponseTypeAttribute(typeof(FluentValidationProblemDetails), _options.ValidationStatusCode)
);
}
}
@@ -102,5 +171,19 @@ public void Apply(ActionModel action)
return;
UpdateProviders(action);
+ ExtractParameterDetails(action);
+ }
+
+ public void Apply(OpenApiSchema schema, SchemaFilterContext context)
+ {
+ if (_propertiesToHideFromOpenApi.TryGetValue(context.Type, out var propertiesToRemove))
+ {
+ foreach (var property in propertiesToRemove
+ .Join(schema.Properties, z => z, z => z.Key, (a, b) => b.Key, StringComparer.OrdinalIgnoreCase)
+ .ToArray())
+ {
+ schema.Properties.Remove(property);
+ }
+ }
}
}
diff --git a/src/AspNetCore/Composition/RestfulApiParameterMatcher.cs b/src/AspNetCore/Composition/RestfulApiParameterMatcher.cs
index d393b6a95..13b17ca6b 100644
--- a/src/AspNetCore/Composition/RestfulApiParameterMatcher.cs
+++ b/src/AspNetCore/Composition/RestfulApiParameterMatcher.cs
@@ -38,7 +38,17 @@ public bool IsMatch(ActionModel actionModel)
var parameter = parameters[ParameterIndex];
if (TypeMatch == ApiConventionTypeMatchBehavior.AssignableFrom && Type != null)
{
- if (!Type.IsAssignableFrom(parameter.ParameterType))
+ if (Type.IsGenericTypeDefinition)
+ {
+ var parameterIs = parameter.ParameterType.IsGenericType && parameter.ParameterType.GetGenericTypeDefinition() == Type;
+ if (!parameterIs)
+ parameterIs = parameter.ParameterType.GetInterfaces().Any(inter => inter.IsGenericType && inter.GetGenericTypeDefinition() == Type);
+ if (!parameterIs)
+ {
+ return false;
+ }
+ }
+ else if (!Type.IsAssignableFrom(parameter.ParameterType))
{
return false;
}
diff --git a/src/AspNetCore/Composition/UnprocessableEntityActionResultFactory.cs b/src/AspNetCore/Composition/UnprocessableEntityActionResultFactory.cs
deleted file mode 100644
index 3f2351925..000000000
--- a/src/AspNetCore/Composition/UnprocessableEntityActionResultFactory.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Mvc;
-
-namespace Rocket.Surgery.LaunchPad.AspNetCore.Composition;
-
-internal class UnprocessableEntityActionResultFactory : IValidationActionResultFactory
-{
- public ActionResult CreateActionResult(ValidationProblemDetails problemDetails)
- {
- problemDetails.Status = StatusCode;
- return new UnprocessableEntityObjectResult(problemDetails);
- }
-
- public int StatusCode { get; } = StatusCodes.Status422UnprocessableEntity;
-}
diff --git a/src/AspNetCore/Conventions/AspNetCoreConvention.cs b/src/AspNetCore/Conventions/AspNetCoreConvention.cs
index 242c45ec1..e54b15f75 100644
--- a/src/AspNetCore/Conventions/AspNetCoreConvention.cs
+++ b/src/AspNetCore/Conventions/AspNetCoreConvention.cs
@@ -112,6 +112,9 @@ public void Register(IConventionContext context, IConfiguration configuration, I
}
services
+#if NET6_0_OR_GREATER
+ .AddEndpointsApiExplorer()
+#endif
.AddMvcCore()
.AddApiExplorer();
PopulateDefaultParts(
diff --git a/src/AspNetCore/Conventions/SwashbuckleConvention.cs b/src/AspNetCore/Conventions/SwashbuckleConvention.cs
index 8ff3c8919..f87a9f6f8 100644
--- a/src/AspNetCore/Conventions/SwashbuckleConvention.cs
+++ b/src/AspNetCore/Conventions/SwashbuckleConvention.cs
@@ -9,6 +9,7 @@
using Microsoft.OpenApi.Models;
using Rocket.Surgery.Conventions;
using Rocket.Surgery.Conventions.DependencyInjection;
+using Rocket.Surgery.LaunchPad.AspNetCore.Composition;
using Rocket.Surgery.LaunchPad.AspNetCore.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
@@ -52,6 +53,8 @@ public void Register(IConventionContext context, IConfiguration configuration, I
options => options.ServiceLifetime = ServiceLifetime.Singleton
);
+// services.TryAddEnumerable(ServiceDescriptor.Transient());
+
services.AddOptions()
.Configure>(
(options, mvcOptions) => options.ConfigureForNodaTime(mvcOptions.Value.JsonSerializerOptions)
@@ -59,7 +62,9 @@ public void Register(IConventionContext context, IConfiguration configuration, I
services.AddSwaggerGen(
options =>
{
+ options.SchemaFilter();
options.SchemaFilter();
+ options.SchemaFilter();
options.OperationFilter();
options.OperationFilter();
options.OperationFilter();
@@ -104,7 +109,7 @@ public void Register(IConventionContext context, IConfiguration configuration, I
{
try
{
- options.IncludeXmlComments(item);
+ options.IncludeXmlComments(item, true);
}
#pragma warning disable CA1031
catch (Exception e)
diff --git a/src/AspNetCore/OpenApi/StronglyTypedIdSchemaFilter.cs b/src/AspNetCore/OpenApi/StronglyTypedIdSchemaFilter.cs
new file mode 100644
index 000000000..f1ac9b274
--- /dev/null
+++ b/src/AspNetCore/OpenApi/StronglyTypedIdSchemaFilter.cs
@@ -0,0 +1,46 @@
+using System.Reflection;
+using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace Rocket.Surgery.LaunchPad.AspNetCore.OpenApi;
+
+internal class StronglyTypedIdHelpers
+{
+ public static bool IsStronglyTypedId(Type? type)
+ {
+ return GetStronglyTypedIdType(type) is { };
+ }
+
+ public static Type? GetStronglyTypedIdType(Type? type)
+ {
+ if (type?.GetMember("New", BindingFlags.Static | BindingFlags.Public).FirstOrDefault() is MethodInfo
+ && type.GetMember("Empty", BindingFlags.Static | BindingFlags.Public).FirstOrDefault() is FieldInfo { }
+ && type.GetMember("Value", BindingFlags.Instance | BindingFlags.Public).FirstOrDefault() is PropertyInfo propertyInfo)
+ {
+ return propertyInfo.PropertyType;
+ }
+
+ return null;
+ }
+}
+
+internal class StronglyTypedIdSchemaFilter : ISchemaFilter
+{
+ public void Apply(OpenApiSchema schema, SchemaFilterContext context)
+ {
+ if (StronglyTypedIdHelpers.GetStronglyTypedIdType(context.Type) is not { } type) return;
+ schema.Properties.Clear();
+ var s2 = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository, context.MemberInfo, context.ParameterInfo);
+ schema.Format = s2.Format;
+ schema.Type = s2.Type;
+ schema.Enum = s2.Enum;
+ schema.Default = s2.Default;
+ schema.Maximum = s2.Maximum;
+ schema.Minimum = s2.Minimum;
+ schema.Reference = s2.Reference;
+ schema.Pattern = s2.Pattern;
+ schema.Nullable = s2.Nullable;
+ schema.Required = s2.Required;
+ schema.Not = s2.Not;
+ }
+}
diff --git a/src/AspNetCore/RestfulApiController.cs b/src/AspNetCore/RestfulApiController.cs
index 5d7fe9a36..95af8120d 100644
--- a/src/AspNetCore/RestfulApiController.cs
+++ b/src/AspNetCore/RestfulApiController.cs
@@ -67,30 +67,6 @@ Func> success
return success(await Mediator.Send(request, HttpContext.RequestAborted).ConfigureAwait(false));
}
- ///
- /// Send an request and allow for async
- ///
- /// The request model
- /// The method to call when the request succeeds
- protected async Task Send(
- IRequest request,
- Func> success
- )
- {
- if (request is null)
- {
- throw new ArgumentNullException(nameof(request));
- }
-
- if (success is null)
- {
- throw new ArgumentNullException(nameof(success));
- }
-
- await Mediator.Send(request, HttpContext.RequestAborted).ConfigureAwait(false);
- return await success().ConfigureAwait(false);
- }
-
///
/// Send an request and allow for sync
///
diff --git a/src/AspNetCore/RestfulApiOptions.cs b/src/AspNetCore/RestfulApiOptions.cs
index 24bedc070..d2bdf9f1b 100644
--- a/src/AspNetCore/RestfulApiOptions.cs
+++ b/src/AspNetCore/RestfulApiOptions.cs
@@ -30,16 +30,29 @@ public class RestfulApiOptions
new RestfulApiMethodBuilder(RestfulApiMethod.List)
.MatchPrefix("List", "Search")
.MatchParameterType(^1, typeof(IBaseRequest)),
+ new RestfulApiMethodBuilder(RestfulApiMethod.List)
+ .MatchPrefix("List", "Search")
+ .MatchParameterType(^1, typeof(IStreamRequest<>)),
new RestfulApiMethodBuilder(RestfulApiMethod.Read)
.MatchPrefix("Get", "Find", "Fetch", "Read")
.MatchParameterType(^1, typeof(IBaseRequest)),
+ new RestfulApiMethodBuilder(RestfulApiMethod.Read)
+ .MatchPrefix("Get", "Find", "Fetch", "Read")
+ .MatchParameterType(^1, typeof(IStreamRequest<>)),
new RestfulApiMethodBuilder(RestfulApiMethod.Create)
.MatchPrefix("Post", "Create", "Add")
.MatchParameterType(^1, typeof(IBaseRequest)),
+ new RestfulApiMethodBuilder(RestfulApiMethod.Create)
+ .MatchPrefix("Post", "Create", "Add")
+ .MatchParameterType(^1, typeof(IStreamRequest<>)),
new RestfulApiMethodBuilder(RestfulApiMethod.Update)
.MatchPrefix("Put", "Edit", "Update")
.MatchParameterSuffix(^2, "id")
.MatchParameterType(^1, typeof(IBaseRequest)),
+ new RestfulApiMethodBuilder(RestfulApiMethod.Update)
+ .MatchPrefix("Put", "Edit", "Update")
+ .MatchParameterSuffix(^2, "id")
+ .MatchParameterType(^1, typeof(IStreamRequest<>)),
new RestfulApiMethodBuilder(RestfulApiMethod.Update)
.MatchPrefix("Put", "Edit", "Update")
.MatchParameterSuffix(^2, "id")
@@ -47,6 +60,9 @@ public class RestfulApiOptions
new RestfulApiMethodBuilder(RestfulApiMethod.Delete)
.MatchPrefix("Delete", "Remove")
.MatchParameterType(^1, typeof(IBaseRequest)),
+ new RestfulApiMethodBuilder(RestfulApiMethod.Delete)
+ .MatchPrefix("Delete", "Remove")
+ .MatchParameterType(^1, typeof(IStreamRequest<>)),
new RestfulApiMethodBuilder(RestfulApiMethod.Delete)
.MatchPrefix("Delete", "Remove")
.MatchParameterSuffix(^1, "id"),
@@ -55,7 +71,7 @@ public class RestfulApiOptions
///
/// The factory to use for Validation results
///
- public IValidationActionResultFactory ValidationActionResultFactory { get; set; } = new UnprocessableEntityActionResultFactory();
+ public int ValidationStatusCode { get; set; } = StatusCodes.Status422UnprocessableEntity;
internal ILookup GetMatchers()
{
diff --git a/src/AspNetCore/Validation/ValidatorInterceptor.cs b/src/AspNetCore/Validation/ValidatorInterceptor.cs
index d85b19c74..4362aabb2 100644
--- a/src/AspNetCore/Validation/ValidatorInterceptor.cs
+++ b/src/AspNetCore/Validation/ValidatorInterceptor.cs
@@ -15,6 +15,15 @@ public IValidationContext BeforeAspNetValidation(ActionContext actionContext, IV
public ValidationResult AfterAspNetValidation(ActionContext actionContext, IValidationContext validationContext, ValidationResult result)
{
actionContext.HttpContext.Items[typeof(ValidationResult)] = result;
+ if (actionContext.ActionDescriptor.Properties.TryGetValue(typeof(CustomizeValidatorAttribute), out var value)
+ && value is string[] includeProperties)
+ {
+ return new ValidationResult(
+ result.Errors
+ .Join(includeProperties, z => z.PropertyName, z => z, (a, b) => a, StringComparer.OrdinalIgnoreCase)
+ );
+ }
+
return result;
}
}
diff --git a/src/EntityFramework.HotChocolate/AutoConfigureDbContextConfigureQueryType.cs b/src/EntityFramework.HotChocolate/AutoConfigureDbContextConfigureQueryType.cs
index bb8d0c33c..559356eb7 100644
--- a/src/EntityFramework.HotChocolate/AutoConfigureDbContextConfigureQueryType.cs
+++ b/src/EntityFramework.HotChocolate/AutoConfigureDbContextConfigureQueryType.cs
@@ -14,7 +14,7 @@ namespace Rocket.Surgery.LaunchPad.EntityFramework.HotChocolate;
public class ConfigureConfigureEntityFrameworkContextQueryType : ObjectTypeExtension
where TContext : DbContext
{
- private static IObjectFieldDescriptor ConfigureResolve(IObjectFieldDescriptor typeDescriptor, PropertyInfo propertyInfo)
+ private static IObjectFieldDescriptor ConfigureResolve(IObjectFieldDescriptor typeDescriptor, PropertyInfo propertyInfo)
{
var resolverContextProperty = Expression.Parameter(typeof(IPureResolverContext), "ctx");
var cancellationTokenProperty = Expression.Parameter(typeof(CancellationToken), "ct");
@@ -22,13 +22,40 @@ private static IObjectFieldDescriptor ConfigureResolve(IObjectFieldDesc
var serviceCall = Expression.Call(resolverContextProperty, nameof(IPureResolverContext.Service), new[] { typeof(TContext) });
var contextProperty = Expression.Property(serviceCall, propertyInfo);
- var method = Expression.Lambda>(contextProperty, resolverContextProperty, cancellationTokenProperty)
+ var method = Expression.Lambda>(contextProperty, resolverContextProperty, cancellationTokenProperty)
.Compile();
return typeDescriptor
.UseDbContext()
.Resolve(method);
}
+ private static IObjectFieldDescriptor ConfigureResolveModel(IObjectFieldDescriptor typeDescriptor, PropertyInfo propertyInfo)
+ {
+ var resolverContextProperty = Expression.Parameter(typeof(IResolverContext), "ctx");
+ var cancellationTokenProperty = Expression.Parameter(typeof(CancellationToken), "ct");
+
+ var serviceCall = Expression.Call(
+ resolverContextProperty,
+ typeof(IPureResolverContext).GetMethod(nameof(IPureResolverContext.Service), BindingFlags.Public | BindingFlags.Instance)!.MakeGenericMethod(
+ typeof(TContext)
+ )
+ );
+ var contextProperty = Expression.Property(serviceCall, propertyInfo);
+
+ var projectToMethod = typeof(AutoMapperQueryableExtensions).GetMethod(
+ nameof(AutoMapperQueryableExtensions.ProjectTo), BindingFlags.Static | BindingFlags.Public
+ );
+ var projectTo = Expression.Call(null, projectToMethod!.MakeGenericMethod(typeof(TEntity), typeof(TModel)), contextProperty, resolverContextProperty);
+
+ var method = Expression
+ .Lambda>>(projectTo, resolverContextProperty, cancellationTokenProperty)
+ .Compile();
+ return typeDescriptor
+ .UseDbContext()
+ .Resolve(method);
+ }
+
+
private readonly IEnumerable _configureQueryEntities;
///
@@ -49,6 +76,8 @@ public ConfigureConfigureEntityFrameworkContextQueryType(params IConfigureEntity
_configureQueryEntities = configureQueryEntities;
}
+ public Func MapModel { get; set; }
+
///
protected override void Configure(IObjectTypeDescriptor descriptor)
{
@@ -57,6 +86,10 @@ protected override void Configure(IObjectTypeDescriptor descriptor)
nameof(ConfigureResolve),
BindingFlags.NonPublic | BindingFlags.Static
)!;
+ var configureModel = typeof(ConfigureConfigureEntityFrameworkContextQueryType).GetMethod(
+ nameof(ConfigureResolveModel),
+ BindingFlags.NonPublic | BindingFlags.Static
+ )!;
var sets = typeof(TContext)
.GetProperties()
.Where(z => z.PropertyType.IsGenericType && z.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>))
@@ -65,7 +98,17 @@ protected override void Configure(IObjectTypeDescriptor descriptor)
foreach (var set in sets)
{
var field = descriptor.Field(set.Name.Humanize().Pluralize().Dehumanize().Camelize());
- configure.MakeGenericMethod(set.PropertyType).Invoke(null, new object[] { field, set });
+ var entityType = set.PropertyType.GetGenericArguments()[0];
+ var modelType = MapModel?.Invoke(entityType);
+ if (modelType == entityType)
+ {
+ configure.MakeGenericMethod(set.PropertyType).Invoke(null, new object[] { field, set });
+ }
+ else if (modelType is { })
+ {
+ configureModel.MakeGenericMethod(set.PropertyType.GetGenericArguments()[0], modelType).Invoke(null, new object[] { field, set });
+ }
+
Configure(field, set);
foreach (var item in _configureQueryEntities.Where(z => z.Match(set)))
{
diff --git a/src/EntityFramework.HotChocolate/Rocket.Surgery.LaunchPad.EntityFramework.HotChocolate.csproj b/src/EntityFramework.HotChocolate/Rocket.Surgery.LaunchPad.EntityFramework.HotChocolate.csproj
index 526d52572..d1bc13238 100644
--- a/src/EntityFramework.HotChocolate/Rocket.Surgery.LaunchPad.EntityFramework.HotChocolate.csproj
+++ b/src/EntityFramework.HotChocolate/Rocket.Surgery.LaunchPad.EntityFramework.HotChocolate.csproj
@@ -1,11 +1,12 @@
net6.0
-
+
$(PackageTags)
+
diff --git a/src/EntityFramework/NodaTimeDateTimeValueGenerator.cs b/src/EntityFramework/NodaTimeDateTimeValueGenerator.cs
index f15095aa3..9c328b987 100644
--- a/src/EntityFramework/NodaTimeDateTimeValueGenerator.cs
+++ b/src/EntityFramework/NodaTimeDateTimeValueGenerator.cs
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore.ChangeTracking;
+using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.ValueGeneration;
using NodaTime;
@@ -30,3 +31,43 @@ public override DateTime Next(EntityEntry entry)
return _clock.GetCurrentInstant().ToDateTimeUtc();
}
}
+
+///
+/// Value generator for StronglyTypedIds
+///
+///
+public class StronglyTypedIdValueGenerator : ValueGenerator
+{
+ private readonly Func _factory;
+
+ internal StronglyTypedIdValueGenerator(Func factory)
+ {
+ _factory = factory;
+ }
+
+ ///
+ public override bool GeneratesTemporaryValues => false;
+
+ ///
+ public override T Next(EntityEntry entry)
+ {
+ return _factory();
+ }
+}
+
+///
+/// Factory a StronglyTypedId value generator
+///
+public static class StronglyTypedIdValueGenerator
+{
+ ///
+ /// Create a value generator for the given type
+ ///
+ ///
+ ///
+ ///
+ public static Func Create(Func factory) where T : struct
+ {
+ return (_, _) => new StronglyTypedIdValueGenerator(factory);
+ }
+}
diff --git a/src/Foundation.NewtonsoftJson/NewtonsoftJsonDateTimeOffsetPattern.cs b/src/Foundation.NewtonsoftJson/NewtonsoftJsonDateTimeOffsetPattern.cs
deleted file mode 100644
index ccce4b32b..000000000
--- a/src/Foundation.NewtonsoftJson/NewtonsoftJsonDateTimeOffsetPattern.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using System.Text;
-using NodaTime;
-using NodaTime.Extensions;
-using NodaTime.Text;
-
-namespace Rocket.Surgery.LaunchPad.Foundation;
-
-internal class NewtonsoftJsonDateTimeOffsetPattern : IPattern
-{
- public ParseResult Parse(string text)
- {
- return DateTimeOffset.TryParse(text, out var value)
- ? ParseResult.ForValue(value.ToInstant())
- : ParseResult