From 0feec0ac5ff3f0abf4dc0a9bf016831c8b7627ac Mon Sep 17 00:00:00 2001 From: John Gathogo Date: Wed, 27 Jul 2022 12:34:33 +0300 Subject: [PATCH] Resolve ContentID in odata.bind annotation (#643) --- .../ODataResourceDeserializer.cs | 44 ++-- .../Batch/ContentIdToLocationMappingTests.cs | 200 ++++++++++++++++++ 2 files changed, 229 insertions(+), 15 deletions(-) create mode 100644 test/Microsoft.AspNetCore.OData.E2E.Tests/Batch/ContentIdToLocationMappingTests.cs diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataResourceDeserializer.cs b/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataResourceDeserializer.cs index e99c87358..ce0cc6b6f 100644 --- a/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataResourceDeserializer.cs +++ b/src/Microsoft.AspNetCore.OData/Formatter/Deserialization/ODataResourceDeserializer.cs @@ -14,7 +14,9 @@ using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Serialization; +using System.Text.RegularExpressions; using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.OData.Deltas; using Microsoft.AspNetCore.OData.Edm; using Microsoft.AspNetCore.OData.Extensions; @@ -22,6 +24,7 @@ using Microsoft.AspNetCore.OData.Formatter.Wrapper; using Microsoft.AspNetCore.OData.Routing; using Microsoft.AspNetCore.OData.Routing.Parser; +using Microsoft.Extensions.DependencyInjection; using Microsoft.OData; using Microsoft.OData.Edm; using Microsoft.OData.UriParser; @@ -33,6 +36,8 @@ namespace Microsoft.AspNetCore.OData.Formatter.Deserialization /// public class ODataResourceDeserializer : ODataEdmTypeDeserializer { + private static readonly Regex ContentIdReferencePattern = new Regex(@"\$\d", RegexOptions.Compiled); + /// /// Initializes a new instance of the class. /// @@ -376,7 +381,7 @@ public virtual void ApplyNestedProperty(object resource, ODataNestedResourceInfo } IList nestedItems; - var referenceLinks = resourceInfoWrapper.NestedItems.OfType().ToArray(); + ODataEntityReferenceLinkWrapper[] referenceLinks = resourceInfoWrapper.NestedItems.OfType().ToArray(); if (referenceLinks.Length > 0) { // Be noted: @@ -578,7 +583,7 @@ private object ReadNestedResourceInline(ODataResourceWrapper resourceWrapper, IE IEdmStructuredTypeReference structuredType = edmType.AsStructured(); - var nestedReadContext = new ODataDeserializerContext + ODataDeserializerContext nestedReadContext = new ODataDeserializerContext { Path = readContext.Path, Model = readContext.Model, @@ -797,7 +802,7 @@ private static ODataResourceWrapper CreateResourceWrapper(IEdmTypeReference edmP resource.Properties = CreateKeyProperties(refLink.EntityReferenceLink.Url, readContext) ?? Array.Empty(); ODataResourceWrapper resourceWrapper = new ODataResourceWrapper(resource); - foreach (var instanceAnnotation in refLink.EntityReferenceLink.InstanceAnnotations) + foreach (ODataInstanceAnnotation instanceAnnotation in refLink.EntityReferenceLink.InstanceAnnotations) { resource.InstanceAnnotations.Add(instanceAnnotation); } @@ -835,7 +840,7 @@ private ODataResourceWrapper UpdateResourceWrapper(ODataResourceWrapper resource else { IDictionary newPropertiesDic = resourceWrapper.Resource.Properties.ToDictionary(p => p.Name, p => p); - foreach (var key in keys) + foreach (ODataProperty key in keys) { // Logic: if we have the key property, try to keep the key property and get rid of the key value from ID. // Need to double confirm whether it is the right logic? @@ -870,26 +875,35 @@ private static IList CreateKeyProperties(Uri id, ODataDeserialize try { - Uri serviceRootUri = null; - if (id.IsAbsoluteUri) + IEdmModel model = readContext.Model; + HttpRequest request = readContext.Request; + IServiceProvider requestContainer = request.GetRouteServices(); + Uri resolvedId = id; + + string idOriginalString = id.OriginalString; + if (ContentIdReferencePattern.IsMatch(idOriginalString)) { - string serviceRoot = readContext.Request.CreateODataLink(); - serviceRootUri = new Uri(serviceRoot, UriKind.Absolute); + // We can expect request.ODataBatchFeature() to not be null + string resolvedUri = ContentIdHelpers.ResolveContentId( + idOriginalString, + request.ODataBatchFeature().ContentIdMapping); + resolvedId = new Uri(resolvedUri, UriKind.RelativeOrAbsolute); } - var request = readContext.Request; - IEdmModel model = readContext.Model; - - // TODO: shall we use the DI to inject the path parser? - DefaultODataPathParser pathParser = new DefaultODataPathParser(); + Uri serviceRootUri = new Uri(request.CreateODataLink()); + IODataPathParser pathParser = requestContainer?.GetService(); + if (pathParser == null) // Seems like IODataPathParser is NOT injected into DI container by default + { + pathParser = new DefaultODataPathParser(); + } IList properties = null; - var path = pathParser.Parse(model, serviceRootUri, id, request.GetRouteServices()); + ODataPath path = pathParser.Parse(model, serviceRootUri, resolvedId, requestContainer); KeySegment keySegment = path.OfType().LastOrDefault(); if (keySegment != null) { properties = new List(); - foreach (var key in keySegment.Keys) + foreach (KeyValuePair key in keySegment.Keys) { properties.Add(new ODataProperty { diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Batch/ContentIdToLocationMappingTests.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/Batch/ContentIdToLocationMappingTests.cs new file mode 100644 index 000000000..7cce8a56a --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Batch/ContentIdToLocationMappingTests.cs @@ -0,0 +1,200 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Batch; +using Microsoft.AspNetCore.OData.E2E.Tests.Commons; +using Microsoft.AspNetCore.OData.Routing.Controllers; +using Microsoft.AspNetCore.OData.TestCommon; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OData; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; +using Xunit; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.Batch +{ + public class ContentIdToLocationMappingTests : WebApiTestBase + { + private static IEdmModel edmModel; + + public ContentIdToLocationMappingTests(WebApiTestFixture fixture) + : base(fixture) + { + } + + protected static void UpdateConfigureServices(IServiceCollection services) + { + services.ConfigureControllers( + typeof(ContentIdToLocationMappingParentsController), + typeof(ContentIdToLocationMappingChildrenController)); + + edmModel = GetEdmModel(); + services.AddControllers().AddOData(opt => + { + opt.EnableQueryFeatures(); + opt.EnableContinueOnErrorHeader = true; + opt.AddRouteComponents("ContentIdToLocationMapping", edmModel, new DefaultODataBatchHandler()); + }); + } + + protected static void UpdateConfigure(IApplicationBuilder app) + { + app.UseODataBatching(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + + protected static IEdmModel GetEdmModel() + { + ODataModelBuilder builder = new ODataConventionModelBuilder(); + builder.EntitySet("ContentIdToLocationMappingParents"); + builder.EntitySet("ContentIdToLocationMappingChildren"); + builder.Namespace = typeof(ContentIdToLocationMappingParent).Namespace; + + return builder.GetEdmModel(); + } + + [Fact] + public async Task CanResolveContentIdInODataBindAnnotationAsync() + { + // Arrange + HttpClient client = CreateClient(); + string serviceBase = $"{client.BaseAddress}ContentIdToLocationMapping"; + string requestUri = $"{serviceBase}/$batch"; + string parentsUri = $"{serviceBase}/ContentIdToLocationMappingParents"; + string childrenUri = $"{serviceBase}/ContentIdToLocationMappingChildren"; + string payload = "{" + + " \"requests\": [" + + " {" + + " \"id\": \"1\"," + + " \"method\": \"POST\"," + + $" \"url\": \"{parentsUri}\"," + + " \"headers\": {" + + " \"OData-Version\": \"4.0\"," + + " \"Content-Type\": \"application/json;odata.metadata=minimal\"," + + " \"Accept\": \"application/json;odata.metadata=minimal\"" + + " }," + + " \"body\": {\"ParentId\":123}" + + " }," + + " {" + + " \"id\": \"2\"," + + " \"method\": \"POST\"," + + $" \"url\": \"{childrenUri}\"," + + " \"headers\": {" + + " \"OData-Version\": \"4.0\"," + + " \"Content-Type\": \"application/json;odata.metadata=minimal\"," + + " \"Accept\": \"application/json;odata.metadata=minimal\"" + + " }," + + " \"body\": {" + + " \"Parent@odata.bind\": \"$1\"" + + " }" + + " }" + + " ]" + + "}"; + + // Act + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, requestUri); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json")); + request.Content = new StringContent(payload); + request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json"); + HttpResponseMessage response = await client.SendAsync(request); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var stream = await response.Content.ReadAsStreamAsync(); + IODataResponseMessage odataResponseMessage = new ODataMessageWrapper(stream, response.Content.Headers); + int subResponseCount = 0; + using (var messageReader = new ODataMessageReader(odataResponseMessage, new ODataMessageReaderSettings(), edmModel)) + { + var batchReader = messageReader.CreateODataBatchReader(); + while (batchReader.Read()) + { + switch (batchReader.State) + { + case ODataBatchReaderState.Operation: + var operationMessage = batchReader.CreateOperationResponseMessage(); + subResponseCount++; + Assert.Equal(201, operationMessage.StatusCode); + break; + } + } + } + + // NOTE: We assert that $1 is successfully resolved from the controller action + Assert.Equal(2, subResponseCount); + } + } + + public class ContentIdToLocationMappingParentsController : ODataController + { + public ActionResult Post([FromBody] ContentIdToLocationMappingParent parent) + { + return Created(new Uri($"{Request.Scheme}://{Request.Host}{Request.Path}/{parent.ParentId}"), parent); + } + } + + public class ContentIdToLocationMappingChildrenController : ODataController + { + public ActionResult Post([FromBody] ContentIdToLocationMappingChild child) + { + Assert.Equal(123, child.Parent.ParentId); + + return Created(new Uri($"{Request.Scheme}://{Request.Host}{Request.Path}/{child.ChildId}"), child); + } + } + + public class ContentIdToLocationMappingParent + { + public ContentIdToLocationMappingParent() + { + Children = new HashSet(); + } + + [Key] + public int ParentId + { + get; set; + } + + public virtual ICollection Children + { + get; set; + } + } + + public class ContentIdToLocationMappingChild + { + [Key] + public int ChildId + { + get; set; + } + + public int? ParentId + { + get; set; + } + + public virtual ContentIdToLocationMappingParent Parent + { + get; set; + } + } +}