Skip to content

Commit

Permalink
Add Extensions to Translate Ardalis.Result to Microsoft.AspNetCore.Ht…
Browse files Browse the repository at this point in the history
…tp.IResult (ardalis#103)

* Add Microsoft.AspNetCore.Http.IResult Extensions. Add minimal API sample project.

* Code cleanup

* Whitespace

* Add minimal API sample

* Add badrequest to weatherservice, change webmarker to interface

* Rename ToMinimalApiResult method

* Update README sample usage, rename extension class

* Consolidate package
  • Loading branch information
KyleMcMaster authored Dec 13, 2022
1 parent 860263c commit 247e8df
Show file tree
Hide file tree
Showing 14 changed files with 347 additions and 51 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,6 @@ ASALocalRun/

# MFractors (Xamarin productivity tool) working folder
.mfractor/

# Development settings
*.Development.json
20 changes: 17 additions & 3 deletions Ardalis.Result.sln
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29512.175
# Visual Studio Version 17
VisualStudioVersion = 17.3.32922.545
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ardalis.Result", "src\Ardalis.Result\Ardalis.Result.csproj", "{21F67195-9A44-4937-AD13-62D086BAFB3E}"
EndProject
Expand All @@ -23,7 +23,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ardalis.Result.Sample.UnitT
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ardalis.Result.Sample.Core", "sample\Ardalis.Result.Sample.Core\Ardalis.Result.Sample.Core.csproj", "{8E6018B8-0549-4290-A1C4-44CF7B4CEC5D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ardalis.Result.FluentValidation", "src\Ardalis.Result.FluentValidation\Ardalis.Result.FluentValidation.csproj", "{43443B4B-2305-4CDF-A7A4-32A80ED1D19B}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ardalis.Result.FluentValidation", "src\Ardalis.Result.FluentValidation\Ardalis.Result.FluentValidation.csproj", "{43443B4B-2305-4CDF-A7A4-32A80ED1D19B}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ardalis.Result.SampleMinimalApi", "sample\Ardalis.Result.SampleMinimalApi\Ardalis.Result.SampleMinimalApi.csproj", "{60860685-37BB-47D9-B0DC-6FE7F0DB2AE5}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ardalis.Result.SampleMinimalApi.FunctionalTests", "sample\Ardalis.Result.SampleMinimalApi.FunctionalTests\Ardalis.Result.SampleMinimalApi.FunctionalTests.csproj", "{A9769533-C9B2-4AD4-8B24-70C474D8EBB0}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Expand Down Expand Up @@ -63,6 +67,14 @@ Global
{43443B4B-2305-4CDF-A7A4-32A80ED1D19B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{43443B4B-2305-4CDF-A7A4-32A80ED1D19B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{43443B4B-2305-4CDF-A7A4-32A80ED1D19B}.Release|Any CPU.Build.0 = Release|Any CPU
{60860685-37BB-47D9-B0DC-6FE7F0DB2AE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{60860685-37BB-47D9-B0DC-6FE7F0DB2AE5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{60860685-37BB-47D9-B0DC-6FE7F0DB2AE5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{60860685-37BB-47D9-B0DC-6FE7F0DB2AE5}.Release|Any CPU.Build.0 = Release|Any CPU
{A9769533-C9B2-4AD4-8B24-70C474D8EBB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A9769533-C9B2-4AD4-8B24-70C474D8EBB0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A9769533-C9B2-4AD4-8B24-70C474D8EBB0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A9769533-C9B2-4AD4-8B24-70C474D8EBB0}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -76,6 +88,8 @@ Global
{84E62AF8-95DA-43BC-95FD-A19103C50949} = {FA061DC6-61B7-401F-87A4-D338B396CCD9}
{8E6018B8-0549-4290-A1C4-44CF7B4CEC5D} = {FA061DC6-61B7-401F-87A4-D338B396CCD9}
{43443B4B-2305-4CDF-A7A4-32A80ED1D19B} = {865A74CD-F478-4AA5-AFA5-6C26FB38B849}
{60860685-37BB-47D9-B0DC-6FE7F0DB2AE5} = {FA061DC6-61B7-401F-87A4-D338B396CCD9}
{A9769533-C9B2-4AD4-8B24-70C474D8EBB0} = {FA061DC6-61B7-401F-87A4-D338B396CCD9}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {7CD0ED8C-3B1C-4F16-8B8D-3D8F1A8F1A5A}
Expand Down
102 changes: 56 additions & 46 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,45 @@ The result pattern provides a standard, reusable way to return both success as w

## Sample Usage

### Creating a Result

The [sample folder](https://github.com/ardalis/Result/tree/main/sample/Ardalis.Result.SampleWeb) includes some examples of how to use the project. Here are a couple of simple uses.

Imagine the snippet below is defined in a domain service that retrieves WeatherForecasts. When compared to the approach described above, this approach uses a result to handle common failure scenarios like missing data denoted as NotFound and or input validation errors denoted as Invalid. If execution is successful, the result will contain the random data generated by the final return statement.

```csharp
public Result<IEnumerable<WeatherForecast>> GetForecast(ForecastRequestDto model)
{
if (model.PostalCode == "NotFound") return Result<IEnumerable<WeatherForecast>>.NotFound();

// validate model
if (model.PostalCode.Length > 10)
{
return Result<IEnumerable<WeatherForecast>>.Invalid(new List<ValidationError> {
new ValidationError
{
Identifier = nameof(model.PostalCode),
ErrorMessage = "PostalCode cannot exceed 10 characters."
}
});
}

var rng = new Random();
return new Result<IEnumerable<WeatherForecast>>(Enumerable.Range(1, 5)
.Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = rng.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
})
.ToArray());
}
```

### Translating Results to ActionResults

Continuing with the domain service example from the previous section, it's important to show that the domain service doesn't know about `ActionResult` or other MVC/etc types. But since it is using a `Result<T>` abstraction, it can return results that are easily mapped to HTTP status codes. Note that the method above returns a `Result<IEnumerable<WeatherForecast>` but in some cases, it might need to return an `Invalid` result, or a `NotFound` result. Otherwise, it returns a `Success` result with the actual returned value (just like an API would return an HTTP 200 and the actual result of the API call).

You can apply the `[TranslateResultToActionResult]` attribute to an [API Endpoint](https://github.com/ardalis/ApiEndpoints) (or controller action if you still use those things) and it will automatically translate the `Result<T>` return type of the method to an `ActionResult<T>` appropriately based on the Result type.

```csharp
Expand All @@ -86,7 +123,7 @@ public Result<IEnumerable<WeatherForecast>> CreateForecast([FromBody]ForecastReq
}
```

Alternately, you can use the `ToActionResult` helper method within an endpoint to achieve the same thing:
Alternatively, you can use the `ToActionResult` helper method within an endpoint to achieve the same thing:

```csharp
[HttpPost("/Forecast/New")]
Expand All @@ -99,62 +136,35 @@ public override ActionResult<IEnumerable<WeatherForecast>> Handle(ForecastReques
}
```

A common use case is to map between domain entities to API response types usually represented as DTOs. You can map a Result containing a domain entity to a Result containing a DTO by using the `Map` method. The following example calls the method `_weatherService.GetSingleForecast` which returns a `Result<WeatherForecast>` which is then converted to a `Result<WeatherForecastSummaryDto>` by the call to `Map`. Then, the Result is converted to an `ActionResult<WeatherForecastSummaryDto>` using the `ToActionResult` helper method.
### Translating Results to Minimal API Results

Similar to how the `ToActionResult` extension method translates `Ardalis.Results` to `ActionResults`, the `ToMinimalApiResult` translates results to the new `Microsoft.AspNetCore.Http.Results` `IResult` types in .NET 6+. The following code snippet demonstrates how one might use the domain service that returns a `Result<IEnumerable<WeatherForecast>>` and convert to a `Microsoft.AspNetCore.Http.Results` instance.

```csharp
[HttpPost("Summary")]
public ActionResult<WeatherForecastSummaryDto> CreateSummaryForecast([FromBody] ForecastRequestDto model)
app.MapPost("/Forecast/New", (ForecastRequestDto request, WeatherService weatherService) =>
{
return _weatherService.GetSingleForecast(model)
.Map(wf => new WeatherForecastSummaryDto(wf.Date, wf.Summary))
.ToActionResult(this);
}
return weatherService.GetForecast(request).ToMinimalApiResult();
})
.WithName("NewWeatherForecast");
```

So, what does the `_weatherService.GetForecast` method look like? Well, it's typically not defined in the same project as the web project, so it doesn't know anything about `ActionResult` or other MVC/etc types. But since it is using a `Result<T>` abstraction, it can return results that are easily mapped to HTTP status codes. Note that in the service below it returns a `Result<IEnumerable<WeatherForecast>` but in some cases it might need to return an `Invalid` result, or a `NotFound` result. Otherwise it returns a `Success` result with the actual returned value (just like an API would return an HTTP 200 and the actual result of the API call).
The full Minimal API sample can be found in the [sample folder](./sample/Ardalis.Result.SampleMinimalApi/Program.cs).

```csharp
public Result<IEnumerable<WeatherForecast>> GetForecast(ForecastRequestDto model)
{
if (model.PostalCode == "NotFound") return Result<IEnumerable<WeatherForecast>>.NotFound();
### Mapping Results From One Type to Another

// validate model
if (model.PostalCode.Length > 10)
{
return Result<IEnumerable<WeatherForecast>>.Invalid(new List<ValidationError> {
new ValidationError
{
Identifier = nameof(model.PostalCode),
ErrorMessage = _stringLocalizer["PostalCode cannot exceed 10 characters."].Value }
});
}
A common use case is to map between domain entities to API response types usually represented as DTOs. You can map a result containing a domain entity to a Result containing a DTO by using the `Map` method. The following example calls the method `_weatherService.GetSingleForecast` which returns a `Result<WeatherForecast>` which is then converted to a `Result<WeatherForecastSummaryDto>` by the call to `Map`. Then, the result is converted to an `ActionResult<WeatherForecastSummaryDto>` using the `ToActionResult` helper method.

// test value
if (model.PostalCode == "55555")
{
return new Result<IEnumerable<WeatherForecast>>(Enumerable.Range(1, 1)
.Select(index =>
new WeatherForecast
{
Date = DateTime.Now,
TemperatureC = 0,
Summary = Summaries[0]
}));
}

var rng = new Random();
return new Result<IEnumerable<WeatherForecast>>(Enumerable.Range(1, 5)
.Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = rng.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
})
.ToArray());
```csharp
[HttpPost("Summary")]
public ActionResult<WeatherForecastSummaryDto> CreateSummaryForecast([FromBody] ForecastRequestDto model)
{
return _weatherService.GetSingleForecast(model)
.Map(wf => new WeatherForecastSummaryDto(wf.Date, wf.Summary))
.ToActionResult(this);
}
```

## FluentValidation
### Using Results with FluentValidation

We can use Ardalis.Result.FluentValidation on a service with FluentValidation like that:

Expand Down
10 changes: 10 additions & 0 deletions sample/Ardalis.Result.Sample.Core/Services/WeatherService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ public Result<IEnumerable<WeatherForecast>> GetForecast(ForecastRequestDto model
});
}

if (string.IsNullOrWhiteSpace(model.PostalCode))
{
return Result.Invalid(new List<ValidationError> {
new ValidationError
{
Identifier = nameof(model.PostalCode),
ErrorMessage = "PostalCode is required" }
});
}

// test value
if (model.PostalCode == "55555")
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>

<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="6.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.1.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Ardalis.Result.SampleMinimalApi\Ardalis.Result.SampleMinimalApi.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using Ardalis.Result.Sample.Core.DTOs;
using Ardalis.Result.Sample.Core.Model;
using Microsoft.AspNetCore.Mvc.Testing;
using Newtonsoft.Json;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Xunit;

namespace Ardalis.Result.SampleMinimalApi.FunctionalTests;
public class NewWeatherForecast : IClassFixture<WebApplicationFactory<IWebMarker>>
{
private const string ENDPOINT_POST_ROUTE = "/forecast/new";
private readonly HttpClient _client;

public NewWeatherForecast(WebApplicationFactory<IWebMarker> factory)
{
_client = factory.CreateClient();
}

[Fact]
public async Task ReturnsOkWithValueGivenValidPostalCode()
{
var requestDto = new ForecastRequestDto() { PostalCode = "55555" };

var jsonContent = new StringContent(JsonConvert.SerializeObject(requestDto), Encoding.Default, "application/json");
var response = await _client.PostAsync(ENDPOINT_POST_ROUTE, jsonContent);
response.EnsureSuccessStatusCode();

var stringResponse = await response.Content.ReadAsStringAsync();
var forecasts = JsonConvert.DeserializeObject<List<WeatherForecast>>(stringResponse);

Assert.Equal("Freezing", forecasts.First().Summary);
}

[Fact]
public async Task ReturnsBadRequestGivenNoPostalCode()
{
var requestDto = new ForecastRequestDto() { PostalCode = "" };
var jsonContent = new StringContent(JsonConvert.SerializeObject(requestDto), Encoding.Default, "application/json");
var response = await _client.PostAsync(ENDPOINT_POST_ROUTE, jsonContent);

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}

[Fact]
public async Task ReturnsNotFoundGivenNonExistentPostalCode()
{
var requestDto = new ForecastRequestDto() { PostalCode = "NotFound" };
var jsonContent = new StringContent(JsonConvert.SerializeObject(requestDto), Encoding.Default, "application/json");
var response = await _client.PostAsync(ENDPOINT_POST_ROUTE, jsonContent);

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

[Fact]
public async Task ReturnsBadRequestGivenPostalCodeTooLong()
{
var requestDto = new ForecastRequestDto() { PostalCode = "01234567890" };
var jsonContent = new StringContent(JsonConvert.SerializeObject(requestDto), Encoding.Default, "application/json");
var response = await _client.PostAsync(ENDPOINT_POST_ROUTE, jsonContent);

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("PostalCode cannot exceed 10 characters.", stringResponse);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Ardalis.Result.AspNetCore\Ardalis.Result.AspNetCore.csproj">
<SetTargetFramework>TargetFramework=net6.0</SetTargetFramework>
</ProjectReference>
<ProjectReference Include="..\Ardalis.Result.Sample.Core\Ardalis.Result.Sample.Core.csproj" />
</ItemGroup>

</Project>
8 changes: 8 additions & 0 deletions sample/Ardalis.Result.SampleMinimalApi/IWebMarker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Ardalis.Result.SampleMinimalApi;

/// <summary>
/// This is a simple marker type that is used by the integration tests to reference the correct assembly for host building
/// </summary>
public interface IWebMarker
{
}
44 changes: 44 additions & 0 deletions sample/Ardalis.Result.SampleMinimalApi/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using Ardalis.Result.AspNetCore;
using Ardalis.Result.Sample.Core.DTOs;
using Ardalis.Result.Sample.Core.Services;
using Microsoft.AspNetCore.Localization;
using System.Globalization;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddScoped<WeatherService>();
builder.Services.AddLocalization(opt => { opt.ResourcesPath = "Resources"; });
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
var supportedCultures = new List<CultureInfo>
{
new CultureInfo("en-US"),
new CultureInfo("de-DE"),
};
options.DefaultRequestCulture = new RequestCulture("en-US");
options.SupportedCultures = supportedCultures;
options.SupportedUICultures = supportedCultures;
});

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.MapPost("/Forecast/New", (ForecastRequestDto request, WeatherService weatherService) =>
{
return weatherService.GetForecast(request).ToMinimalApiResult();
})
.WithName("NewWeatherForecast");

app.Run();
9 changes: 9 additions & 0 deletions sample/Ardalis.Result.SampleMinimalApi/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="6.0.10" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.4.0" />

</ItemGroup>

<ItemGroup>
Expand Down
Loading

0 comments on commit 247e8df

Please sign in to comment.