diff --git a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs
index c0347ba46..90f3d151e 100644
--- a/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs
+++ b/src/Microsoft.AspNetCore.OData/Formatter/Serialization/ODataResourceSerializer.cs
@@ -581,7 +581,18 @@ public virtual ODataResource CreateResource(SelectExpandNode selectExpandNode, R
if (resourceContext.StructuredType.TypeKind == EdmTypeKind.Entity && resourceContext.NavigationSource != null)
{
- if (!(resourceContext.NavigationSource is IEdmContainedEntitySet))
+ // Condition 1. If resourceContext.NavigationSource is a contained entity set
+ // and a contained resource is being written, the id/read/edit links can be derived
+ // from the entity set or parent resource, i.e., no need to use link builder to build the links.
+ // Condition 2. If resourceContext.NavigationSource is a contained entity set
+ // but an expanded non-contained resource is being written,
+ // deriving the id/read/edit links from the entity set or parent resource will
+ // most likely result into invalid links.
+ // A navigation property binding should exist and we should try
+ // to use the navigation link builder to build the links.
+ // NOTE: resourceContext.SerializerContext.NavigationProperty will not be null when writing an expanded resource
+ if (!(resourceContext.NavigationSource is IEdmContainedEntitySet)
+ || resourceContext.SerializerContext.NavigationProperty?.ContainsTarget == false)
{
IEdmModel model = resourceContext.SerializerContext.Model;
NavigationSourceLinkBuilderAnnotation linkBuilder = EdmModelLinkBuilderExtensions.GetNavigationSourceLinkBuilder(model, resourceContext.NavigationSource);
diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesController.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesController.cs
new file mode 100644
index 000000000..31097a9fe
--- /dev/null
+++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesController.cs
@@ -0,0 +1,98 @@
+//-----------------------------------------------------------------------------
+//
+// Copyright (c) .NET Foundation and Contributors. All rights reserved.
+// See License.txt in the project root for license information.
+//
+//------------------------------------------------------------------------------
+
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Core;
+using Microsoft.AspNetCore.OData.Query;
+using Microsoft.AspNetCore.OData.Routing.Controllers;
+
+namespace Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties
+{
+ [Route("NonContainedNavPropInContainedNavSource")]
+ [Route("ContainedNavPropInContainedNavSource")]
+ public class SitesController : ODataController
+ {
+ [EnableQuery]
+ [HttpGet("Sites")]
+ public ActionResult> Get()
+ {
+ return MetadataPropertiesDataSource.Sites;
+ }
+
+ [EnableQuery]
+ [HttpGet("Sites({key})")]
+ public ActionResult Get(int key)
+ {
+ var site = MetadataPropertiesDataSource.Sites.SingleOrDefault(d => d.Id == key);
+
+ if (site == null)
+ {
+ return NotFound();
+ }
+
+ return site;
+ }
+
+ [EnableQuery]
+ [HttpGet("Sites({key})/Plants")]
+ public ActionResult> GetPlants(int key)
+ {
+ var site = MetadataPropertiesDataSource.Sites.SingleOrDefault(d => d.Id == key);
+
+ if (site == null || site.Plants == null)
+ {
+ return Enumerable.Empty().ToList();
+ }
+
+ return site.Plants.ToList();
+ }
+
+ [EnableQuery]
+ [HttpGet("Sites({siteKey})/Plants({plantKey})")]
+ public ActionResult GetPlant(int siteKey, int plantKey)
+ {
+ var plant = MetadataPropertiesDataSource.Sites.SingleOrDefault(d => d.Id == siteKey)?.Plants?.SingleOrDefault(d => d.Id == plantKey);
+
+ if (plant == null)
+ {
+ return NotFound();
+ }
+
+ return plant;
+ }
+
+ [EnableQuery]
+ [HttpGet("Sites({siteKey})/Plants({plantKey})/Pipelines")]
+ public ActionResult> GetPipelines(int siteKey, int plantKey)
+ {
+ var plant = MetadataPropertiesDataSource.Sites.SingleOrDefault(d => d.Id == siteKey)?.Plants?.SingleOrDefault(d => d.Id == plantKey);
+
+ if (plant == null || plant.Pipelines == null)
+ {
+ return Enumerable.Empty().ToList();
+ }
+
+ return plant.Pipelines.ToList();
+ }
+
+ [EnableQuery]
+ [HttpGet("Sites({siteKey})/Plants({plantKey})/Pipelines({pipelineKey})")]
+ public ActionResult GetPlantPipeline(int siteKey, int plantKey, int pipelineKey)
+ {
+ var pipeline = MetadataPropertiesDataSource.Sites.SingleOrDefault(d => d.Id == siteKey)?.Plants?.SingleOrDefault(d => d.Id == plantKey)?.Pipelines?.SingleOrDefault(d => d.Id == pipelineKey);
+
+ if (pipeline == null)
+ {
+ return NotFound();
+ }
+
+ return pipeline;
+ }
+ }
+}
diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesDataModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesDataModel.cs
new file mode 100644
index 000000000..d0fd98ecc
--- /dev/null
+++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesDataModel.cs
@@ -0,0 +1,50 @@
+//-----------------------------------------------------------------------------
+//
+// Copyright (c) .NET Foundation and Contributors. All rights reserved.
+// See License.txt in the project root for license information.
+//
+//------------------------------------------------------------------------------
+
+namespace Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Core
+{
+ using System.Collections.Generic;
+ using System.ComponentModel.DataAnnotations;
+ using Microsoft.OData.ModelBuilder;
+
+ public abstract class EntityBase
+ {
+ [Key]
+ public int Id { get; set; }
+ public string Name { get; set; }
+ public Dictionary Attributes { get; set; }
+ }
+
+ public class Site : EntityBase
+ {
+ [Contained]
+ public IEnumerable Plants { get; set; }
+ }
+
+ public class Plant : EntityBase
+ {
+ public Site Site { get; set; }
+ [Contained]
+ public IEnumerable Pipelines { get; set; }
+ }
+
+ public abstract class PipelineBase : EntityBase
+ {
+ public Plant Plant { get; set; }
+ }
+}
+
+// NOTE: Pipeline class defined in a different namespace to repro a reported scenario
+namespace Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Plant1
+{
+ using Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Core;
+
+ public class Pipeline : PipelineBase
+ {
+ public int? Length { get; set; }
+ }
+}
diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesDataSource.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesDataSource.cs
new file mode 100644
index 000000000..ffd90a5d3
--- /dev/null
+++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesDataSource.cs
@@ -0,0 +1,57 @@
+//-----------------------------------------------------------------------------
+//
+// Copyright (c) .NET Foundation and Contributors. All rights reserved.
+// See License.txt in the project root for license information.
+//
+//------------------------------------------------------------------------------
+
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Core;
+using Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Plant1;
+
+namespace Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties
+{
+ internal static class MetadataPropertiesDataSource
+ {
+ private readonly static List sites;
+ private readonly static List plants;
+ private readonly static List pipelines;
+
+ static MetadataPropertiesDataSource()
+ {
+ pipelines = new List(Enumerable.Range(1, 8).Select(idx => new Pipeline
+ {
+ Id = idx,
+ Name = $"Pipeline {idx}",
+ Length = idx * 100
+ }));
+
+ plants = new List(Enumerable.Range(1, 4).Select(idx => new Plant
+ {
+ Id = idx,
+ Name = $"Plant {idx}",
+ Pipelines = pipelines.Skip((idx - 1) * 2).Take(2)
+ }));
+
+ sites = new List(Enumerable.Range(1, 2).Select(idx => new Site
+ {
+ Id = idx,
+ Name = $"Site {idx}",
+ Plants = plants.Skip((idx - 1) * 2).Take(2)
+ }));
+
+ for (var i = 0; i < plants.Count; i++)
+ {
+ plants[i].Site = sites[i / 2];
+ }
+
+ for (var i = 0; i < pipelines.Count; i++)
+ {
+ pipelines[i].Plant = plants[i / 2];
+ }
+ }
+
+ public static List Sites => sites;
+ }
+}
diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesEdmModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesEdmModel.cs
new file mode 100644
index 000000000..a76f4e62e
--- /dev/null
+++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesEdmModel.cs
@@ -0,0 +1,68 @@
+//-----------------------------------------------------------------------------
+//
+// Copyright (c) .NET Foundation and Contributors. All rights reserved.
+// See License.txt in the project root for license information.
+//
+//------------------------------------------------------------------------------
+
+using System.Linq;
+using Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Core;
+using Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Plant1;
+using Microsoft.OData.Edm;
+using Microsoft.OData.ModelBuilder;
+
+namespace Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties
+{
+ public class MetadataPropertiesEdmModel
+ {
+ ///
+ /// Returns model where Site and Plant navigation properties are non-contained and navigation source is contained.
+ ///
+ /// Returns Edm model.
+ public static IEdmModel GetEdmModelWithNonContainedNavPropInContainedNavSource()
+ {
+ var modelBuilder = new ODataConventionModelBuilder();
+ modelBuilder.EntityType();
+ modelBuilder.EntityType();
+ modelBuilder.EntityType();
+ modelBuilder.EntityType();
+ modelBuilder.EntityType();
+ modelBuilder.EntitySet("Sites");
+
+ var model = modelBuilder.GetEdmModel();
+
+ var sitesEntitySet = (EdmEntitySet)model.FindDeclaredEntitySet("Default.Container.Sites");
+ var plantsNavigationProperty = sitesEntitySet.EntityType().DeclaredNavigationProperties().Single(d => d.Name.Equals("Plants"));
+ var plantsContainedEntitySet = sitesEntitySet.FindNavigationTarget(plantsNavigationProperty);
+ var siteNavigationProperty = plantsContainedEntitySet.EntityType().DeclaredNavigationProperties().Single(d => d.Name.Equals("Site"));
+ var pipelinesNavigationProperty = plantsContainedEntitySet.EntityType().DeclaredNavigationProperties().Single(d => d.Name.Equals("Pipelines"));
+ var pipelineContainedEntitySet = plantsContainedEntitySet.FindNavigationTarget(pipelinesNavigationProperty, new EdmPathExpression("Plants", "Pipelines"));
+ var plantNavigationProperty = pipelineContainedEntitySet.EntityType().DeclaredNavigationProperties().Single(d => d.Name.Equals("Plant"));
+
+ sitesEntitySet.AddNavigationTarget(siteNavigationProperty, sitesEntitySet, new EdmPathExpression("Plants", "Site"));
+ sitesEntitySet.AddNavigationTarget(plantNavigationProperty, plantsContainedEntitySet, new EdmPathExpression("Plants", "Pipelines", "Plant"));
+
+ return model;
+ }
+
+ ///
+ /// Returns model where Site and Plant navigation properties are contained and navigation source is contained.
+ ///
+ /// Returns Edm model.
+ public static IEdmModel GetEdmModelWithContainedNavPropInContainedNavSource()
+ {
+ var modelBuilder = new ODataConventionModelBuilder();
+ modelBuilder.EntityType();
+ modelBuilder.EntityType();
+ // Make Site and Plant navigation properties contained
+ modelBuilder.EntityType().ContainsRequired(d => d.Site).Contained();
+ modelBuilder.EntityType().ContainsRequired(d => d.Plant).Contained();
+ modelBuilder.EntityType();
+ modelBuilder.EntitySet("Sites");
+
+ var model = modelBuilder.GetEdmModel();
+
+ return model;
+ }
+ }
+}
diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesTests.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesTests.cs
new file mode 100644
index 000000000..49d205924
--- /dev/null
+++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/MetadataProperties/MetadataPropertiesTests.cs
@@ -0,0 +1,262 @@
+//-----------------------------------------------------------------------------
+//
+// Copyright (c) .NET Foundation and Contributors. All rights reserved.
+// See License.txt in the project root for license information.
+//
+//------------------------------------------------------------------------------
+
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Core;
+using Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties.Plant1;
+using Microsoft.AspNetCore.OData.E2E.Tests.Extensions;
+using Microsoft.AspNetCore.OData.TestCommon;
+using Microsoft.Extensions.DependencyInjection;
+using Newtonsoft.Json.Linq;
+using Xunit;
+
+namespace Microsoft.AspNetCore.OData.E2E.Tests.MetadataProperties
+{
+ public class MetadataPropertiesTests : WebApiTestBase
+ {
+ public MetadataPropertiesTests(WebApiTestFixture fixture)
+ : base(fixture)
+ {
+ }
+
+ protected static void UpdateConfigureServices(IServiceCollection services)
+ {
+ var nonContainedNavPropInContainedNavSourceModel = MetadataPropertiesEdmModel.GetEdmModelWithNonContainedNavPropInContainedNavSource();
+ var containedNavPropertyInContainedNavSourceModel = MetadataPropertiesEdmModel.GetEdmModelWithContainedNavPropInContainedNavSource();
+
+ services.ConfigureControllers(typeof(SitesController));
+
+ services.AddControllers().AddOData(
+ options => options.EnableQueryFeatures()
+ .AddRouteComponents(
+ routePrefix: "NonContainedNavPropInContainedNavSource",
+ model: nonContainedNavPropInContainedNavSourceModel)
+ .AddRouteComponents(
+ routePrefix: "ContainedNavPropInContainedNavSource",
+ model: containedNavPropertyInContainedNavSourceModel));
+ }
+
+ [Theory]
+ [InlineData("NonContainedNavPropInContainedNavSource", "Sites(1)/Plants(1)")]
+ [InlineData("ContainedNavPropInContainedNavSource", "Sites(1)/Plants(1)/Pipelines(1)/Plant")]
+ public async Task TestExpandPlantNavigationPropertyOnContainedNavigationSource(string routePrefix, string plantResourceBase)
+ {
+ // Arrange
+ var requestUri = $"{routePrefix}/Sites(1)/Plants(1)/Pipelines(1)?$expand=Plant";
+ var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
+ request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=full"));
+ var client = CreateClient();
+ var typeofPipeline = typeof(Pipeline);
+ var typeofPlant = typeof(Plant);
+
+ // Act
+ var response = await client.SendAsync(request);
+
+ // Assert
+ Assert.NotNull(response);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var result = await response.Content.ReadAsObject();
+
+ Assert.EndsWith($"{routePrefix}/$metadata#Sites(1)/Plants(1)/Pipelines/{typeofPipeline}(Plant())/$entity", result.Value("@odata.context"));
+ Assert.Equal($"#{typeofPipeline}", result.Value("@odata.type"));
+ Assert.EndsWith("Sites(1)/Plants(1)/Pipelines(1)", result.Value("@odata.id"));
+ Assert.EndsWith($"Sites(1)/Plants(1)/Pipelines(1)/{typeofPipeline}", result.Value("@odata.editLink"));
+ Assert.Equal(1, result.Value("Id"));
+ Assert.Equal("Pipeline 1", result.Value("Name"));
+ Assert.Equal(100, result.Value("Length"));
+ Assert.EndsWith($"/Sites(1)/Plants(1)/Pipelines(1)/{typeofPipeline}/Plant/$ref", result.Value("Plant@odata.associationLink"));
+ Assert.EndsWith($"/Sites(1)/Plants(1)/Pipelines(1)/{typeofPipeline}/Plant", result.Value("Plant@odata.navigationLink"));
+
+ var plant = result.GetValue("Plant") as JObject;
+ Assert.NotNull(plant);
+ Assert.Equal($"#{typeofPlant}", plant.Value("@odata.type"));
+ Assert.EndsWith($"{plantResourceBase}", plant.Value("@odata.id"));
+ Assert.EndsWith($"{plantResourceBase}", plant.Value("@odata.editLink"));
+ Assert.Equal(1, plant.Value("Id"));
+ Assert.Equal("Plant 1", plant.Value("Name"));
+ Assert.EndsWith($"{routePrefix}/{plantResourceBase}/Site/$ref", plant.Value("Site@odata.associationLink"));
+ Assert.EndsWith($"{routePrefix}/{plantResourceBase}/Site", plant.Value("Site@odata.navigationLink"));
+ Assert.EndsWith($"{routePrefix}/{plantResourceBase}/Pipelines/$ref", plant.Value("Pipelines@odata.associationLink"));
+ Assert.EndsWith($"{routePrefix}/{plantResourceBase}/Pipelines", plant.Value("Pipelines@odata.navigationLink"));
+ }
+
+ [Theory]
+ [InlineData("NonContainedNavPropInContainedNavSource", "Sites(1)")]
+ [InlineData("ContainedNavPropInContainedNavSource", "Sites(1)/Plants(1)/Site")]
+ public async Task TestExpandSiteNavigationPropertyOnContainedNavigationSource(string routePrefix, string siteResourceBase)
+ {
+ // Arrange
+ var requestUri = $"{routePrefix}/Sites(1)/Plants(1)?$expand=Site";
+ var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
+ request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=full"));
+ var client = CreateClient();
+ var typeofPlant = typeof(Plant);
+ var typeofSite = typeof(Site);
+
+ // Act
+ var response = await client.SendAsync(request);
+
+ // Assert
+ Assert.NotNull(response);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var result = await response.Content.ReadAsObject();
+
+ Assert.EndsWith($"{routePrefix}/$metadata#Sites(1)/Plants(Site())/$entity", result.Value("@odata.context"));
+ Assert.Equal($"#{typeofPlant}", result.Value("@odata.type"));
+ Assert.EndsWith("Sites(1)/Plants(1)", result.Value("@odata.id"));
+ Assert.EndsWith("Sites(1)/Plants(1)", result.Value("@odata.editLink"));
+ Assert.Equal(1, result.Value("Id"));
+ Assert.Equal("Plant 1", result.Value("Name"));
+ Assert.EndsWith($"{routePrefix}/Sites(1)/Plants(1)/Pipelines/$ref", result.Value("Pipelines@odata.associationLink"));
+ Assert.EndsWith($"{routePrefix}/Sites(1)/Plants(1)/Pipelines", result.Value("Pipelines@odata.navigationLink"));
+ Assert.EndsWith($"{routePrefix}/Sites(1)/Plants(1)/Site/$ref", result.Value("Site@odata.associationLink"));
+ Assert.EndsWith($"{routePrefix}/Sites(1)/Plants(1)/Site", result.Value("Site@odata.navigationLink"));
+
+ var site = result.GetValue("Site") as JObject;
+ Assert.NotNull(site);
+ Assert.Equal($"#{typeofSite}", site.Value("@odata.type"));
+ Assert.EndsWith($"{siteResourceBase}", site.Value("@odata.id"));
+ Assert.EndsWith($"{siteResourceBase}", site.Value("@odata.editLink"));
+ Assert.Equal(1, site.Value("Id"));
+ Assert.Equal("Site 1", site.Value("Name"));
+ Assert.EndsWith($"{routePrefix}/{siteResourceBase}/Plants/$ref", site.Value("Plants@odata.associationLink"));
+ Assert.EndsWith($"{routePrefix}/{siteResourceBase}/Plants", site.Value("Plants@odata.navigationLink"));
+ }
+
+ [Theory]
+ [InlineData("NonContainedNavPropInContainedNavSource", "Sites(1)/Plants(2)")]
+ [InlineData("ContainedNavPropInContainedNavSource", "Sites(1)/Plants(2)/Pipelines({0})/Plant")]
+ public async Task TestExpandPipelinesNavigationPropertyOnContainedNavigationSource(string routePrefix, string plantResourceBase)
+ {
+ // Arrange
+ var requestUri = $"{routePrefix}/Sites(1)/Plants(2)?$expand=Pipelines($expand=Plant)";
+ var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
+ request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=full"));
+ var client = CreateClient();
+
+ // Act
+ var response = await client.SendAsync(request);
+
+ // Assert
+ Assert.NotNull(response);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var result = await response.Content.ReadAsObject();
+
+ Action verifyPlantAction = (plant, localRoutePrefix, localPlantResourceBase) =>
+ {
+ Assert.NotNull(plant);
+ Assert.Equal($"#{typeof(Plant)}", plant.Value("@odata.type"));
+ Assert.EndsWith($"{localPlantResourceBase}", plant.Value("@odata.id"));
+ Assert.EndsWith($"{localPlantResourceBase}", plant.Value("@odata.editLink"));
+ Assert.Equal(2, plant.Value("Id"));
+ Assert.Equal($"Plant 2", plant.Value("Name"));
+ Assert.EndsWith($"{localRoutePrefix}/{localPlantResourceBase}/Pipelines/$ref", plant.Value("Pipelines@odata.associationLink"));
+ Assert.EndsWith($"{localRoutePrefix}/{localPlantResourceBase}/Pipelines", plant.Value("Pipelines@odata.navigationLink"));
+ Assert.EndsWith($"{localRoutePrefix}/{localPlantResourceBase}/Site/$ref", plant.Value("Site@odata.associationLink"));
+ Assert.EndsWith($"{localRoutePrefix}/{localPlantResourceBase}/Site", plant.Value("Site@odata.navigationLink"));
+ };
+
+ verifyPlantAction(result, routePrefix, "Sites(1)/Plants(2)");
+
+ var pipelines = result.GetValue("Pipelines") as JArray;
+ Assert.NotNull(pipelines);
+ Assert.Equal(2, pipelines.Count);
+
+ var pipelineAt0 = pipelines[0] as JObject;
+ var pipelineAt1 = pipelines[1] as JObject;
+
+ Action verifyPipelineAction = (pipeline, localRoutePrefix, pipelineId) =>
+ {
+ var typeofPipeline = typeof(Pipeline);
+
+ Assert.NotNull(pipeline);
+ Assert.Equal($"#{typeofPipeline}", pipeline.Value("@odata.type"));
+ Assert.EndsWith($"Sites(1)/Plants(2)/Pipelines({pipelineId})", pipeline.Value("@odata.id"));
+ Assert.EndsWith($"Sites(1)/Plants(2)/Pipelines({pipelineId})/{typeofPipeline}", pipeline.Value("@odata.editLink"));
+ Assert.Equal(pipelineId, pipeline.Value("Id"));
+ Assert.Equal($"Pipeline {pipelineId}", pipeline.Value("Name"));
+ Assert.Equal(pipelineId * 100, pipeline.Value("Length"));
+ Assert.EndsWith($"/Sites(1)/Plants(2)/Pipelines({pipelineId})/{typeofPipeline}/Plant/$ref", pipeline.Value("Plant@odata.associationLink"));
+ Assert.EndsWith($"/Sites(1)/Plants(2)/Pipelines({pipelineId})/{typeofPipeline}/Plant", pipeline.Value("Plant@odata.navigationLink"));
+
+ var plant = pipeline.GetValue("Plant") as JObject;
+ verifyPlantAction(plant, localRoutePrefix, string.Format(plantResourceBase, pipelineId));
+ };
+
+ verifyPipelineAction(pipelineAt0, routePrefix, 3); // Pipeline Id = 3
+ verifyPipelineAction(pipelineAt1, routePrefix, 4); // Pipeline Id = 4
+ }
+
+ [Theory]
+ [InlineData("NonContainedNavPropInContainedNavSource", "Sites(2)")]
+ [InlineData("ContainedNavPropInContainedNavSource", "Sites(2)/Plants({0})/Site")]
+ public async Task TestExpandPlantsNavigationPropertyOnNonContainedNavigationSource(string routePrefix, string siteResourceBase)
+ {
+ // Arrange
+ var requestUri = $"{routePrefix}/Sites(2)?$expand=Plants($expand=Site)";
+ var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
+ request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=full"));
+ var client = CreateClient();
+
+ // Act
+ var response = await client.SendAsync(request);
+
+ // Assert
+ Assert.NotNull(response);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var result = await response.Content.ReadAsObject();
+
+ Action verifySiteAction = (site, localRoutePrefix, localSiteResourceBase) =>
+ {
+ Assert.Equal($"#{typeof(Site)}", site.Value("@odata.type"));
+ Assert.EndsWith($"{localSiteResourceBase}", site.Value("@odata.id"));
+ Assert.EndsWith($"{localSiteResourceBase}", site.Value("@odata.editLink"));
+ Assert.Equal(2, site.Value("Id"));
+ Assert.Equal("Site 2", site.Value("Name"));
+ Assert.EndsWith($"{localRoutePrefix}/{localSiteResourceBase}/Plants/$ref", site.Value("Plants@odata.associationLink"));
+ Assert.EndsWith($"{localRoutePrefix}/{localSiteResourceBase}/Plants", site.Value("Plants@odata.navigationLink"));
+ };
+
+ verifySiteAction(result, routePrefix, "Sites(2)");
+
+ var plants = result.GetValue("Plants") as JArray;
+ Assert.NotNull(plants);
+ Assert.Equal(2, plants.Count);
+
+ var plantAt0 = plants[0] as JObject;
+ var plantAt1 = plants[1] as JObject;
+
+ Action verifyPlantAction = (plant, localRoutePrefix, plantId) =>
+ {
+ Assert.NotNull(plant);
+ Assert.Equal($"#{typeof(Plant)}", plant.Value("@odata.type"));
+ Assert.EndsWith($"Sites(2)/Plants({plantId})", plant.Value("@odata.id"));
+ Assert.EndsWith($"Sites(2)/Plants({plantId})", plant.Value("@odata.editLink"));
+ Assert.Equal(plantId, plant.Value("Id"));
+ Assert.Equal($"Plant {plantId}", plant.Value("Name"));
+ Assert.EndsWith($"{localRoutePrefix}/Sites(2)/Plants({plantId})/Pipelines/$ref", plant.Value("Pipelines@odata.associationLink"));
+ Assert.EndsWith($"{localRoutePrefix}/Sites(2)/Plants({plantId})/Pipelines", plant.Value("Pipelines@odata.navigationLink"));
+ Assert.EndsWith($"{localRoutePrefix}/Sites(2)/Plants({plantId})/Site/$ref", plant.Value("Site@odata.associationLink"));
+ Assert.EndsWith($"{localRoutePrefix}/Sites(2)/Plants({plantId})/Site", plant.Value("Site@odata.navigationLink"));
+
+ var site = plant.GetValue("Site") as JObject;
+ verifySiteAction(site, localRoutePrefix, string.Format(siteResourceBase, plantId));
+ };
+
+ verifyPlantAction(plantAt0, routePrefix, 3); // Plant Id = 3
+ verifyPlantAction(plantAt1, routePrefix, 4); // Plant Id = 4
+ }
+ }
+}