Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/Http/Http.Extensions/src/HttpValidationProblemDetails.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;

namespace Microsoft.AspNetCore.Http.Extensions
{
/// <summary>
/// A <see cref="ProblemDetails"/> for validation errors.
/// </summary>
[JsonConverter(typeof(HttpValidationProblemDetailsJsonConverter))]
public class HttpValidationProblemDetails : ProblemDetails
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sealed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

namespace Microsoft.AspNetCore.Mvc
{
-   public class ValidationProblemDetails : ProblemDetails
+   public class ValidationProblemDetails : HttpValidationProblemDetails
    {
    }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm open to not doing this - it just felt like a good way to help move the feature over to Http.Extensions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was originally thinking we would have ProblemDetails derive from something in the core ProblemDetailsBase.

{
/// <summary>
/// Initializes a new instance of <see cref="HttpValidationProblemDetails"/>.
/// </summary>
public HttpValidationProblemDetails()
: this(new Dictionary<string, string[]>(StringComparer.Ordinal))
{
}

/// <summary>
/// Initializes a new instance of <see cref="HttpValidationProblemDetails"/> using the specified <paramref name="errors"/>.
/// </summary>
/// <param name="errors">The validation errors.</param>
public HttpValidationProblemDetails(IDictionary<string, string[]> errors)
: this(new Dictionary<string, string[]>(errors, StringComparer.Ordinal))
{
}

private HttpValidationProblemDetails(Dictionary<string, string[]> errors)
{
Title = "One or more validation errors occurred.";
Errors = errors;
}

/// <summary>
/// Gets the validation errors associated with this instance of <see cref="HttpValidationProblemDetails"/>.
/// </summary>
public IDictionary<string, string[]> Errors { get; } = new Dictionary<string, string[]>(StringComparer.Ordinal);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Description>ASP.NET Core common extension methods for HTTP abstractions, HTTP headers, HTTP request/response, and session state.</Description>
Expand All @@ -11,9 +11,11 @@
</PropertyGroup>

<ItemGroup>
<Compile Include="$(SharedSourceRoot)ObjectMethodExecutor\**\*.cs" />
<Compile Include="$(SharedSourceRoot)TryParseMethodCache.cs" />
<Compile Include="..\..\Shared\StreamCopyOperationInternal.cs" />
<Compile Include="$(SharedSourceRoot)ObjectMethodExecutor\**\*.cs" LinkBase="Shared"/>
<Compile Include="$(SharedSourceRoot)TryParseMethodCache.cs" LinkBase="Shared"/>
<Compile Include="..\..\Shared\StreamCopyOperationInternal.cs" LinkBase="Shared" />
<Compile Include="$(SharedSourceRoot)ProblemDetailsJsonConverter.cs" LinkBase="Shared"/>
<Compile Include="$(SharedSourceRoot)HttpValidationProblemDetailsJsonConverter.cs" LinkBase="Shared" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Http.Extensions;

namespace Microsoft.AspNetCore.Mvc
{
Expand Down
17 changes: 17 additions & 0 deletions src/Http/Http.Extensions/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@
*REMOVED*~static Microsoft.AspNetCore.Http.SessionExtensions.GetString(this Microsoft.AspNetCore.Http.ISession session, string key) -> string
*REMOVED*~static Microsoft.AspNetCore.Http.SessionExtensions.SetInt32(this Microsoft.AspNetCore.Http.ISession session, string key, int value) -> void
*REMOVED*~static Microsoft.AspNetCore.Http.SessionExtensions.SetString(this Microsoft.AspNetCore.Http.ISession session, string key, string value) -> void
Microsoft.AspNetCore.Http.Extensions.HttpValidationProblemDetails
Microsoft.AspNetCore.Http.Extensions.HttpValidationProblemDetails.Errors.get -> System.Collections.Generic.IDictionary<string!, string![]!>!
Microsoft.AspNetCore.Http.Extensions.HttpValidationProblemDetails.HttpValidationProblemDetails() -> void
Microsoft.AspNetCore.Http.Extensions.HttpValidationProblemDetails.HttpValidationProblemDetails(System.Collections.Generic.IDictionary<string!, string![]!>! errors) -> void
Microsoft.AspNetCore.Http.Extensions.QueryBuilder.Add(string! key, System.Collections.Generic.IEnumerable<string!>! values) -> void
Microsoft.AspNetCore.Http.Extensions.QueryBuilder.Add(string! key, string! value) -> void
Microsoft.AspNetCore.Http.Extensions.QueryBuilder.GetEnumerator() -> System.Collections.Generic.IEnumerator<System.Collections.Generic.KeyValuePair<string!, string!>>!
Expand Down Expand Up @@ -153,6 +157,19 @@ Microsoft.AspNetCore.Http.Headers.ResponseHeaders.SetCookie.get -> System.Collec
Microsoft.AspNetCore.Http.Headers.ResponseHeaders.SetCookie.set -> void
Microsoft.AspNetCore.Http.Headers.ResponseHeaders.SetList<T>(string! name, System.Collections.Generic.IList<T>? values) -> void
Microsoft.AspNetCore.Http.RequestDelegateFactory
Microsoft.AspNetCore.Mvc.ProblemDetails
Microsoft.AspNetCore.Mvc.ProblemDetails.Detail.get -> string?
Microsoft.AspNetCore.Mvc.ProblemDetails.Detail.set -> void
Microsoft.AspNetCore.Mvc.ProblemDetails.Extensions.get -> System.Collections.Generic.IDictionary<string!, object?>!
Microsoft.AspNetCore.Mvc.ProblemDetails.Instance.get -> string?
Microsoft.AspNetCore.Mvc.ProblemDetails.Instance.set -> void
Microsoft.AspNetCore.Mvc.ProblemDetails.ProblemDetails() -> void
Microsoft.AspNetCore.Mvc.ProblemDetails.Status.get -> int?
Microsoft.AspNetCore.Mvc.ProblemDetails.Status.set -> void
Microsoft.AspNetCore.Mvc.ProblemDetails.Title.get -> string?
Microsoft.AspNetCore.Mvc.ProblemDetails.Title.set -> void
Microsoft.AspNetCore.Mvc.ProblemDetails.Type.get -> string?
Microsoft.AspNetCore.Mvc.ProblemDetails.Type.set -> void
override Microsoft.AspNetCore.Http.Extensions.QueryBuilder.Equals(object? obj) -> bool
override Microsoft.AspNetCore.Http.Extensions.QueryBuilder.ToString() -> string!
static Microsoft.AspNetCore.Http.Extensions.HttpRequestMultipartExtensions.GetMultipartBoundary(this Microsoft.AspNetCore.Http.HttpRequest! request) -> string!
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.Linq;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http.Json;
using Xunit;

namespace Microsoft.AspNetCore.Http.Extensions
{
public class HttpValidationProblemDetailsJsonConverterTest
{
private static JsonSerializerOptions JsonSerializerOptions => new JsonOptions().SerializerOptions;

[Fact]
public void Read_Works()
{
// Arrange
var type = "https://tools.ietf.org/html/rfc7231#section-6.5.4";
var title = "Not found";
var status = 404;
var detail = "Product not found";
var instance = "http://example.com/products/14";
var traceId = "|37dd3dd5-4a9619f953c40a16.";
var json = $"{{\"type\":\"{type}\",\"title\":\"{title}\",\"status\":{status},\"detail\":\"{detail}\", \"instance\":\"{instance}\",\"traceId\":\"{traceId}\"," +
"\"errors\":{\"key0\":[\"error0\"],\"key1\":[\"error1\",\"error2\"]}}";
var converter = new HttpValidationProblemDetailsJsonConverter();
var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json));
reader.Read();

// Act
var problemDetails = converter.Read(ref reader, typeof(HttpValidationProblemDetails), JsonSerializerOptions);

Assert.Equal(type, problemDetails.Type);
Assert.Equal(title, problemDetails.Title);
Assert.Equal(status, problemDetails.Status);
Assert.Equal(instance, problemDetails.Instance);
Assert.Equal(detail, problemDetails.Detail);
Assert.Collection(
problemDetails.Extensions,
kvp =>
{
Assert.Equal("traceId", kvp.Key);
Assert.Equal(traceId, kvp.Value.ToString());
});
Assert.Collection(
problemDetails.Errors.OrderBy(kvp => kvp.Key),
kvp =>
{
Assert.Equal("key0", kvp.Key);
Assert.Equal(new[] { "error0" }, kvp.Value);
},
kvp =>
{
Assert.Equal("key1", kvp.Key);
Assert.Equal(new[] { "error1", "error2" }, kvp.Value);
});
}

[Fact]
public void Read_WithSomeMissingValues_Works()
{
// Arrange
var type = "https://tools.ietf.org/html/rfc7231#section-6.5.4";
var title = "Not found";
var status = 404;
var traceId = "|37dd3dd5-4a9619f953c40a16.";
var json = $"{{\"type\":\"{type}\",\"title\":\"{title}\",\"status\":{status},\"traceId\":\"{traceId}\"," +
"\"errors\":{\"key0\":[\"error0\"],\"key1\":[\"error1\",\"error2\"]}}";
var converter = new HttpValidationProblemDetailsJsonConverter();
var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json));
reader.Read();

// Act
var problemDetails = converter.Read(ref reader, typeof(HttpValidationProblemDetails), JsonSerializerOptions);

Assert.Equal(type, problemDetails.Type);
Assert.Equal(title, problemDetails.Title);
Assert.Equal(status, problemDetails.Status);
Assert.Collection(
problemDetails.Extensions,
kvp =>
{
Assert.Equal("traceId", kvp.Key);
Assert.Equal(traceId, kvp.Value.ToString());
});
Assert.Collection(
problemDetails.Errors.OrderBy(kvp => kvp.Key),
kvp =>
{
Assert.Equal("key0", kvp.Key);
Assert.Equal(new[] { "error0" }, kvp.Value);
},
kvp =>
{
Assert.Equal("key1", kvp.Key);
Assert.Equal(new[] { "error1", "error2" }, kvp.Value);
});
}

[Fact]
public void ReadUsingJsonSerializerWorks()
{
// Arrange
var type = "https://tools.ietf.org/html/rfc7231#section-6.5.4";
var title = "Not found";
var status = 404;
var traceId = "|37dd3dd5-4a9619f953c40a16.";
var json = $"{{\"type\":\"{type}\",\"title\":\"{title}\",\"status\":{status},\"traceId\":\"{traceId}\"," +
"\"errors\":{\"key0\":[\"error0\"],\"key1\":[\"error1\",\"error2\"]}}";

// Act
var problemDetails = JsonSerializer.Deserialize<HttpValidationProblemDetails>(json, JsonSerializerOptions);

Assert.Equal(type, problemDetails.Type);
Assert.Equal(title, problemDetails.Title);
Assert.Equal(status, problemDetails.Status);
Assert.Collection(
problemDetails.Extensions,
kvp =>
{
Assert.Equal("traceId", kvp.Key);
Assert.Equal(traceId, kvp.Value.ToString());
});
Assert.Collection(
problemDetails.Errors.OrderBy(kvp => kvp.Key),
kvp =>
{
Assert.Equal("key0", kvp.Key);
Assert.Equal(new[] { "error0" }, kvp.Value);
},
kvp =>
{
Assert.Equal("key1", kvp.Key);
Assert.Equal(new[] { "error1", "error2" }, kvp.Value);
});
}
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.IO;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.Mvc;
using Xunit;

namespace Microsoft.AspNetCore.Mvc.Infrastructure
namespace Microsoft.AspNetCore.Http.Extensions
{
public class ProblemDetailsJsonConverterTest
{
private static JsonSerializerOptions JsonSerializerOptions => new JsonOptions().JsonSerializerOptions;
private static JsonSerializerOptions JsonSerializerOptions => new JsonOptions().SerializerOptions;

[Fact]
public void Read_ThrowsIfJsonIsIncomplete()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<Compile Include="$(SharedSourceRoot)ResponseContentTypeHelper.cs" LinkBase="Shared" />
<Compile Include="$(SharedSourceRoot)ResultsHelpers\*.cs" LinkBase="Shared" />
<Compile Include="$(SharedSourceRoot)RangeHelper\RangeHelper.cs" LinkBase="Shared" />
<Compile Include="$(SharedSourceRoot)ProblemDetailsDefaults.cs" LinkBase="Shared" />
</ItemGroup>

<ItemGroup>
Expand Down
57 changes: 53 additions & 4 deletions src/Http/Http.Results/src/ObjectResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.Threading.Tasks;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

Expand All @@ -12,7 +14,15 @@ internal partial class ObjectResult : IResult
/// <summary>
/// Creates a new <see cref="ObjectResult"/> instance with the provided <paramref name="value"/>.
/// </summary>
public ObjectResult(object? value, int statusCode)
public ObjectResult(object? value)
{
Value = value;
}

/// <summary>
/// Creates a new <see cref="ObjectResult"/> instance with the provided <paramref name="value"/>.
/// </summary>
public ObjectResult(object? value, int? statusCode)
{
Value = value;
StatusCode = statusCode;
Expand All @@ -24,22 +34,61 @@ public ObjectResult(object? value, int statusCode)
public object? Value { get; }

/// <summary>
/// Gets or sets the HTTP status code.
/// Gets the HTTP status code.
/// </summary>
public int StatusCode { get; }
public int? StatusCode { get; set; }

public Task ExecuteAsync(HttpContext httpContext)
{
var loggerFactory = httpContext.RequestServices.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger(GetType());
Log.ObjectResultExecuting(logger, Value);

httpContext.Response.StatusCode = StatusCode;
if (Value is ProblemDetails problemDetails)
{
ApplyProblemDetailsDefaults(problemDetails);
}

if (StatusCode is { } statusCode)
{
httpContext.Response.StatusCode = statusCode;
}

OnFormatting(httpContext);
return httpContext.Response.WriteAsJsonAsync(Value);
}

private void ApplyProblemDetailsDefaults(ProblemDetails problemDetails)
{
// We allow StatusCode to be specified either on ProblemDetails or on the ObjectResult and use it to configure the other.
// This lets users write <c>return Conflict(new Problem("some description"))</c>
// or <c>return Problem("some-problem", 422)</c> and have the response have consistent fields.
if (problemDetails.Status is null)
{
if (StatusCode is not null)
{
problemDetails.Status = StatusCode;
}
else
{
problemDetails.Status = problemDetails is HttpValidationProblemDetails ?
StatusCodes.Status400BadRequest :
StatusCodes.Status500InternalServerError;
}
}

if (StatusCode is null)
{
StatusCode = problemDetails.Status;
}

if (ProblemDetailsDefaults.Defaults.TryGetValue(problemDetails.Status.Value, out var defaults))
{
problemDetails.Title ??= defaults.Title;
problemDetails.Type ??= defaults.Type;
}
}

protected virtual void OnFormatting(HttpContext httpContext)
{
}
Expand Down
2 changes: 2 additions & 0 deletions src/Http/Http.Results/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ static Microsoft.AspNetCore.Http.Results.PhysicalFile(string! physicalPath, stri
static Microsoft.AspNetCore.Http.Results.PhysicalFile(string! physicalPath, string! contentType, string? fileDownloadName, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag) -> Microsoft.AspNetCore.Http.IResult!
static Microsoft.AspNetCore.Http.Results.PhysicalFile(string! physicalPath, string! contentType, string? fileDownloadName, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult!
static Microsoft.AspNetCore.Http.Results.PhysicalFile(string! physicalPath, string! contentType, string? fileDownloadName, bool enableRangeProcessing) -> Microsoft.AspNetCore.Http.IResult!
static Microsoft.AspNetCore.Http.Results.Problem(string? detail = null, string? instance = null, int? statusCode = null, string? title = null, string? type = null) -> Microsoft.AspNetCore.Http.IResult!
static Microsoft.AspNetCore.Http.Results.Redirect(string! url) -> Microsoft.AspNetCore.Http.IResult!
static Microsoft.AspNetCore.Http.Results.RedirectPermanent(string! url) -> Microsoft.AspNetCore.Http.IResult!
static Microsoft.AspNetCore.Http.Results.RedirectPermanentPreserveMethod(string! url) -> Microsoft.AspNetCore.Http.IResult!
Expand Down Expand Up @@ -102,3 +103,4 @@ static Microsoft.AspNetCore.Http.Results.StatusCode(int statusCode) -> Microsoft
static Microsoft.AspNetCore.Http.Results.Unauthorized() -> Microsoft.AspNetCore.Http.IResult!
static Microsoft.AspNetCore.Http.Results.UnprocessableEntity() -> Microsoft.AspNetCore.Http.IResult!
static Microsoft.AspNetCore.Http.Results.UnprocessableEntity(object? error) -> Microsoft.AspNetCore.Http.IResult!
static Microsoft.AspNetCore.Http.Results.ValidationProblem(System.Collections.Generic.IDictionary<string!, string![]!>! errors, string? detail = null, string? instance = null, int? statusCode = null, string? title = null, string? type = null) -> Microsoft.AspNetCore.Http.IResult!
Loading