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 ReplaceIllegalFieldNameCharactersAttribute, add related tests, fixes issue #1269 #1273

Open
wants to merge 3 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System;
using System.Linq;

namespace Microsoft.AspNetCore.OData.Formatter.Attributes
{
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
sealed class ReplaceIllegalFieldNameCharactersAttribute : Attribute
Copy link
Member

Choose a reason for hiding this comment

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

Use an attribute to change the property name looks weird to me. Since, it requires customers to change their data model by decorating this attribute. Most of time, it's not acceptable.

I think we can have a different option:

  1. Create a service (an interface) named 'IEscapeCharacterService' or similar
  2. Consume this service during 'AppendDynamicProperties'
  3. If we have this service registered, calling it. Otherwise, let it go and no other impact for all existing customers

Where to register this service?

  1. We can let it register into the DI, or
  2. We can let it register into the Edm Model?

Copy link
Author

@marcus905 marcus905 Jul 5, 2024

Choose a reason for hiding this comment

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

I used the attribute to avoid introducing any breaking change like automatic renaming for everyone (breaking the code for users manually disabling the validator, as seen in issue #1423 of OData.net OData/odata.net#1423 for example) and to provide an avenue to allow choosing the new replacement strings for each of the invalid characters.

Moreover, having the user code a service that implements that interface himself if they need it when it is actually very straightforward to implement it easily, verifiably and correctly for the scope of the fix (which is not a facility to let people manipulate the property names, but merely a choice between fail-if-illegal-char-in-property-name and rename-property-and-serialize, with just a hint of customization on replacements @ may become _, or x0040 or anything the user wants that is acceptable) would be to burden them with another fair bit of avoidable boilerplate code.

If the user is already using the lib successfully, then there will be no need to apply that attribute and the renaming would not happen at all (no regressions on other tests, assert exception passed on the invalid mapped dynamic property dictionary) and so customers/user would be safe as no downstream code changes will be needed for normal operations.

If the user, instead, needs to fix the property names (and mind you, even if I do not foresee it being ever used in this way) this way, by annotating the model class, it would work on both dynamic and static properties which have a specific name set all the same.

I concede that the naming of the attribute might not be the best, but that can be changed.

Moreover, annotating entities in this way is a Model pattern classic (EF for example, but it's also used in several other different libraries in other frameworks and programming languages.)

I still consider this to be the best approach, but if this is deemed to not be acceptable for the project, I'll try yours.

Please let me know.

{
//constant collection of illegal characters
private static readonly string[] illegalChars = new string[] { "@", ":", ".", "#" };
public string ReplaceAt { get; }
public string ReplaceColon { get; }
public string ReplaceDot { get; }
public string ReplaceHash { get; }

public ReplaceIllegalFieldNameCharactersAttribute(string replaceAt, string replaceColon, string replaceDot, string replaceHash)
{
//check if the replacement characters are not null
if (replaceAt == null || replaceColon == null || replaceDot == null)
{
throw new ArgumentNullException("Replacement characters cannot be null");
}

// check if any of the the replacement characters provided contain any of the illegal characters
// ex. if replaceAt contains any of the illegal characters checked one by one
if (illegalChars.Any(illegalChar => replaceAt.Contains(illegalChar) || replaceColon.Contains(illegalChar) || replaceDot.Contains(illegalChar)))
{
throw new ArgumentException("Replacement character cannot be an illegal character");
}

ReplaceAt = replaceAt;
ReplaceColon = replaceColon;
ReplaceDot = replaceDot;
ReplaceHash = replaceHash;
}

public ReplaceIllegalFieldNameCharactersAttribute(string replaceAnyIllegal)
{
if (illegalChars.Any(illegalChar => replaceAnyIllegal.Contains(illegalChar)))
{
throw new ArgumentException("Replacement character cannot be an illegal character");
}

ReplaceAt = replaceAnyIllegal;
ReplaceColon = replaceAnyIllegal;
ReplaceDot = replaceAnyIllegal;
ReplaceHash = replaceAnyIllegal;
}

public ReplaceIllegalFieldNameCharactersAttribute()
{
ReplaceAt = "_";
ReplaceColon = "_";
ReplaceDot = "_";
ReplaceHash = "_";
}

public string Replace(string fieldName)
{
return fieldName.Replace("@", ReplaceAt).Replace(":", ReplaceColon).Replace(".", ReplaceDot).Replace("#",ReplaceHash);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
using Microsoft.AspNetCore.OData.Common;
using System.Threading.Tasks;
using Microsoft.AspNetCore.OData.Deltas;
using Microsoft.AspNetCore.OData.Formatter.Attributes;

namespace Microsoft.AspNetCore.OData.Formatter.Serialization
{
Expand Down Expand Up @@ -551,6 +552,20 @@ public virtual ODataResource CreateResource(SelectExpandNode selectExpandNode, R
// Try to add the dynamic properties if the structural type is open.
AppendDynamicProperties(resource, selectExpandNode, resourceContext);

// check if the type is annotated with ReplaceIllegalFieldNameCharactersAttribute and replace the illegal characters in the field names
var resourceInstance = resourceContext.ResourceInstance;
if (resourceInstance != null)
{
var replaceIllegalFieldNameCharactersAttribute = resourceInstance.GetType().GetCustomAttribute<ReplaceIllegalFieldNameCharactersAttribute>();
if (replaceIllegalFieldNameCharactersAttribute != null)
{
foreach (var property in resource.Properties)
{
property.Name = replaceIllegalFieldNameCharactersAttribute.Replace(property.Name);
}
}
}

if (selectExpandNode.SelectedActions != null)
{
IEnumerable<ODataAction> actions = CreateODataActions(selectExpandNode.SelectedActions, resourceContext);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.AspNetCore.OData.Extensions;
using Microsoft.AspNetCore.OData.Formatter;
using Microsoft.AspNetCore.OData.Formatter.Attributes;
using Microsoft.AspNetCore.OData.Formatter.Value;
using Microsoft.AspNetCore.OData.Tests.Commons;
using Microsoft.AspNetCore.OData.Tests.Extensions;
Expand Down Expand Up @@ -232,9 +235,107 @@ public void TryGetContentHeaderODataOutputFormatter_ThrowsArgumentNull_Type()
ExceptionAssert.ThrowsArgumentNull(() => ODataOutputFormatter.TryGetContentHeader(null, null, out _), "type");
}

[Fact]
public void SerializeIllegalUnannotatedObject_ThrowsInvalidOperationException()
{
// Arrange
var illegalObject = new IllegalUnannotatedObject
{
DynamicProperties = new Dictionary<string, object>
{
{ "Inv@l:d.", 1 }
}
};

ODataConventionModelBuilder builder = new ODataConventionModelBuilder();
builder.EntitySet<IllegalUnannotatedObject>("IllegalUnannotatedObjects");
IEdmModel model = builder.GetEdmModel();
IEdmEntitySet entitySet = model.EntityContainer.FindEntitySet("IllegalUnannotatedObjects");
EntitySetSegment entitySetSeg = new EntitySetSegment(entitySet);
HttpRequest request = RequestFactory.Create(opt => opt.AddRouteComponents("odata", model));
request.ODataFeature().RoutePrefix = "odata";
request.ODataFeature().Model = model;
request.ODataFeature().Path = new ODataPath(entitySetSeg);

OutputFormatterWriteContext context = new OutputFormatterWriteContext(
request.HttpContext,
(s, e) => null,
objectType: typeof(IllegalUnannotatedObject),
@object: illegalObject);

ODataOutputFormatter formatter = new ODataOutputFormatter(new[] { ODataPayloadKind.Resource });
formatter.SupportedMediaTypes.Add("application/json");

// Act & Assert
Assert.Throws<ODataException>(() => formatter.WriteResponseBodyAsync(context, Encoding.UTF8).GetAwaiter().GetResult());
}

// positive test as above
[Fact]
public void SerializeIllegalAnnotatedObject_ReturnsFixedValidObject()
{
// Arrange
var illegalObject = new IllegalAnnotatedObject
{
DynamicProperties = new Dictionary<string, object>
{
{ "I#v@l:d.", 1 }
}
};

ODataConventionModelBuilder builder = new ODataConventionModelBuilder();
builder.EntitySet<IllegalAnnotatedObject>("IllegalAnnotatedObject");
IEdmModel model = builder.GetEdmModel();
IEdmEntitySet entitySet = model.EntityContainer.FindEntitySet("IllegalAnnotatedObject");
EntitySetSegment entitySetSeg = new EntitySetSegment(entitySet);
HttpRequest request = RequestFactory.Create(opt => opt.AddRouteComponents("odata", model));
request.ODataFeature().RoutePrefix = "odata";
request.ODataFeature().Model = model;
request.ODataFeature().Path = new ODataPath(entitySetSeg);

OutputFormatterWriteContext context = new OutputFormatterWriteContext(
request.HttpContext,
(s, e) => null,
objectType: typeof(IllegalAnnotatedObject),
@object: illegalObject);

ODataOutputFormatter formatter = new ODataOutputFormatter(new[] { ODataPayloadKind.Resource });
formatter.SupportedMediaTypes.Add("application/json");

// Set the Response.Body to a new MemoryStream to capture the response
var memoryStream = new MemoryStream();
context.HttpContext.Response.Body = memoryStream;

// Act
formatter.WriteResponseBodyAsync(context, Encoding.UTF8).GetAwaiter().GetResult();

memoryStream.Position = 0;
var content = new StreamReader(memoryStream).ReadToEnd();
var jd = System.Text.Json.JsonDocument.Parse(content);
var root = jd.RootElement;

// Assert
// check that the JSON response contains the fixed property name and its value is 1
Assert.Equal(1, root.GetProperty("I_v_l_d_").GetInt32());
}

private class Customer
{
public int Id { get; set; }
}

private class IllegalUnannotatedObject
{
[Key]
public int Id { get; set; }
public IDictionary<string, object> DynamicProperties { get; set; }
}

[ReplaceIllegalFieldNameCharacters]
private class IllegalAnnotatedObject : IllegalUnannotatedObject
{

}

}
}