Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add omit-values supports #657

Open
wants to merge 2 commits into
base: release-8.x
Choose a base branch
from
Open
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
39 changes: 39 additions & 0 deletions src/Microsoft.AspNetCore.OData/Extensions/HttpRequestExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.OData.Abstracts;
using Microsoft.AspNetCore.OData.Common;
using Microsoft.AspNetCore.OData.Formatter;
using Microsoft.AspNetCore.OData.Formatter.Deserialization;
using Microsoft.AspNetCore.OData.Query;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -71,6 +72,44 @@ public static ODataOptions ODataOptions(this HttpRequest request)
return request.HttpContext.ODataOptions();
}

/// <summary>
/// Returns the <see cref="OmitValuesKind"/> from the request.
Copy link
Contributor

Choose a reason for hiding this comment

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

For consistency with the method name, I'd suggest a small rename:

Suggested change
/// Returns the <see cref="OmitValuesKind"/> from the request.
/// Gets the <see cref="OmitValuesKind"/> from the request.

/// </summary>
/// <param name="request">The <see cref="HttpRequest"/> instance to access.</param>
/// <returns>The <see cref="OmitValuesKind"/> from the request.</returns>
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we also document the exception here?

Suggested change
/// <returns>The <see cref="OmitValuesKind"/> from the request.</returns>
/// <returns>The <see cref="OmitValuesKind"/> from the request.</returns>
/// <exception cref="ArgumentNullException"><paramref name="request"/> is <see langword="null"/>.</exception>

public static OmitValuesKind GetOmitValuesKind(this HttpRequest request)
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this indicate that it is a header value, or even a Prefer Header. Something like GetOmitValueHeaderKind

Copy link
Member Author

Choose a reason for hiding this comment

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

Emm, actually it's not only from request header, it's also from ODataOption setting if there's no setting.

But, I am curious which one is a higher priority. @mikepizzo

Copy link
Contributor

Choose a reason for hiding this comment

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

What about GetOmitValuesPreferenceKind, or even just GetOmitValuesPreference with a rename on the enum to OmitValuesPreference?

{
if (request == null)
Copy link
Contributor

Choose a reason for hiding this comment

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

For constant comparisons, I'd suggest using constant pattern matching on new code:

Suggested change
if (request == null)
if (request is null)

{
throw Error.ArgumentNull(nameof(request));
}

// The 'Prefer' header from client has the top priority
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of having code blocks with comments such as this, I'd suggest extracting each "strategy" into at least a local function, and then calling them in the explicit fallback order, something like:

return GetOmitValuePreferenceFromHeaders() ?? 
    GetOmitValuePreferenceFromODataConfiguration();

This not only cleanly isolates each "strategy" into a named method (most likely removing the need for the comment itself), but also models the fallback logic in a more clean and direct way that is more apparent to the reader.

string preferHeader = RequestPreferenceHelpers.GetRequestPreferHeader(request.Headers);
if (preferHeader != null)
{
// use case insensitive string comparison
if (preferHeader.Contains("omit-values=nulls", StringComparison.OrdinalIgnoreCase))
{
return OmitValuesKind.Nulls;
}

if (preferHeader.Contains("omit-values=defaults", StringComparison.OrdinalIgnoreCase))
{
return OmitValuesKind.Defaults;
}
Comment on lines +92 to +100
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a possibility for both 'omit-values=defaults' and 'omit-values=nulls' to be added as headers in a request? In that case won't one of them be ignored?

}

// If there's no setting from client, we move to use the configuration on the OData options.
ODataOptions options = request.ODataOptions();
if (options == null)
{
return OmitValuesKind.Unknown;
}

return options.OmitValuesKind;
}

/// <summary>
/// Gets the <see cref="IEdmModel"/> from the request container.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,9 @@ public override void WriteResponseHeaders(OutputFormatterWriteContext context)

// Add version header.
response.Headers["OData-Version"] = ODataUtils.ODataVersionToString(request.GetODataVersion());

// Set the prefer-applied header on response
ResponsePreferenceHelpers.SetResponsePreferAppliedHeader(response);
}

/// <inheritdoc/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts;
using System.IO;
using System.Linq;
using System.Runtime.Serialization;
Expand All @@ -24,7 +23,6 @@
using Microsoft.Net.Http.Headers;
using Microsoft.OData;
using Microsoft.OData.Edm;
using Microsoft.OData.ModelBuilder;
using Microsoft.OData.UriParser;

namespace Microsoft.AspNetCore.OData.Formatter
Expand Down Expand Up @@ -142,6 +140,7 @@ internal static async Task WriteToStreamAsync(
writeContext.MetadataLevel = metadataLevel;
writeContext.QueryOptions = queryOptions;
writeContext.SetComputedProperties(queryOptions?.Compute?.ComputeClause);
writeContext.OmitValuesKind = request.GetOmitValuesKind();

//Set the SelectExpandClause on the context if it was explicitly specified.
if (selectExpandDifferentFromQueryOptions != null)
Expand Down
33 changes: 33 additions & 0 deletions src/Microsoft.AspNetCore.OData/Formatter/OmitValuesKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//-----------------------------------------------------------------------------
// <copyright file="OmitValuesKind.cs" company=".NET Foundation">
// Copyright (c) .NET Foundation and Contributors. All rights reserved.
// See License.txt in the project root for license information.
// </copyright>
//------------------------------------------------------------------------------

namespace Microsoft.AspNetCore.OData.Formatter
{
/// <summary>
/// Omit-Values kind
/// </summary>
public enum OmitValuesKind
{
/// <summary>
/// Not set, unknown
/// </summary>
Unknown,
Copy link
Contributor

Choose a reason for hiding this comment

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

Have you considered just not having this entry at all? Since it is completely invalid/not part of the spec, I don't think it is a good idea to even map it.

If there are any places where the value is optional, a nullable OmitValuesKind could be used.


/// <summary>
/// If nulls is specified, then the service MAY omit properties containing null values from the response,
/// in which case it MUST specify the Preference-Applied response header with omit-values=nulls.
/// </summary>
Nulls,

/// <summary>
/// If defaults is specified, then the service MAY omit properties containing default values from the response, including nulls for properties that have no other defined default value.
/// Nulls MUST be included for properties that have a non-null default value defined.
/// If the service omits default values it MUST specify the Preference-Applied response header with omit-values=defaults.
/// </summary>
Defaults
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//-----------------------------------------------------------------------------
// <copyright file="ResponsePreferenceHelpers.cs" company=".NET Foundation">
// Copyright (c) .NET Foundation and Contributors. All rights reserved.
// See License.txt in the project root for license information.
// </copyright>
//------------------------------------------------------------------------------

using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.OData.Extensions;
using Microsoft.Extensions.Primitives;

namespace Microsoft.AspNetCore.OData.Formatter
{
internal static class ResponsePreferenceHelpers
{
public const string PreferAppliedHeaderName = "Preference-Applied";

public static void SetResponsePreferAppliedHeader(HttpResponse response)
{
if (response == null)
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if (response == null)
if (response is null)

{
throw Error.ArgumentNull(nameof(response));
}

HttpRequest request = response.HttpContext.Request;

OmitValuesKind omitValuesKind = request.GetOmitValuesKind();
if (omitValuesKind == OmitValuesKind.Unknown)
{
return;
}
Comment on lines +29 to +32
Copy link
Contributor

Choose a reason for hiding this comment

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

Again, not a big fan of having this kind of logic. Can't we just make it so that GetOmitValuesKind returns a nullable, then use null as the indicator for "no value found", which is common for get-like methods?


string prefer_applied = null;
if (response.Headers.TryGetValue(PreferAppliedHeaderName, out StringValues values))
{
// If there are many "Preference-Applied" headers, pick up the first one.
prefer_applied = values.FirstOrDefault();
Comment on lines +37 to +38
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you elaborate why we are picking the first one? Shouldn't we just append ours to the end of the existing list no matter how many there are?

}

string omitValuesHead = omitValuesKind == OmitValuesKind.Nulls ?
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
string omitValuesHead = omitValuesKind == OmitValuesKind.Nulls ?
string omitValuesHeaderValue = omitValuesKind == OmitValuesKind.Nulls ?

"omit-values=nulls" :
"omit-values=defaults";
Comment on lines +42 to +43
Copy link
Contributor

Choose a reason for hiding this comment

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

We are referencing these hardcoded strings in multiple places. I think it should be extracted to consts to avoid sharing them as hardcoded data.

Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of handling these with raw strings, have you considered creating a small model and/or factory method for it?

Imagine something like

public sealed class PreferHeader
{
    public static PreferHeader OmitValues(OmitValuesKind kind)
    {
        return new("omit-values", kind.ToText())
    }
}

This would remove the need to ever handle raw strings. You can parse values into this model and use factory methods to create strongly typed instances for each preference OData has.


if (prefer_applied == null)
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if (prefer_applied == null)
if (prefer_applied is null)

{
response.Headers[PreferAppliedHeaderName] = omitValuesHead;
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of manually accessing each header like this, have you considered creating something like the native GetTypedHeaders, where each header is modeled as its own property with strong typing?
https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.headerdictionarytypeextensions.gettypedheaders?view=aspnetcore-6.0

It could be called GetTypedODataHeaders or something similar.

}
else
{
response.Headers[PreferAppliedHeaderName] = $"{prefer_applied},{omitValuesHead}";
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there any way to avoid this manual string concatenation by relying on the StringValues type itself to do it?

We could probably "add" the omit setting to the existing StringValues instance and then write that directly.

}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -749,16 +749,16 @@ private async Task WriteDynamicComplexPropertiesAsync(ResourceContext resourceCo
if (edmTypeReference.IsStructured() ||
(edmTypeReference.IsCollection() && edmTypeReference.AsCollection().ElementType().IsStructured()))
{
ODataNestedResourceInfo nestedResourceInfo = new ODataNestedResourceInfo
{
IsCollection = edmTypeReference.IsCollection(),
Name = dynamicComplexProperty.Key,
};
ODataNestedResourceInfo nestedResourceInfo
= CreateDynamicComplexNestedResourceInfo(dynamicComplexProperty.Key, dynamicComplexProperty.Value, edmTypeReference, resourceContext);

await writer.WriteStartAsync(nestedResourceInfo).ConfigureAwait(false);
await WriteDynamicComplexPropertyAsync(dynamicComplexProperty.Value, edmTypeReference, resourceContext, writer)
.ConfigureAwait(false);
await writer.WriteEndAsync().ConfigureAwait(false);
if (nestedResourceInfo != null)
{
await writer.WriteStartAsync(nestedResourceInfo).ConfigureAwait(false);
await WriteDynamicComplexPropertyAsync(dynamicComplexProperty.Value, edmTypeReference, resourceContext, writer)
.ConfigureAwait(false);
await writer.WriteEndAsync().ConfigureAwait(false);
}
}
}
}
Expand Down Expand Up @@ -834,16 +834,14 @@ private async Task WriteComplexPropertiesAsync(SelectExpandNode selectExpandNode
{
IEdmStructuralProperty complexProperty = selectedComplex.Key;

ODataNestedResourceInfo nestedResourceInfo = new ODataNestedResourceInfo
ODataNestedResourceInfo nestedResourceInfo = CreateComplexNestedResourceInfo(complexProperty, selectedComplex.Value, resourceContext);
if (nestedResourceInfo != null)
{
IsCollection = complexProperty.Type.IsCollection(),
Name = complexProperty.Name
};

await writer.WriteStartAsync(nestedResourceInfo).ConfigureAwait(false);
await WriteComplexAndExpandedNavigationPropertyAsync(complexProperty, selectedComplex.Value, resourceContext, writer)
.ConfigureAwait(false);
await writer.WriteEndAsync().ConfigureAwait(false);
await writer.WriteStartAsync(nestedResourceInfo).ConfigureAwait(false);
await WriteComplexAndExpandedNavigationPropertyAsync(complexProperty, selectedComplex.Value, resourceContext, writer)
.ConfigureAwait(false);
await writer.WriteEndAsync().ConfigureAwait(false);
}
}
}

Expand Down Expand Up @@ -960,6 +958,66 @@ private IEnumerable<ODataNestedResourceInfo> CreateNavigationLinks(
}
}

/// <summary>
/// Creates the <see cref="ODataNestedResourceInfo"/> to be written while writing this dynamic complex property.
/// </summary>
/// <param name="propertyName">The dynamic property name.</param>
/// <param name="propertyValue">The dynamic property value.</param>
/// <param name="edmType">The edm type reference.</param>
/// <param name="resourceContext">The context for the complex instance being written.</param>
/// <returns>The nested resource info to be written. Returns 'null' will omit this serialization.</returns>
/// <remarks>It enables customer to get more controll by overriding this method. </remarks>
public virtual ODataNestedResourceInfo CreateDynamicComplexNestedResourceInfo(string propertyName, object propertyValue, IEdmTypeReference edmType, ResourceContext resourceContext)
{
if (edmType == null)
{
throw Error.ArgumentNull(nameof(edmType));
}

return new ODataNestedResourceInfo
{
IsCollection = edmType.IsCollection(),
Name = propertyName,
};
}

/// <summary>
/// Creates the <see cref="ODataNestedResourceInfo"/> to be written while writing this complex property.
/// </summary>
/// <param name="complexProperty">The complex property for which the nested resource info is being created.</param>
/// <param name="pathSelectItem">The corresponding sub select item belongs to this complex property.</param>
/// <param name="resourceContext">The context for the complex instance being written.</param>
/// <returns>The nested resource info to be written. Returns 'null' will omit this complex serialization.</returns>
/// <remarks>It enables customer to get more controll by overriding this method. </remarks>
public virtual ODataNestedResourceInfo CreateComplexNestedResourceInfo(IEdmStructuralProperty complexProperty, PathSelectItem pathSelectItem, ResourceContext resourceContext)
{
if (complexProperty == null)
{
throw Error.ArgumentNull(nameof(complexProperty));
}

if (resourceContext == null)
{
throw Error.ArgumentNull(nameof(resourceContext));
}


if (resourceContext.SerializerContext.OmitValuesKind == OmitValuesKind.Nulls)
{
object propertyValue = resourceContext.GetPropertyValue(complexProperty.Name);
if (propertyValue == null || propertyValue is NullEdmComplexObject)
{
return null;
}
}

return new ODataNestedResourceInfo
{
IsCollection = complexProperty.Type.IsCollection(),
Name = complexProperty.Name
};
}

/// <summary>
/// Creates the <see cref="ODataNestedResourceInfo"/> to be written while writing this entity.
/// </summary>
Expand All @@ -978,6 +1036,15 @@ public virtual ODataNestedResourceInfo CreateNavigationLink(IEdmNavigationProper
throw Error.ArgumentNull(nameof(resourceContext));
}

if (resourceContext.SerializerContext.OmitValuesKind == OmitValuesKind.Nulls)
{
object propertyValue = resourceContext.GetPropertyValue(navigationProperty.Name);
if (propertyValue == null)
{
return null;
}
}

ODataSerializerContext writeContext = resourceContext.SerializerContext;
IEdmNavigationSource navigationSource = writeContext.NavigationSource;
ODataNestedResourceInfo navigationLink = null;
Expand Down Expand Up @@ -1160,6 +1227,21 @@ public virtual ODataProperty CreateStructuralProperty(IEdmStructuralProperty str

object propertyValue = resourceContext.GetPropertyValue(structuralProperty.Name);

if (resourceContext.SerializerContext.OmitValuesKind == OmitValuesKind.Nulls && propertyValue == null)
{
return null; // omit null value
}

if (resourceContext.SerializerContext.OmitValuesKind == OmitValuesKind.Defaults &&
structuralProperty.DefaultValueString != null &&
structuralProperty.Type.IsString() && propertyValue != null) // let's focus on the "Edm.String" property only, NOW!!
{
if (propertyValue.ToString() == structuralProperty.DefaultValueString)
{
return null; // omit default value
}
}

IEdmTypeReference propertyType = structuralProperty.Type;
if (propertyValue != null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ internal ODataSerializerContext(ResourceContext resource, IEdmProperty edmProper
Items = context.Items;
ExpandReference = context.ExpandReference;
TimeZone = context.TimeZone;
OmitValuesKind = context.OmitValuesKind;

QueryContext = queryContext;

Expand Down Expand Up @@ -180,6 +181,11 @@ internal ODataSerializerContext(ResourceContext resource, IEdmProperty edmProper
/// </summary>
public TimeZoneInfo TimeZone { get; set; }

/// <summary>
/// Gets or sets the <see cref="OmitValuesKind"/>.
/// </summary>
public OmitValuesKind OmitValuesKind { get; set; }

/// <summary>
/// Gets or sets the <see cref="ODataQueryOptions"/>.
/// </summary>
Expand Down
Loading