From bab795a1664f459691cb0122aad079ed98decf8b Mon Sep 17 00:00:00 2001 From: Chris Santero Date: Sat, 26 Mar 2016 18:17:54 -0400 Subject: [PATCH 01/22] support serializing complex attributes to types other than string --- .../Core/ResourceTypeRegistrarTests.cs | 98 ++++++++++++++++--- JSONAPI.Tests/Models/AttributeGrabBag.cs | 16 +++ .../Core/ComplexAttributeValueConverter.cs | 2 +- .../ObjectComplexAttributeValueConverter.cs | 39 ++++++++ JSONAPI/Core/ResourceTypeRegistrar.cs | 12 ++- JSONAPI/JSONAPI.csproj | 1 + 6 files changed, 154 insertions(+), 14 deletions(-) create mode 100644 JSONAPI/Core/ObjectComplexAttributeValueConverter.cs diff --git a/JSONAPI.Tests/Core/ResourceTypeRegistrarTests.cs b/JSONAPI.Tests/Core/ResourceTypeRegistrarTests.cs index 79d30e47..b1b242e9 100644 --- a/JSONAPI.Tests/Core/ResourceTypeRegistrarTests.cs +++ b/JSONAPI.Tests/Core/ResourceTypeRegistrarTests.cs @@ -187,25 +187,38 @@ private AttributeGrabBag InitializeGrabBag() private void AssertAttribute(IResourceTypeRegistration reg, string attributeName, JToken tokenToSet, TPropertyType expectedPropertyValue, TTokenType expectedTokenAfterSet, Func getPropertyFunc) + { + AssertAttributeHelper(reg, attributeName, tokenToSet, grabBag => + { + var propertyValueAfterSet = getPropertyFunc(grabBag); + propertyValueAfterSet.Should().Be(expectedPropertyValue); + }, token => + { + if (expectedTokenAfterSet == null) + token.Should().BeNull(); + else + { + var convertedTokenValue = token.Value(); + convertedTokenValue.Should().Be(expectedTokenAfterSet); + } + }); + } + + private void AssertAttributeHelper(IResourceTypeRegistration reg, string attributeName, + JToken tokenToSet, Action testPropertyValueAfterSet, + Action testTokenAfterSetAndGet) { var grabBag = InitializeGrabBag(); var field = reg.GetFieldByName(attributeName); - var attribute = (ResourceTypeAttribute) field; + var attribute = (ResourceTypeAttribute)field; attribute.JsonKey.Should().Be(attributeName); attribute.SetValue(grabBag, tokenToSet); - var propertyValueAfterSet = getPropertyFunc(grabBag); - propertyValueAfterSet.Should().Be(expectedPropertyValue); - + testPropertyValueAfterSet(grabBag); + var convertedToken = attribute.GetValue(grabBag); - if (expectedTokenAfterSet == null) - convertedToken.Should().BeNull(); - else - { - var convertedTokenValue = convertedToken.Value(); - convertedTokenValue.Should().Be(expectedTokenAfterSet); - } + testTokenAfterSetAndGet(convertedToken); } [TestMethod] @@ -733,5 +746,68 @@ public void BuildRegistration_sets_up_correct_attribute_for_nullable_enum_field( AssertAttribute(reg, "nullable-enum-field", (int)SampleEnum.Value1, SampleEnum.Value1, (int)SampleEnum.Value1, g => g.NullableEnumField); AssertAttribute(reg, "nullable-enum-field", null, null, (SampleEnum?)null, g => g.NullableEnumField); } + + [TestMethod] + public void BuildRegistration_sets_up_correct_attribute_for_to_one_complex_field() + { + // Arrange + var registrar = new ResourceTypeRegistrar(new DefaultNamingConventions(new PluralizationService())); + + // Act + var reg = registrar.BuildRegistration(typeof(AttributeGrabBag)); + + // Assert + AssertAttributeHelper(reg, "to-one-complex-type-field", + new JObject { { "intProp", 32 }, { "StringProp", "qux" } }, + grabBag => + { + grabBag.ToOneComplexTypeField.Should().NotBeNull(); + grabBag.ToOneComplexTypeField.IntProp.Should().Be(32); + grabBag.ToOneComplexTypeField.StringProp.Should().Be("qux"); + }, + token => + { + ((int)token["intProp"]).Should().Be(32); + ((string)token["StringProp"]).Should().Be("qux"); + }); + AssertAttribute(reg, "to-one-complex-type-field", null, null, (SampleComplexType)null, g => g.ToOneComplexTypeField); + } + + [TestMethod] + public void BuildRegistration_sets_up_correct_attribute_for_to_many_complex_field() + { + // Arrange + var registrar = new ResourceTypeRegistrar(new DefaultNamingConventions(new PluralizationService())); + + // Act + var reg = registrar.BuildRegistration(typeof(AttributeGrabBag)); + + // Assert + AssertAttributeHelper(reg, "to-many-complex-type-field", + new JArray + { + new JObject { { "intProp", 49 }, { "StringProp", "blue" } }, + new JObject { { "intProp", 67 }, { "StringProp", "orange" } } + }, + grabBag => + { + var result = grabBag.ToManyComplexTypeField.ToArray(); + result.Length.Should().Be(2); + result[0].IntProp.Should().Be(49); + result[0].StringProp.Should().Be("blue"); + result[1].IntProp.Should().Be(67); + result[1].StringProp.Should().Be("orange"); + }, + token => + { + var jarray = (JArray) token; + jarray.Count.Should().Be(2); + ((int)jarray[0]["intProp"]).Should().Be(49); + ((string)jarray[0]["StringProp"]).Should().Be("blue"); + ((int)jarray[1]["intProp"]).Should().Be(67); + ((string)jarray[1]["StringProp"]).Should().Be("orange"); + }); + AssertAttribute(reg, "to-many-complex-type-field", null, null, (SampleComplexType[])null, g => g.ToManyComplexTypeField); + } } } diff --git a/JSONAPI.Tests/Models/AttributeGrabBag.cs b/JSONAPI.Tests/Models/AttributeGrabBag.cs index 85490cef..dca40e34 100644 --- a/JSONAPI.Tests/Models/AttributeGrabBag.cs +++ b/JSONAPI.Tests/Models/AttributeGrabBag.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; using JSONAPI.Attributes; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; namespace JSONAPI.Tests.Models { @@ -9,6 +12,13 @@ public enum SampleEnum Value2 = 2 } + public class SampleComplexType + { + [JsonProperty("intProp")] + public Int32 IntProp { get; set; } + public string StringProp { get; set; } + } + public class AttributeGrabBag { public string Id { get; set; } @@ -48,5 +58,11 @@ public class AttributeGrabBag [SerializeAsComplex] public string ComplexAttributeField { get; set; } + + [SerializeAsComplex] + public SampleComplexType ToOneComplexTypeField { get; set; } + + [SerializeAsComplex] + public virtual ICollection ToManyComplexTypeField { get; set; } } } diff --git a/JSONAPI/Core/ComplexAttributeValueConverter.cs b/JSONAPI/Core/ComplexAttributeValueConverter.cs index ab892c97..e26726d0 100644 --- a/JSONAPI/Core/ComplexAttributeValueConverter.cs +++ b/JSONAPI/Core/ComplexAttributeValueConverter.cs @@ -5,7 +5,7 @@ namespace JSONAPI.Core { /// /// Implementation of suitable for - /// use with complex attributes. + /// use with complex attributes that deserialize to strings. /// public class ComplexAttributeValueConverter : IAttributeValueConverter { diff --git a/JSONAPI/Core/ObjectComplexAttributeValueConverter.cs b/JSONAPI/Core/ObjectComplexAttributeValueConverter.cs new file mode 100644 index 00000000..c6c73af0 --- /dev/null +++ b/JSONAPI/Core/ObjectComplexAttributeValueConverter.cs @@ -0,0 +1,39 @@ +using System.Reflection; +using Newtonsoft.Json.Linq; + +namespace JSONAPI.Core +{ + /// + /// Implementation of suitable for + /// use with complex attributes that deserialize to custom types. + /// + public class ObjectComplexAttributeValueConverter : IAttributeValueConverter + { + private readonly PropertyInfo _property; + private readonly bool _isToMany; + + /// + /// Creates a new ComplexAttributeValueConverter + /// + /// + /// + public ObjectComplexAttributeValueConverter(PropertyInfo property, bool isToMany) + { + _property = property; + _isToMany = isToMany; + } + + public JToken GetValue(object resource) + { + var value = _property.GetValue(resource); + if (value == null) return null; + return _isToMany ? (JToken)JArray.FromObject(value) : JObject.FromObject(value); + } + + public void SetValue(object resource, JToken value) + { + var deserialized = value?.ToObject(_property.PropertyType); + _property.SetValue(resource, deserialized); + } + } +} \ No newline at end of file diff --git a/JSONAPI/Core/ResourceTypeRegistrar.cs b/JSONAPI/Core/ResourceTypeRegistrar.cs index 6ae7b057..55337a07 100644 --- a/JSONAPI/Core/ResourceTypeRegistrar.cs +++ b/JSONAPI/Core/ResourceTypeRegistrar.cs @@ -101,7 +101,15 @@ protected virtual IAttributeValueConverter GetValueConverterForProperty(Property { var serializeAsComplexAttribute = prop.GetCustomAttribute(); if (serializeAsComplexAttribute != null) - return new ComplexAttributeValueConverter(prop); + { + if (prop.PropertyType == typeof (string)) + return new ComplexAttributeValueConverter(prop); + + var isToMany = + prop.PropertyType.IsArray || + (prop.PropertyType.GetInterfaces().Contains(typeof(System.Collections.IEnumerable)) && prop.PropertyType.IsGenericType); + return new ObjectComplexAttributeValueConverter(prop, isToMany); + } if (prop.PropertyType == typeof(DateTime)) return new DateTimeAttributeValueConverter(prop, false); @@ -150,7 +158,7 @@ protected virtual ResourceTypeField CreateResourceTypeField(PropertyInfo prop) var type = prop.PropertyType; - if (prop.PropertyType.CanWriteAsJsonApiAttribute()) + if (prop.PropertyType.CanWriteAsJsonApiAttribute() || prop.GetCustomAttributes().Any()) { var converter = GetValueConverterForProperty(prop); return new ResourceTypeAttribute(converter, prop, jsonKey); diff --git a/JSONAPI/JSONAPI.csproj b/JSONAPI/JSONAPI.csproj index cfb6b1d5..2412f4cc 100644 --- a/JSONAPI/JSONAPI.csproj +++ b/JSONAPI/JSONAPI.csproj @@ -81,6 +81,7 @@ + From c573d2653b33b77f00a87122ce529e48e77601a1 Mon Sep 17 00:00:00 2001 From: Chris Santero Date: Tue, 29 Mar 2016 22:49:58 -0400 Subject: [PATCH 02/22] do not serialize related data that already exists in the primary data --- .../RegistryDrivenResourceCollectionDocumentBuilder.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/JSONAPI/Documents/Builders/RegistryDrivenResourceCollectionDocumentBuilder.cs b/JSONAPI/Documents/Builders/RegistryDrivenResourceCollectionDocumentBuilder.cs index 031f9f9b..0508dd4a 100644 --- a/JSONAPI/Documents/Builders/RegistryDrivenResourceCollectionDocumentBuilder.cs +++ b/JSONAPI/Documents/Builders/RegistryDrivenResourceCollectionDocumentBuilder.cs @@ -27,8 +27,14 @@ public IResourceCollectionDocument BuildDocument(IEnumerable pri primaryData.Select(d => (IResourceObject)CreateResourceObject(d, idDictionariesByType, null, includePathExpressions, linkBaseUrl, resourceMetadata)) .ToArray(); + var primaryResourceIdentifiers = primaryDataResources.Select(r => new { r.Id, r.Type }).ToArray(); + var relatedData = idDictionariesByType.Values.SelectMany(d => d.Values).Cast().ToArray(); - var document = new ResourceCollectionDocument(primaryDataResources, relatedData, metadata); + var relatedDataNotInPrimaryData = relatedData + .Where(r => !primaryResourceIdentifiers.Any(pri => pri.Id == r.Id && pri.Type == r.Type)) + .ToArray(); + + var document = new ResourceCollectionDocument(primaryDataResources, relatedDataNotInPrimaryData, metadata); return document; } } From 949953e3a325336233a4c563f6dbdda4e4f81dcf Mon Sep 17 00:00:00 2001 From: Chris Santero Date: Mon, 4 Apr 2016 11:34:29 -0400 Subject: [PATCH 03/22] allow property injection for formatters --- JSONAPI/Json/LinkFormatter.cs | 29 ++++++- JSONAPI/Json/RelationshipObjectFormatter.cs | 65 ++++++++++++--- .../ResourceCollectionDocumentFormatter.cs | 61 +++++++++++--- JSONAPI/Json/ResourceObjectFormatter.cs | 80 ++++++++++++++++--- .../Json/SingleResourceDocumentFormatter.cs | 56 +++++++++++-- 5 files changed, 254 insertions(+), 37 deletions(-) diff --git a/JSONAPI/Json/LinkFormatter.cs b/JSONAPI/Json/LinkFormatter.cs index 7c88dae3..8f0331b5 100644 --- a/JSONAPI/Json/LinkFormatter.cs +++ b/JSONAPI/Json/LinkFormatter.cs @@ -10,10 +10,18 @@ namespace JSONAPI.Json /// public class LinkFormatter : ILinkFormatter { - private readonly IMetadataFormatter _metadataFormatter; + private IMetadataFormatter _metadataFormatter; private const string HrefKeyName = "href"; private const string MetaKeyName = "meta"; + /// + /// Constructs a LinkFormatter + /// + public LinkFormatter() + { + + } + /// /// Constructs a LinkFormatter /// @@ -23,6 +31,23 @@ public LinkFormatter(IMetadataFormatter metadataFormatter) _metadataFormatter = metadataFormatter; } + /// + /// The formatter to use for metadata on the link object + /// + /// + public IMetadataFormatter MetadataFormatter + { + get + { + return _metadataFormatter ?? (_metadataFormatter = new MetadataFormatter()); + } + set + { + if (_metadataFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _metadataFormatter = value; + } + } + public Task Serialize(ILink link, JsonWriter writer) { if (link.Metadata == null) @@ -35,7 +60,7 @@ public Task Serialize(ILink link, JsonWriter writer) writer.WritePropertyName(HrefKeyName); writer.WriteValue(link.Href); writer.WritePropertyName(MetaKeyName); - _metadataFormatter.Serialize(link.Metadata, writer); + MetadataFormatter.Serialize(link.Metadata, writer); writer.WriteEndObject(); } return Task.FromResult(0); diff --git a/JSONAPI/Json/RelationshipObjectFormatter.cs b/JSONAPI/Json/RelationshipObjectFormatter.cs index e598894c..a41c8411 100644 --- a/JSONAPI/Json/RelationshipObjectFormatter.cs +++ b/JSONAPI/Json/RelationshipObjectFormatter.cs @@ -16,9 +16,17 @@ public class RelationshipObjectFormatter : IRelationshipObjectFormatter private const string LinkageKeyName = "data"; private const string MetaKeyName = "meta"; - private readonly ILinkFormatter _linkFormatter; - private readonly IResourceLinkageFormatter _resourceLinkageFormatter; - private readonly IMetadataFormatter _metadataFormatter; + private ILinkFormatter _linkFormatter; + private IResourceLinkageFormatter _resourceLinkageFormatter; + private IMetadataFormatter _metadataFormatter; + + /// + /// Creates a new RelationshipObjectFormatter + /// + public RelationshipObjectFormatter () + { + + } /// /// Creates a new RelationshipObjectFormatter @@ -30,6 +38,45 @@ public RelationshipObjectFormatter(ILinkFormatter linkFormatter, IResourceLinkag _metadataFormatter = metadataFormatter; } + private ILinkFormatter LinkFormatter + { + get + { + return _linkFormatter ?? (_linkFormatter = new LinkFormatter()); + } + set + { + if (_linkFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _linkFormatter = value; + } + } + + private IResourceLinkageFormatter ResourceLinkageFormatter + { + get + { + return _resourceLinkageFormatter ?? (_resourceLinkageFormatter = new ResourceLinkageFormatter()); + } + set + { + if (_resourceLinkageFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _resourceLinkageFormatter = value; + } + } + + private IMetadataFormatter MetadataFormatter + { + get + { + return _metadataFormatter ?? (_metadataFormatter = new MetadataFormatter()); + } + set + { + if (_metadataFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _metadataFormatter = value; + } + } + public Task Serialize(IRelationshipObject relationshipObject, JsonWriter writer) { if (relationshipObject.Metadata == null && relationshipObject.SelfLink == null && @@ -60,12 +107,12 @@ protected virtual void SerializeLinks(IRelationshipObject relationshipObject, Js if (relationshipObject.SelfLink != null) { writer.WritePropertyName(SelfLinkKeyName); - _linkFormatter.Serialize(relationshipObject.SelfLink, writer); + LinkFormatter.Serialize(relationshipObject.SelfLink, writer); } if (relationshipObject.RelatedResourceLink != null) { writer.WritePropertyName(RelatedLinkKeyName); - _linkFormatter.Serialize(relationshipObject.RelatedResourceLink, writer); + LinkFormatter.Serialize(relationshipObject.RelatedResourceLink, writer); } writer.WriteEndObject(); @@ -80,7 +127,7 @@ protected virtual void SerializeLinkage(IRelationshipObject relationshipObject, if (relationshipObject.Linkage != null) { writer.WritePropertyName(LinkageKeyName); - _resourceLinkageFormatter.Serialize(relationshipObject.Linkage, writer); + ResourceLinkageFormatter.Serialize(relationshipObject.Linkage, writer); } } @@ -92,7 +139,7 @@ protected virtual void SerializeMetadata(IRelationshipObject relationshipObject, if (relationshipObject.Metadata != null) { writer.WritePropertyName(MetaKeyName); - _metadataFormatter.Serialize(relationshipObject.Metadata, writer); + MetadataFormatter.Serialize(relationshipObject.Metadata, writer); } } @@ -114,10 +161,10 @@ public async Task Deserialize(JsonReader reader, string cur switch (propertyName) { case LinkageKeyName: - linkage = await _resourceLinkageFormatter.Deserialize(reader, currentPath + "/" + LinkageKeyName); + linkage = await ResourceLinkageFormatter.Deserialize(reader, currentPath + "/" + LinkageKeyName); break; case MetaKeyName: - metadata = await _metadataFormatter.Deserialize(reader, currentPath + "/" + MetaKeyName); + metadata = await MetadataFormatter.Deserialize(reader, currentPath + "/" + MetaKeyName); break; } } diff --git a/JSONAPI/Json/ResourceCollectionDocumentFormatter.cs b/JSONAPI/Json/ResourceCollectionDocumentFormatter.cs index db2d40e9..79cd75d8 100644 --- a/JSONAPI/Json/ResourceCollectionDocumentFormatter.cs +++ b/JSONAPI/Json/ResourceCollectionDocumentFormatter.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -11,14 +12,22 @@ namespace JSONAPI.Json /// public class ResourceCollectionDocumentFormatter : IResourceCollectionDocumentFormatter { - private readonly IResourceObjectFormatter _resourceObjectFormatter; - private readonly IMetadataFormatter _metadataFormatter; + private IResourceObjectFormatter _resourceObjectFormatter; + private IMetadataFormatter _metadataFormatter; private const string PrimaryDataKeyName = "data"; private const string RelatedDataKeyName = "included"; private const string MetaKeyName = "meta"; - + + /// + /// Creates a ResourceCollectionDocumentFormatter + /// + public ResourceCollectionDocumentFormatter() + { + + } + /// - /// Creates a SingleResourceDocumentFormatter + /// Creates a ResourceCollectionDocumentFormatter /// /// /// @@ -28,6 +37,40 @@ public ResourceCollectionDocumentFormatter(IResourceObjectFormatter resourceObje _metadataFormatter = metadataFormatter; } + /// + /// The formatter to use for resource objects found in this document + /// + /// + public IResourceObjectFormatter ResourceObjectFormatter + { + get + { + return _resourceObjectFormatter ?? (_resourceObjectFormatter = new ResourceObjectFormatter()); + } + set + { + if (_resourceObjectFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _resourceObjectFormatter = value; + } + } + + /// + /// The formatter to use for document-level metadata + /// + /// + public IMetadataFormatter MetadataFormatter + { + get + { + return _metadataFormatter ?? (_metadataFormatter = new MetadataFormatter()); + } + set + { + if (_metadataFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _metadataFormatter = value; + } + } + public Task Serialize(IResourceCollectionDocument document, JsonWriter writer) { writer.WriteStartObject(); @@ -37,7 +80,7 @@ public Task Serialize(IResourceCollectionDocument document, JsonWriter writer) writer.WriteStartArray(); foreach (var resourceObject in document.PrimaryData) { - _resourceObjectFormatter.Serialize(resourceObject, writer); + ResourceObjectFormatter.Serialize(resourceObject, writer); } writer.WriteEndArray(); @@ -47,7 +90,7 @@ public Task Serialize(IResourceCollectionDocument document, JsonWriter writer) writer.WriteStartArray(); foreach (var resourceObject in document.RelatedData) { - _resourceObjectFormatter.Serialize(resourceObject, writer); + ResourceObjectFormatter.Serialize(resourceObject, writer); } writer.WriteEndArray(); } @@ -55,7 +98,7 @@ public Task Serialize(IResourceCollectionDocument document, JsonWriter writer) if (document.Metadata != null) { writer.WritePropertyName(MetaKeyName); - _metadataFormatter.Serialize(document.Metadata, writer); + MetadataFormatter.Serialize(document.Metadata, writer); } writer.WriteEndObject(); @@ -91,7 +134,7 @@ public async Task Deserialize(JsonReader reader, st primaryData = await DeserializePrimaryData(reader, currentPath + "/" + PrimaryDataKeyName); break; case MetaKeyName: - metadata = await _metadataFormatter.Deserialize(reader, currentPath + "/" + MetaKeyName); + metadata = await MetadataFormatter.Deserialize(reader, currentPath + "/" + MetaKeyName); break; default: reader.Skip(); @@ -115,7 +158,7 @@ private async Task DeserializePrimaryData(JsonReader reader, if (reader.TokenType == JsonToken.EndArray) break; - var resourceObject = await _resourceObjectFormatter.Deserialize(reader, currentPath + "/" + index); + var resourceObject = await ResourceObjectFormatter.Deserialize(reader, currentPath + "/" + index); primaryData.Add(resourceObject); index++; diff --git a/JSONAPI/Json/ResourceObjectFormatter.cs b/JSONAPI/Json/ResourceObjectFormatter.cs index 076f0e0f..18907217 100644 --- a/JSONAPI/Json/ResourceObjectFormatter.cs +++ b/JSONAPI/Json/ResourceObjectFormatter.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using JSONAPI.Documents; @@ -12,9 +13,9 @@ namespace JSONAPI.Json /// public class ResourceObjectFormatter : IResourceObjectFormatter { - private readonly IRelationshipObjectFormatter _relationshipObjectFormatter; - private readonly ILinkFormatter _linkFormatter; - private readonly IMetadataFormatter _metadataFormatter; + private IRelationshipObjectFormatter _relationshipObjectFormatter; + private ILinkFormatter _linkFormatter; + private IMetadataFormatter _metadataFormatter; private const string TypeKeyName = "type"; private const string IdKeyName = "id"; private const string AttributesKeyName = "attributes"; @@ -24,7 +25,15 @@ public class ResourceObjectFormatter : IResourceObjectFormatter private const string SelfLinkKeyName = "self"; /// - /// Constructs a new resourceObjectFormatter + /// Constructs a new ResourceObjectFormatter + /// + public ResourceObjectFormatter() + { + + } + + /// + /// Constructs a new ResourceObjectFormatter /// /// The formatter to use for relationship objects /// The formatter to use for links @@ -36,6 +45,57 @@ public ResourceObjectFormatter(IRelationshipObjectFormatter relationshipObjectFo _metadataFormatter = metadataFormatter; } + /// + /// The formatter to use for relationship objects belonging to this resource + /// + /// + public IRelationshipObjectFormatter RelationshipObjectFormatter + { + get + { + return _relationshipObjectFormatter ?? (_relationshipObjectFormatter = new RelationshipObjectFormatter()); + } + set + { + if (_relationshipObjectFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _relationshipObjectFormatter = value; + } + } + + /// + /// The formatter to use for links + /// + /// + public ILinkFormatter LinkFormatter + { + get + { + return _linkFormatter ?? (_linkFormatter = new LinkFormatter()); + } + set + { + if (_linkFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _linkFormatter = value; + } + } + + /// + /// The formatter to use for metadata that belongs to this resource object + /// + /// + public IMetadataFormatter MetadataFormatter + { + get + { + return _metadataFormatter ?? (_metadataFormatter = new MetadataFormatter()); + } + set + { + if (_metadataFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _metadataFormatter = value; + } + } + public Task Serialize(IResourceObject resourceObject, JsonWriter writer) { if (resourceObject == null) @@ -88,7 +148,7 @@ public Task Serialize(IResourceObject resourceObject, JsonWriter writer) { if (relationship.Value == null) continue; writer.WritePropertyName(relationship.Key); - _relationshipObjectFormatter.Serialize(relationship.Value, writer); + RelationshipObjectFormatter.Serialize(relationship.Value, writer); } writer.WriteEndObject(); } @@ -99,14 +159,14 @@ public Task Serialize(IResourceObject resourceObject, JsonWriter writer) writer.WritePropertyName(LinksKeyName); writer.WriteStartObject(); writer.WritePropertyName(SelfLinkKeyName); - _linkFormatter.Serialize(resourceObject.SelfLink, writer); + LinkFormatter.Serialize(resourceObject.SelfLink, writer); writer.WriteEndObject(); } if (resourceObject.Metadata != null) { writer.WritePropertyName(MetaKeyName); - _metadataFormatter.Serialize(resourceObject.Metadata, writer); + MetadataFormatter.Serialize(resourceObject.Metadata, writer); } writer.WriteEndObject(); @@ -142,7 +202,7 @@ public async Task Deserialize(JsonReader reader, string current id = (string) reader.Value; break; case MetaKeyName: - metadata = await _metadataFormatter.Deserialize(reader, currentPath + "/" + MetaKeyName); + metadata = await MetadataFormatter.Deserialize(reader, currentPath + "/" + MetaKeyName); break; case AttributesKeyName: attributes = DeserializeAttributes(reader, currentPath + "/" + AttributesKeyName); @@ -202,7 +262,7 @@ private async Task> DeserializeRelation var relationshipName = (string)reader.Value; reader.Read(); - var relationship = await _relationshipObjectFormatter.Deserialize(reader, currentPath + "/" + relationshipName); + var relationship = await RelationshipObjectFormatter.Deserialize(reader, currentPath + "/" + relationshipName); relationships.Add(relationshipName, relationship); } diff --git a/JSONAPI/Json/SingleResourceDocumentFormatter.cs b/JSONAPI/Json/SingleResourceDocumentFormatter.cs index e0414c11..816e963c 100644 --- a/JSONAPI/Json/SingleResourceDocumentFormatter.cs +++ b/JSONAPI/Json/SingleResourceDocumentFormatter.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using System.Threading.Tasks; using JSONAPI.Documents; @@ -10,12 +11,19 @@ namespace JSONAPI.Json /// public class SingleResourceDocumentFormatter : ISingleResourceDocumentFormatter { - private readonly IResourceObjectFormatter _resourceObjectFormatter; - private readonly IMetadataFormatter _metadataFormatter; + private IResourceObjectFormatter _resourceObjectFormatter; + private IMetadataFormatter _metadataFormatter; private const string PrimaryDataKeyName = "data"; private const string RelatedDataKeyName = "included"; private const string MetaKeyName = "meta"; + /// + /// Creates a SingleResourceDocumentFormatter with default parameters + /// + public SingleResourceDocumentFormatter() + { + } + /// /// Creates a SingleResourceDocumentFormatter /// @@ -27,13 +35,47 @@ public SingleResourceDocumentFormatter(IResourceObjectFormatter resourceObjectFo _metadataFormatter = metadataFormatter; } + /// + /// The formatter to use for resource objects found in this document + /// + /// + public IResourceObjectFormatter ResourceObjectFormatter + { + get + { + return _resourceObjectFormatter ?? (_resourceObjectFormatter = new ResourceObjectFormatter()); + } + set + { + if (_resourceObjectFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _resourceObjectFormatter = value; + } + } + + /// + /// The formatter to use for document-level metadata + /// + /// + public IMetadataFormatter MetadataFormatter + { + get + { + return _metadataFormatter ?? (_metadataFormatter = new MetadataFormatter()); + } + set + { + if (_metadataFormatter != null) throw new InvalidOperationException("This property can only be set once."); + _metadataFormatter = value; + } + } + public Task Serialize(ISingleResourceDocument document, JsonWriter writer) { writer.WriteStartObject(); writer.WritePropertyName(PrimaryDataKeyName); - _resourceObjectFormatter.Serialize(document.PrimaryData, writer); + ResourceObjectFormatter.Serialize(document.PrimaryData, writer); if (document.RelatedData != null && document.RelatedData.Any()) { @@ -41,7 +83,7 @@ public Task Serialize(ISingleResourceDocument document, JsonWriter writer) writer.WriteStartArray(); foreach (var resourceObject in document.RelatedData) { - _resourceObjectFormatter.Serialize(resourceObject, writer); + ResourceObjectFormatter.Serialize(resourceObject, writer); } writer.WriteEndArray(); } @@ -49,7 +91,7 @@ public Task Serialize(ISingleResourceDocument document, JsonWriter writer) if (document.Metadata != null) { writer.WritePropertyName(MetaKeyName); - _metadataFormatter.Serialize(document.Metadata, writer); + MetadataFormatter.Serialize(document.Metadata, writer); } writer.WriteEndObject(); @@ -83,7 +125,7 @@ public async Task Deserialize(JsonReader reader, string primaryData = await DeserializePrimaryData(reader, currentPath + "/" + PrimaryDataKeyName); break; case MetaKeyName: - metadata = await _metadataFormatter.Deserialize(reader, currentPath + "/" + MetaKeyName); + metadata = await MetadataFormatter.Deserialize(reader, currentPath + "/" + MetaKeyName); break; default: reader.Skip(); @@ -98,7 +140,7 @@ private async Task DeserializePrimaryData(JsonReader reader, st { if (reader.TokenType == JsonToken.Null) return null; - var primaryData = await _resourceObjectFormatter.Deserialize(reader, currentPath); + var primaryData = await ResourceObjectFormatter.Deserialize(reader, currentPath); return primaryData; } } From 9e305ecff354035c8b1fc67c792ffff2daa5457e Mon Sep 17 00:00:00 2001 From: Chris Santero Date: Tue, 12 Apr 2016 19:16:35 -0400 Subject: [PATCH 04/22] add HttpRequestMessage to DeleteRecord handler (#107) --- .../DocumentMaterializers/StarshipDocumentMaterializer.cs | 2 +- .../Http/EntityFrameworkDocumentMaterializer.cs | 2 +- JSONAPI/Http/IDocumentMaterializer.cs | 2 +- JSONAPI/Http/JsonApiController.cs | 2 +- JSONAPI/Http/MappedDocumentMaterializer.cs | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipDocumentMaterializer.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipDocumentMaterializer.cs index 17cde210..9eb3fa47 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipDocumentMaterializer.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipDocumentMaterializer.cs @@ -62,7 +62,7 @@ public override Task UpdateRecord(string id, ISingleRes throw new NotImplementedException(); } - public override Task DeleteRecord(string id, CancellationToken cancellationToken) + public override Task DeleteRecord(string id, HttpRequestMessage request, CancellationToken cancellationToken) { throw new NotImplementedException(); } diff --git a/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs b/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs index cbe623ad..db2aaf29 100644 --- a/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs +++ b/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs @@ -86,7 +86,7 @@ public virtual async Task UpdateRecord(string id, ISing return returnDocument; } - public virtual async Task DeleteRecord(string id, CancellationToken cancellationToken) + public virtual async Task DeleteRecord(string id, HttpRequestMessage request, CancellationToken cancellationToken) { var singleResource = await _dbContext.Set().FindAsync(cancellationToken, id); _dbContext.Set().Remove(singleResource); diff --git a/JSONAPI/Http/IDocumentMaterializer.cs b/JSONAPI/Http/IDocumentMaterializer.cs index efe4d9bb..b4eb4bce 100644 --- a/JSONAPI/Http/IDocumentMaterializer.cs +++ b/JSONAPI/Http/IDocumentMaterializer.cs @@ -39,6 +39,6 @@ Task UpdateRecord(string id, ISingleResourceDocument re /// /// Deletes the record corresponding to the given id. /// - Task DeleteRecord(string id, CancellationToken cancellationToken); + Task DeleteRecord(string id, HttpRequestMessage request, CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/JSONAPI/Http/JsonApiController.cs b/JSONAPI/Http/JsonApiController.cs index 78f5b66a..09deb1b1 100644 --- a/JSONAPI/Http/JsonApiController.cs +++ b/JSONAPI/Http/JsonApiController.cs @@ -78,7 +78,7 @@ public virtual async Task Patch(string resourceType, string i public virtual async Task Delete(string resourceType, string id, CancellationToken cancellationToken) { var materializer = _documentMaterializerLocator.GetMaterializerByResourceTypeName(resourceType); - var document = await materializer.DeleteRecord(id, cancellationToken); + var document = await materializer.DeleteRecord(id, Request, cancellationToken); return Ok(document); } } diff --git a/JSONAPI/Http/MappedDocumentMaterializer.cs b/JSONAPI/Http/MappedDocumentMaterializer.cs index ee002270..4c53c2e0 100644 --- a/JSONAPI/Http/MappedDocumentMaterializer.cs +++ b/JSONAPI/Http/MappedDocumentMaterializer.cs @@ -102,8 +102,8 @@ public abstract Task UpdateRecord(string id, ISingleRes HttpRequestMessage request, CancellationToken cancellationToken); - public abstract Task DeleteRecord(string id, CancellationToken cancellationToken); - + public abstract Task DeleteRecord(string id, HttpRequestMessage request, CancellationToken cancellationToken); + /// /// Returns a list of property paths to be included when constructing a query for this resource type /// From c96ec02ec28ab4cb0fe0a0a9e3901178b5b6d4a3 Mon Sep 17 00:00:00 2001 From: Chris Santero Date: Tue, 26 Apr 2016 14:09:29 -0400 Subject: [PATCH 05/22] allow filtering by ID --- .../DefaultFilteringTransformerTests.cs | 15 +++++ .../DefaultFilteringTransformer.cs | 58 ++++++++++--------- 2 files changed, 47 insertions(+), 26 deletions(-) diff --git a/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs b/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs index 39850fc4..6b458e2a 100644 --- a/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs +++ b/JSONAPI.Tests/ActionFilters/DefaultFilteringTransformerTests.cs @@ -559,6 +559,21 @@ private Dummy[] GetArray(string uri) #region String + [TestMethod] + public void Filters_by_matching_string_id_property() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[id]=100"); + returnedArray.Length.Should().Be(1); + returnedArray[0].Id.Should().Be("100"); + } + + [TestMethod] + public void Filters_by_missing_string_id_property() + { + var returnedArray = GetArray("http://api.example.com/dummies?filter[id]="); + returnedArray.Length.Should().Be(0); + } + [TestMethod] public void Filters_by_matching_string_property() { diff --git a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs index 88e09605..f449d1bb 100644 --- a/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs +++ b/JSONAPI/QueryableTransformers/DefaultFilteringTransformer.cs @@ -2,11 +2,8 @@ using System.Globalization; using System.Linq; using System.Linq.Expressions; -using System.Net; using System.Net.Http; using System.Reflection; -using System.Web.Http; -using JSONAPI.ActionFilters; using JSONAPI.Core; using JSONAPI.Documents.Builders; @@ -83,41 +80,50 @@ private Expression GetPredicateBody(HttpRequestMessage request, ParameterExpress throw JsonApiException.CreateForBadRequest("No registration exists for the specified type"); } - var resourceTypeField = registration.GetFieldByName(filterField); - if (resourceTypeField == null) - throw JsonApiException.CreateForBadRequest( - string.Format("No attribute {0} exists on the specified type.", filterField)); + var expr = GetPredicate(filterField, registration, param, queryPair.Value); + workingExpr = workingExpr == null ? expr : Expression.AndAlso(workingExpr, expr); + } - var queryValue = queryPair.Value; - if (string.IsNullOrWhiteSpace(queryValue)) - queryValue = null; + return workingExpr ?? Expression.Constant(true); // No filters, so return everything + } - Expression expr = null; + private Expression GetPredicate(string filterField, IResourceTypeRegistration registration, ParameterExpression param, string queryValue) + { + if (filterField == "id") + return GetPredicateBodyForProperty(registration.IdProperty, queryValue, param); - // See if it is a field property - var fieldModelProperty = resourceTypeField as ResourceTypeAttribute; - if (fieldModelProperty != null) - expr = GetPredicateBodyForField(fieldModelProperty, queryValue, param); + var resourceTypeField = registration.GetFieldByName(filterField); + if (resourceTypeField == null) + throw JsonApiException.CreateForBadRequest( + string.Format("No attribute {0} exists on the specified type.", filterField)); - // See if it is a relationship property - var relationshipModelProperty = resourceTypeField as ResourceTypeRelationship; - if (relationshipModelProperty != null) - expr = GetPredicateBodyForRelationship(relationshipModelProperty, queryValue, param); + if (string.IsNullOrWhiteSpace(queryValue)) + queryValue = null; - if (expr == null) throw JsonApiException.CreateForBadRequest( - string.Format("The attribute {0} is unsupported for filtering.", filterField)); + // See if it is a field property + var fieldModelProperty = resourceTypeField as ResourceTypeAttribute; + if (fieldModelProperty != null) + return GetPredicateBodyForField(fieldModelProperty, queryValue, param); - workingExpr = workingExpr == null ? expr : Expression.AndAlso(workingExpr, expr); - } + // See if it is a relationship property + var relationshipModelProperty = resourceTypeField as ResourceTypeRelationship; + if (relationshipModelProperty != null) + return GetPredicateBodyForRelationship(relationshipModelProperty, queryValue, param); - return workingExpr ?? Expression.Constant(true); // No filters, so return everything + throw JsonApiException.CreateForBadRequest( + string.Format("The attribute {0} is unsupported for filtering.", filterField)); + } + + private Expression GetPredicateBodyForField(ResourceTypeAttribute resourceTypeAttribute, string queryValue, + ParameterExpression param) + { + return GetPredicateBodyForProperty(resourceTypeAttribute.Property, queryValue, param); } // ReSharper disable once FunctionComplexityOverflow // TODO: should probably break this method up - private Expression GetPredicateBodyForField(ResourceTypeAttribute resourceTypeAttribute, string queryValue, ParameterExpression param) + private Expression GetPredicateBodyForProperty(PropertyInfo prop, string queryValue, ParameterExpression param) { - var prop = resourceTypeAttribute.Property; var propertyType = prop.PropertyType; Expression expr; From 7f6431fb09b2c61878d252afe39fde51d09ef5a6 Mon Sep 17 00:00:00 2001 From: Chris Santero Date: Fri, 29 Apr 2016 15:42:51 -0400 Subject: [PATCH 06/22] add null checks in ResourceTypeRegistration --- JSONAPI/Core/ResourceTypeRegistration.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/JSONAPI/Core/ResourceTypeRegistration.cs b/JSONAPI/Core/ResourceTypeRegistration.cs index 5eef1b6c..7521b428 100644 --- a/JSONAPI/Core/ResourceTypeRegistration.cs +++ b/JSONAPI/Core/ResourceTypeRegistration.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; -using JSONAPI.Http; namespace JSONAPI.Core { @@ -22,6 +21,9 @@ internal ResourceTypeRegistration(Type type, PropertyInfo idProperty, string res Func filterByIdExpressionFactory, Func sortByIdExpressionFactory) { + if (type == null) throw new ArgumentNullException(nameof(type)); + if (idProperty == null) throw new ArgumentNullException(nameof(idProperty)); + if (resourceTypeName == null) throw new ArgumentNullException(nameof(resourceTypeName)); IdProperty = idProperty; Type = type; ResourceTypeName = resourceTypeName; @@ -44,11 +46,13 @@ internal ResourceTypeRegistration(Type type, PropertyInfo idProperty, string res public string GetIdForResource(object resource) { + if (resource == null) throw new ArgumentNullException(nameof(resource)); return IdProperty.GetValue(resource).ToString(); } public void SetIdForResource(object resource, string id) { + if (resource == null) throw new ArgumentNullException(nameof(resource)); IdProperty.SetValue(resource, id); // TODO: handle classes with non-string ID types } From 6a2f9682b50a2e1f136bfca0da3ef57f04a8b79f Mon Sep 17 00:00:00 2001 From: Chris Santero Date: Fri, 29 Apr 2016 15:56:47 -0400 Subject: [PATCH 07/22] provide more helpful error when the resource ID is null --- JSONAPI/Core/ResourceTypeRegistration.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/JSONAPI/Core/ResourceTypeRegistration.cs b/JSONAPI/Core/ResourceTypeRegistration.cs index 7521b428..ad35ef2f 100644 --- a/JSONAPI/Core/ResourceTypeRegistration.cs +++ b/JSONAPI/Core/ResourceTypeRegistration.cs @@ -47,7 +47,9 @@ internal ResourceTypeRegistration(Type type, PropertyInfo idProperty, string res public string GetIdForResource(object resource) { if (resource == null) throw new ArgumentNullException(nameof(resource)); - return IdProperty.GetValue(resource).ToString(); + var resourceId = IdProperty.GetValue(resource); + if (resourceId == null) throw new ArgumentException($"The ID for the provided `{ResourceTypeName}` resource is null."); + return resourceId.ToString(); } public void SetIdForResource(object resource, string id) From 9768449bffa7fc51f1cf5a4506d538c7bbc4bc8a Mon Sep 17 00:00:00 2001 From: Chris Santero Date: Wed, 8 Jun 2016 18:47:37 -0400 Subject: [PATCH 08/22] add non-generic version of IEphemeralRelatedResourceReader that has a generic method instead --- .../Core/EphemeralRelatedResourceReader.cs | 37 +++++++++++++++---- .../Core/IEphemeralRelatedResourceReader.cs | 18 ++++++++- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/JSONAPI/Core/EphemeralRelatedResourceReader.cs b/JSONAPI/Core/EphemeralRelatedResourceReader.cs index 9ef242d6..7f40ef7f 100644 --- a/JSONAPI/Core/EphemeralRelatedResourceReader.cs +++ b/JSONAPI/Core/EphemeralRelatedResourceReader.cs @@ -2,8 +2,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Text; -using System.Threading.Tasks; using JSONAPI.Documents; using JSONAPI.Json; @@ -13,11 +11,34 @@ namespace JSONAPI.Core /// Populates property values on an ephemeral resource /// /// + [Obsolete] public class EphemeralRelatedResourceReader : IEphemeralRelatedResourceReader + { + private readonly IEphemeralRelatedResourceReader _ephemeralRelatedResourceReader; + + /// + /// Creates a new EphemeralRelatedResourceReader + /// + /// + public EphemeralRelatedResourceReader(IEphemeralRelatedResourceReader ephemeralRelatedResourceReader) + { + _ephemeralRelatedResourceReader = ephemeralRelatedResourceReader; + } + + public void SetProperty(T ephemeralResource, string jsonKey, IRelationshipObject relationshipObject) + { + _ephemeralRelatedResourceReader.SetProperty(ephemeralResource, jsonKey, relationshipObject); + } + } + + /// + /// Populates property values on an ephemeral resource + /// + /// + public class EphemeralRelatedResourceReader : IEphemeralRelatedResourceReader { private readonly IResourceTypeRegistry _resourceTypeRegistry; private readonly IEphemeralRelatedResourceCreator _ephemeralRelatedResourceCreator; - private readonly Lazy _resourceTypeRegistration; private readonly MethodInfo _openSetToManyRelationshipValueMethod; /// @@ -29,14 +50,14 @@ public EphemeralRelatedResourceReader(IResourceTypeRegistry resourceTypeRegistry { _resourceTypeRegistry = resourceTypeRegistry; _ephemeralRelatedResourceCreator = ephemeralRelatedResourceCreator; - _resourceTypeRegistration = new Lazy(() => _resourceTypeRegistry.GetRegistrationForType(typeof(T))); _openSetToManyRelationshipValueMethod = GetType() .GetMethod("SetToManyRelationshipValue", BindingFlags.NonPublic | BindingFlags.Instance); } - public void SetProperty(T ephemeralResource, string jsonKey, IRelationshipObject relationshipObject) + public void SetProperty(T ephemeralResource, string jsonKey, IRelationshipObject relationshipObject) { - var relationship = _resourceTypeRegistration.Value.GetFieldByName(jsonKey) as ResourceTypeRelationship; + var resourceTypeRegistration = new Lazy(() => _resourceTypeRegistry.GetRegistrationForType(typeof(T))); + var relationship = resourceTypeRegistration.Value.GetFieldByName(jsonKey) as ResourceTypeRelationship; if (relationship == null) return; if (relationship.IsToMany) @@ -45,7 +66,7 @@ public void SetProperty(T ephemeralResource, string jsonKey, IRelationshipObject SetPropertyForToOneRelationship(ephemeralResource, relationship, relationshipObject.Linkage); } - protected virtual void SetPropertyForToOneRelationship(T ephemeralResource, ResourceTypeRelationship relationship, IResourceLinkage linkage) + protected virtual void SetPropertyForToOneRelationship(T ephemeralResource, ResourceTypeRelationship relationship, IResourceLinkage linkage) { if (linkage == null) throw new DeserializationException("Missing linkage for to-one relationship", @@ -70,7 +91,7 @@ protected virtual void SetPropertyForToOneRelationship(T ephemeralResource, Reso } } - protected virtual void SetPropertyForToManyRelationship(T ephemeralResource, ResourceTypeRelationship relationship, + protected virtual void SetPropertyForToManyRelationship(T ephemeralResource, ResourceTypeRelationship relationship, IResourceLinkage linkage) { if (linkage == null) diff --git a/JSONAPI/Core/IEphemeralRelatedResourceReader.cs b/JSONAPI/Core/IEphemeralRelatedResourceReader.cs index 827b455a..95a8fade 100644 --- a/JSONAPI/Core/IEphemeralRelatedResourceReader.cs +++ b/JSONAPI/Core/IEphemeralRelatedResourceReader.cs @@ -1,4 +1,5 @@ -using JSONAPI.Documents; +using System; +using JSONAPI.Documents; namespace JSONAPI.Core { @@ -6,6 +7,7 @@ namespace JSONAPI.Core /// Populates property values on an ephemeral resource from a relationship object /// /// + [Obsolete] public interface IEphemeralRelatedResourceReader { /// @@ -16,4 +18,18 @@ public interface IEphemeralRelatedResourceReader /// void SetProperty(T ephemeralResource, string jsonKey, IRelationshipObject relationshipObject); } + + /// + /// Populates property values on an ephemeral resource from a relationship object + /// + public interface IEphemeralRelatedResourceReader + { + /// + /// Sets the property on the ephemeral resource that corresponds to the given property + /// + /// + /// + /// + void SetProperty(T ephemeralResource, string jsonKey, IRelationshipObject relationshipObject); + } } \ No newline at end of file From 8a2477f7bff02c51a892e869aeaa5714352a0b15 Mon Sep 17 00:00:00 2001 From: Chris Santero Date: Fri, 24 Jun 2016 15:56:08 -0400 Subject: [PATCH 09/22] add attribute to allow not serializing a relationship --- JSONAPI/Attributes/LinkSettingsAttribute.cs | 25 +++++++++++++++++++ JSONAPI/Core/ResourceTypeRegistrar.cs | 15 +++++++---- JSONAPI/Core/ResourceTypeRelationship.cs | 17 ++++++++++++- .../Core/ToManyResourceTypeRelationship.cs | 4 +-- JSONAPI/Core/ToOneResourceTypeRelationship.cs | 4 +-- JSONAPI/Documents/DefaultLinkConventions.cs | 4 +++ JSONAPI/JSONAPI.csproj | 1 + 7 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 JSONAPI/Attributes/LinkSettingsAttribute.cs diff --git a/JSONAPI/Attributes/LinkSettingsAttribute.cs b/JSONAPI/Attributes/LinkSettingsAttribute.cs new file mode 100644 index 00000000..4c2b96cf --- /dev/null +++ b/JSONAPI/Attributes/LinkSettingsAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace JSONAPI.Attributes +{ + /// + /// This attribute should be added to a property to override DefaultLinkConventions's default + /// behavior for serializing links. + /// + [AttributeUsage(AttributeTargets.Property)] + public class LinkSettingsAttribute : Attribute + { + internal bool SerializeRelationshipLink; + + internal bool SerializeRelatedResourceLink; + + /// + /// Creates a new LinkSettingsAttribute. + /// + public LinkSettingsAttribute(bool serializeRelationshipLink = true, bool serializeRelatedResourceLink = true) + { + SerializeRelationshipLink = serializeRelationshipLink; + SerializeRelatedResourceLink = serializeRelatedResourceLink; + } + } +} diff --git a/JSONAPI/Core/ResourceTypeRegistrar.cs b/JSONAPI/Core/ResourceTypeRegistrar.cs index 55337a07..742e025c 100644 --- a/JSONAPI/Core/ResourceTypeRegistrar.cs +++ b/JSONAPI/Core/ResourceTypeRegistrar.cs @@ -4,7 +4,6 @@ using System.Linq.Expressions; using System.Reflection; using JSONAPI.Attributes; -using JSONAPI.Configuration; using JSONAPI.Extensions; using Newtonsoft.Json; @@ -165,17 +164,23 @@ protected virtual ResourceTypeField CreateResourceTypeField(PropertyInfo prop) } var selfLinkTemplateAttribute = prop.GetCustomAttributes().OfType().FirstOrDefault(); - var selfLinkTemplate = selfLinkTemplateAttribute == null ? null : selfLinkTemplateAttribute.TemplateString; + var selfLinkTemplate = selfLinkTemplateAttribute?.TemplateString; var relatedResourceLinkTemplateAttribute = prop.GetCustomAttributes().OfType().FirstOrDefault(); - var relatedResourceLinkTemplate = relatedResourceLinkTemplateAttribute == null ? null : relatedResourceLinkTemplateAttribute.TemplateString; + var relatedResourceLinkTemplate = relatedResourceLinkTemplateAttribute?.TemplateString; var isToMany = type.IsArray || (type.GetInterfaces().Contains(typeof(System.Collections.IEnumerable)) && type.IsGenericType); - if (!isToMany) return new ToOneResourceTypeRelationship(prop, jsonKey, type, selfLinkTemplate, relatedResourceLinkTemplate); + var linkSettingsAttribute = prop.GetCustomAttributes().OfType().FirstOrDefault(); + var serializeRelationshipLink = linkSettingsAttribute == null || linkSettingsAttribute.SerializeRelationshipLink; + var serializeRelatedResourceLink = linkSettingsAttribute == null || linkSettingsAttribute.SerializeRelatedResourceLink; + + if (!isToMany) return new ToOneResourceTypeRelationship(prop, jsonKey, type, selfLinkTemplate, relatedResourceLinkTemplate, + serializeRelationshipLink, serializeRelatedResourceLink); var relatedType = type.IsGenericType ? type.GetGenericArguments()[0] : type.GetElementType(); - return new ToManyResourceTypeRelationship(prop, jsonKey, relatedType, selfLinkTemplate, relatedResourceLinkTemplate); + return new ToManyResourceTypeRelationship(prop, jsonKey, relatedType, selfLinkTemplate, relatedResourceLinkTemplate, + serializeRelationshipLink, serializeRelatedResourceLink); } /// diff --git a/JSONAPI/Core/ResourceTypeRelationship.cs b/JSONAPI/Core/ResourceTypeRelationship.cs index d5ba5576..2955d94d 100644 --- a/JSONAPI/Core/ResourceTypeRelationship.cs +++ b/JSONAPI/Core/ResourceTypeRelationship.cs @@ -9,13 +9,16 @@ namespace JSONAPI.Core public abstract class ResourceTypeRelationship : ResourceTypeField { internal ResourceTypeRelationship(PropertyInfo property, string jsonKey, Type relatedType, - string selfLinkTemplate, string relatedResourceLinkTemplate, bool isToMany) + string selfLinkTemplate, string relatedResourceLinkTemplate, bool isToMany, + bool serializeRelationshipLink = true, bool serializeRelatedResourceLink = true) : base(property, jsonKey) { RelatedType = relatedType; SelfLinkTemplate = selfLinkTemplate; RelatedResourceLinkTemplate = relatedResourceLinkTemplate; IsToMany = isToMany; + SerializeRelationshipLink = serializeRelationshipLink; + SerializeRelatedResourceLink = serializeRelatedResourceLink; } /// @@ -41,5 +44,17 @@ internal ResourceTypeRelationship(PropertyInfo property, string jsonKey, Type re /// relationship belongs to. /// public string RelatedResourceLinkTemplate { get; private set; } + + /// + /// Whether to include a link to the relationship URL when serializing relationship objects for + /// this relationship + /// + public bool SerializeRelationshipLink { get; private set; } + + /// + /// Whether to include a link to the related resource URL when serializing relationship objects for + /// this relationship + /// + public bool SerializeRelatedResourceLink { get; private set; } } } \ No newline at end of file diff --git a/JSONAPI/Core/ToManyResourceTypeRelationship.cs b/JSONAPI/Core/ToManyResourceTypeRelationship.cs index c61b9e5b..0337ed85 100644 --- a/JSONAPI/Core/ToManyResourceTypeRelationship.cs +++ b/JSONAPI/Core/ToManyResourceTypeRelationship.cs @@ -9,8 +9,8 @@ namespace JSONAPI.Core public sealed class ToManyResourceTypeRelationship : ResourceTypeRelationship { internal ToManyResourceTypeRelationship(PropertyInfo property, string jsonKey, Type relatedType, - string selfLinkTemplate, string relatedResourceLinkTemplate) - : base(property, jsonKey, relatedType, selfLinkTemplate, relatedResourceLinkTemplate, true) + string selfLinkTemplate, string relatedResourceLinkTemplate, bool serializeRelationshipLink = true, bool serializeRelatedResourceLink = true) + : base(property, jsonKey, relatedType, selfLinkTemplate, relatedResourceLinkTemplate, true, serializeRelationshipLink, serializeRelatedResourceLink) { } } diff --git a/JSONAPI/Core/ToOneResourceTypeRelationship.cs b/JSONAPI/Core/ToOneResourceTypeRelationship.cs index cb9a57c4..25d148c8 100644 --- a/JSONAPI/Core/ToOneResourceTypeRelationship.cs +++ b/JSONAPI/Core/ToOneResourceTypeRelationship.cs @@ -9,8 +9,8 @@ namespace JSONAPI.Core public sealed class ToOneResourceTypeRelationship : ResourceTypeRelationship { internal ToOneResourceTypeRelationship(PropertyInfo property, string jsonKey, Type relatedType, - string selfLinkTemplate, string relatedResourceLinkTemplate) - : base(property, jsonKey, relatedType, selfLinkTemplate, relatedResourceLinkTemplate, false) + string selfLinkTemplate, string relatedResourceLinkTemplate, bool serializeRelationshipLink = true, bool serializeRelatedResourceLink = true) + : base(property, jsonKey, relatedType, selfLinkTemplate, relatedResourceLinkTemplate, false, serializeRelationshipLink, serializeRelatedResourceLink) { } } diff --git a/JSONAPI/Documents/DefaultLinkConventions.cs b/JSONAPI/Documents/DefaultLinkConventions.cs index 6ca5f30e..88d1c500 100644 --- a/JSONAPI/Documents/DefaultLinkConventions.cs +++ b/JSONAPI/Documents/DefaultLinkConventions.cs @@ -10,6 +10,8 @@ public class DefaultLinkConventions : ILinkConventions { public ILink GetRelationshipLink(TResource relationshipOwner, IResourceTypeRegistry resourceTypeRegistry, ResourceTypeRelationship property, string baseUrl) { + if (!property.SerializeRelationshipLink) return null; + var url = BuildRelationshipUrl(relationshipOwner, resourceTypeRegistry, property, baseUrl); var metadata = GetMetadataForRelationshipLink(relationshipOwner, property); return new Link(url, metadata); @@ -17,6 +19,8 @@ public ILink GetRelationshipLink(TResource relationshipOwner, IResour public ILink GetRelatedResourceLink(TResource relationshipOwner, IResourceTypeRegistry resourceTypeRegistry, ResourceTypeRelationship property, string baseUrl) { + if (!property.SerializeRelatedResourceLink) return null; + var url = BuildRelatedResourceUrl(relationshipOwner, resourceTypeRegistry, property, baseUrl); var metadata = GetMetadataForRelatedResourceLink(relationshipOwner, property); return new Link(url, metadata); diff --git a/JSONAPI/JSONAPI.csproj b/JSONAPI/JSONAPI.csproj index 2412f4cc..d6ed5c23 100644 --- a/JSONAPI/JSONAPI.csproj +++ b/JSONAPI/JSONAPI.csproj @@ -67,6 +67,7 @@ + From 44600295adc50aa012fd17d3ddf87ae43e34e3e0 Mon Sep 17 00:00:00 2001 From: Clint Good Date: Fri, 22 Jul 2016 01:58:52 +1000 Subject: [PATCH 10/22] Fix to allow filtering to work with entities that have integer keys (#109) * Add Master and Child test entities that use integer keys Add failing test retrieving children related to a master * Fix to allow Get_related_to_many_integer_key to pass --- .../Data/Child.csv | 7 + .../Data/Master.csv | 5 + .../FetchingResourcesTests.cs | 13 + ..._related_to_many_integer_key_response.json | 34 ++ ...sts.EntityFrameworkTestWebApp.Tests.csproj | 7 + ...anceTests.EntityFrameworkTestWebApp.csproj | 412 +++++++++--------- .../Models/Child.cs | 21 + .../Models/Master.cs | 16 + .../Models/TestDbContext.cs | 2 + .../Startup.cs | 7 +- JSONAPI/Core/ResourceTypeRegistrar.cs | 2 +- 11 files changed, 319 insertions(+), 207 deletions(-) create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/Child.csv create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/Master.csv create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_integer_key_response.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Child.cs create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Master.cs diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/Child.csv b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/Child.csv new file mode 100644 index 00000000..b529aab6 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/Child.csv @@ -0,0 +1,7 @@ +Id,ChildDescription,MasterId +7500,"Child 1 Description",1500 +7501,"Child 2 Description",1501 +7502,"Child 3 Description",1501 +7503,"Child 4 Description",1503 +7504,"Child 5 Description",1503 +7505,"Child 6 Description",1503 \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/Master.csv b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/Master.csv new file mode 100644 index 00000000..2a582cc4 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/Master.csv @@ -0,0 +1,5 @@ +Id,Description +1500,"Master 1 Description" +1501,"Master 2 Description" +1502,"Master 3 Description" +1503,"Master 4 Description" diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs index 5ec6cc98..36321862 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs @@ -183,5 +183,18 @@ await AssertResponseContent(response, HttpStatusCode.OK); } } + + [TestMethod] + [DeploymentItem(@"Data\Master.csv", @"Data")] + [DeploymentItem(@"Data\Child.csv", @"Data")] + public async Task Get_related_to_many_integer_key() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "masters/1501/children"); + + await AssertResponseContent(response, @"Fixtures\FetchingResources\Get_related_to_many_integer_key_response.json", HttpStatusCode.OK); + } + } } } diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_integer_key_response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_integer_key_response.json new file mode 100644 index 00000000..f99bab69 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_integer_key_response.json @@ -0,0 +1,34 @@ +{ + "data": [ + { + "type": "children", + "id": "7501", + "attributes": { + "child-description": "Child 2 Description" + }, + "relationships": { + "master": { + "links": { + "self": "https://www.example.com/children/7501/relationships/master", + "related": "https://www.example.com/children/7501/master" + } + } + } + }, + { + "type": "children", + "id": "7502", + "attributes": { + "child-description": "Child 3 Description" + }, + "relationships": { + "master": { + "links": { + "self": "https://www.example.com/children/7502/relationships/master", + "related": "https://www.example.com/children/7502/master" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj index 7f2a1699..f0cfb3c7 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj @@ -123,6 +123,12 @@ + + Always + + + Always + Always @@ -233,6 +239,7 @@ + diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj index 26e9df66..d7b1469b 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj @@ -1,212 +1,214 @@ - - - - - Debug - AnyCPU - - - 2.0 - {76DEE472-723B-4BE6-8B97-428AC326E30F} - {349c5851-65df-11da-9384-00065b846f21};{fae04ec0-301f-11d3-bf4b-00c04f79efbc} - Library - Properties - JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp - JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp - v4.5 - true - - - - - ..\ - true - - - true - full - false - bin\ - DEBUG;TRACE - prompt - 4 - - - pdbonly - true - bin\ - TRACE - prompt - 4 - - - - False - ..\packages\Autofac.3.5.2\lib\net40\Autofac.dll - - - False - ..\packages\Autofac.Owin.3.1.0\lib\net45\Autofac.Integration.Owin.dll - - - False - ..\packages\Autofac.WebApi2.3.4.0\lib\net45\Autofac.Integration.WebApi.dll - - - False - ..\packages\Autofac.WebApi2.Owin.3.2.0\lib\net45\Autofac.Integration.WebApi.Owin.dll - - - False - ..\packages\EntityFramework.6.1.3\lib\net45\EntityFramework.dll - - - False - ..\packages\EntityFramework.6.1.3\lib\net45\EntityFramework.SqlServer.dll - - - - ..\packages\Microsoft.Owin.3.0.0\lib\net45\Microsoft.Owin.dll - - - ..\packages\Microsoft.Owin.Host.SystemWeb.3.0.0\lib\net45\Microsoft.Owin.Host.SystemWeb.dll - - - False - ..\packages\Newtonsoft.Json.6.0.8\lib\net45\Newtonsoft.Json.dll - - - ..\packages\Owin.1.0\lib\net40\Owin.dll - - - - False - ..\packages\Microsoft.AspNet.WebApi.Client.5.2.2\lib\net45\System.Net.Http.Formatting.dll - - - - - - - - - - - - False - ..\packages\Microsoft.AspNet.WebApi.Core.5.2.2\lib\net45\System.Web.Http.dll - - - ..\packages\Microsoft.AspNet.WebApi.Owin.5.2.2\lib\net45\System.Web.Http.Owin.dll - - - - - - - - - - - - - Web.config - - - Web.config - - - - - Designer - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {64abe648-efcb-46ee-9e1a-e163f52bf372} - JSONAPI.Autofac.EntityFramework - - - {af7861f3-550b-4f70-a33e-1e5f48d39333} - JSONAPI.Autofac - - - {e906356c-93f6-41f6-9a0d-73b8a99aa53c} - JSONAPI.EntityFramework - - - {52b19fd6-efaa-45b5-9c3e-a652e27608d1} - JSONAPI - - - - 10.0 - $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - - - - - - - - - True - True - 9283 - / - http://localhost:9283/ - False - False - - - False - - - - - - - - This project references NuGet package(s) that are missing on this computer. Enable NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. - - - + + + + + Debug + AnyCPU + + + 2.0 + {76DEE472-723B-4BE6-8B97-428AC326E30F} + {349c5851-65df-11da-9384-00065b846f21};{fae04ec0-301f-11d3-bf4b-00c04f79efbc} + Library + Properties + JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp + JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp + v4.5 + true + + + + + ..\ + true + + + true + full + false + bin\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\ + TRACE + prompt + 4 + + + + False + ..\packages\Autofac.3.5.2\lib\net40\Autofac.dll + + + False + ..\packages\Autofac.Owin.3.1.0\lib\net45\Autofac.Integration.Owin.dll + + + False + ..\packages\Autofac.WebApi2.3.4.0\lib\net45\Autofac.Integration.WebApi.dll + + + False + ..\packages\Autofac.WebApi2.Owin.3.2.0\lib\net45\Autofac.Integration.WebApi.Owin.dll + + + False + ..\packages\EntityFramework.6.1.3\lib\net45\EntityFramework.dll + + + False + ..\packages\EntityFramework.6.1.3\lib\net45\EntityFramework.SqlServer.dll + + + + ..\packages\Microsoft.Owin.3.0.0\lib\net45\Microsoft.Owin.dll + + + ..\packages\Microsoft.Owin.Host.SystemWeb.3.0.0\lib\net45\Microsoft.Owin.Host.SystemWeb.dll + + + False + ..\packages\Newtonsoft.Json.6.0.8\lib\net45\Newtonsoft.Json.dll + + + ..\packages\Owin.1.0\lib\net40\Owin.dll + + + + False + ..\packages\Microsoft.AspNet.WebApi.Client.5.2.2\lib\net45\System.Net.Http.Formatting.dll + + + + + + + + + + + + False + ..\packages\Microsoft.AspNet.WebApi.Core.5.2.2\lib\net45\System.Web.Http.dll + + + ..\packages\Microsoft.AspNet.WebApi.Owin.5.2.2\lib\net45\System.Web.Http.Owin.dll + + + + + + + + + + + + + Web.config + + + Web.config + + + + + Designer + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {64abe648-efcb-46ee-9e1a-e163f52bf372} + JSONAPI.Autofac.EntityFramework + + + {af7861f3-550b-4f70-a33e-1e5f48d39333} + JSONAPI.Autofac + + + {e906356c-93f6-41f6-9a0d-73b8a99aa53c} + JSONAPI.EntityFramework + + + {52b19fd6-efaa-45b5-9c3e-a652e27608d1} + JSONAPI + + + + 10.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + + + + + + True + True + 9283 + / + http://localhost:9283/ + False + False + + + False + + + + + + + + This project references NuGet package(s) that are missing on this computer. Enable NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + --> \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Child.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Child.cs new file mode 100644 index 00000000..b3aefcc7 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Child.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models +{ + public class Child + { + public int Id { get; set; } + + public string ChildDescription { get; set; } + + [Required] + [JsonIgnore] + public int MasterId { get; set; } + + [ForeignKey("MasterId")] + public Master Master { get; set; } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Master.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Master.cs new file mode 100644 index 00000000..5ad2015c --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Master.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Web; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models +{ + public class Master + { + public int Id { get; set; } + + public string Description { get; set; } + + public virtual ICollection Children { get; set; } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/TestDbContext.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/TestDbContext.cs index 7efbb549..d0d8aec8 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/TestDbContext.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/TestDbContext.cs @@ -47,5 +47,7 @@ protected override void OnModelCreating(DbModelBuilder modelBuilder) public DbSet StarshipClasses { get; set; } public DbSet Officers { get; set; } public DbSet StarshipOfficerLinks { get; set; } + public DbSet Masters { get; set; } + public DbSet Children { get; set; } } } \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs index ae14df36..b4fee0ec 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs @@ -13,6 +13,7 @@ using JSONAPI.EntityFramework; using JSONAPI.EntityFramework.Configuration; using Owin; +using System.Collections.Generic; namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp { @@ -33,7 +34,9 @@ public Startup(Func dbContextFactory) public void Configuration(IAppBuilder app) { - var configuration = new JsonApiConfiguration(); + var configuration = new JsonApiConfiguration( + new Core.PluralizationService( + new Dictionary { { "Child", "Children" } })); configuration.RegisterEntityFrameworkResourceType(); configuration.RegisterEntityFrameworkResourceType(); configuration.RegisterEntityFrameworkResourceType(); @@ -58,6 +61,8 @@ public void Configuration(IAppBuilder app) rc => rc.UseMaterializer()); }); // Example of a resource that is mapped from a DB entity configuration.RegisterResourceType(); + configuration.RegisterEntityFrameworkResourceType(); + configuration.RegisterEntityFrameworkResourceType(); var configurator = new JsonApiHttpAutofacConfigurator(); configurator.OnApplicationLifetimeScopeCreating(builder => diff --git a/JSONAPI/Core/ResourceTypeRegistrar.cs b/JSONAPI/Core/ResourceTypeRegistrar.cs index 742e025c..5b960a91 100644 --- a/JSONAPI/Core/ResourceTypeRegistrar.cs +++ b/JSONAPI/Core/ResourceTypeRegistrar.cs @@ -77,7 +77,7 @@ public IResourceTypeRegistration BuildRegistration(Type type, string resourceTyp filterByIdFactory = (param, id) => { var propertyExpr = Expression.Property(param, idProperty); - var idExpr = Expression.Constant(id); + var idExpr = Expression.Constant(Convert.ChangeType(id, idProperty.PropertyType)); return Expression.Equal(propertyExpr, idExpr); }; } From ef5cb3c10017f04c3520da5c2060bdc551a569dc Mon Sep 17 00:00:00 2001 From: Simon Hofer Date: Thu, 4 Aug 2016 20:22:11 +0200 Subject: [PATCH 11/22] Fix: for non string keys with some tests / allow uppercase id (#110) * Fix: - allow ID to be in upper case (Oracle generates this by default in upper) - allow the id be long or decimal (Oracle numbers are mapped to decimal) * Made StringExtensions public it's handy to use in custom NamingConvention --- .../CreatingResourcesTests.cs | 46 ++++++++++++++ .../Data/PostID.csv | 5 ++ .../Data/PostLongId.csv | 5 ++ .../DeletingResourcesTests.cs | 62 ++++++++++++++++--- ...ostID_with_client_provided_id_Request.json | 11 ++++ ...ongId_with_client_provided_id_Request.json | 11 ++++ ...stID_with_client_provided_id_Response.json | 11 ++++ ...ngId_with_client_provided_id_Response.json | 11 ++++ .../PatchWithAttributeUpdateRequestID.json | 9 +++ ...PatchWithAttributeUpdateRequestLongId.json | 9 +++ .../PatchWithAttributeUpdateResponseID.json | 11 ++++ ...atchWithAttributeUpdateResponseLongId.json | 11 ++++ ...sts.EntityFrameworkTestWebApp.Tests.csproj | 14 +++++ .../UpdatingResourcesTests.cs | 46 ++++++++++++++ ...anceTests.EntityFrameworkTestWebApp.csproj | 14 +++-- .../Models/PostID.cs | 21 +++++++ .../Models/PostLongId.cs | 21 +++++++ .../Models/TestDbContext.cs | 2 + .../Startup.cs | 2 + .../DbContextExtensionsTests.cs | 27 ++++++++ .../JSONAPI.EntityFramework.Tests.csproj | 1 + .../Models/PostID.cs | 21 +++++++ ...tityFrameworkResourceObjectMaterializer.cs | 10 +-- .../EntityFrameworkDocumentMaterializer.cs | 2 +- JSONAPI/Core/ResourceTypeRegistrar.cs | 11 +++- JSONAPI/Extensions/StringExtensions.cs | 2 +- 26 files changed, 373 insertions(+), 23 deletions(-) create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostID.csv create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostLongId.csv create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Requests/PostID_with_client_provided_id_Request.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Requests/PostLongId_with_client_provided_id_Request.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/PostID_with_client_provided_id_Response.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/PostLongId_with_client_provided_id_Response.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Requests/PatchWithAttributeUpdateRequestID.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Requests/PatchWithAttributeUpdateRequestLongId.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateResponseID.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateResponseLongId.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostID.cs create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostLongId.cs create mode 100644 JSONAPI.EntityFramework.Tests/Models/PostID.cs diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/CreatingResourcesTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/CreatingResourcesTests.cs index 40a973df..e1b5a308 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/CreatingResourcesTests.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/CreatingResourcesTests.cs @@ -39,6 +39,52 @@ public async Task Post_with_client_provided_id() } } + [TestMethod] + [DeploymentItem(@"Data\PostID.csv", @"Data")] + public async Task PostID_with_client_provided_id() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitPost(effortConnection, "post-i-ds", @"Fixtures\CreatingResources\Requests\PostID_with_client_provided_id_Request.json"); + + await AssertResponseContent(response, @"Fixtures\CreatingResources\Responses\PostID_with_client_provided_id_Response.json", HttpStatusCode.OK); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.PostsID.ToArray(); + allPosts.Length.Should().Be(5); + var actualPost = allPosts.First(t => t.ID == "205"); + actualPost.ID.Should().Be("205"); + actualPost.Title.Should().Be("Added post"); + actualPost.Content.Should().Be("Added post content"); + actualPost.Created.Should().Be(new DateTimeOffset(2015, 03, 11, 04, 31, 0, new TimeSpan(0))); + } + } + } + + [TestMethod] + [DeploymentItem(@"Data\PostLongId.csv", @"Data")] + public async Task PostLongId_with_client_provided_id() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitPost(effortConnection, "post-long-ids", @"Fixtures\CreatingResources\Requests\PostLongId_with_client_provided_id_Request.json"); + + await AssertResponseContent(response, @"Fixtures\CreatingResources\Responses\PostLongId_with_client_provided_id_Response.json", HttpStatusCode.OK); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.PostsLongId.ToArray(); + allPosts.Length.Should().Be(5); + var actualPost = allPosts.First(t => t.Id == 205); + actualPost.Id.Should().Be(205); + actualPost.Title.Should().Be("Added post"); + actualPost.Content.Should().Be("Added post content"); + actualPost.Created.Should().Be(new DateTimeOffset(2015, 03, 11, 04, 31, 0, new TimeSpan(0))); + } + } + } + [TestMethod] [DeploymentItem(@"Data\Comment.csv", @"Data")] [DeploymentItem(@"Data\Post.csv", @"Data")] diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostID.csv b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostID.csv new file mode 100644 index 00000000..544b39c7 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostID.csv @@ -0,0 +1,5 @@ +ID,Title,Content,Created,AuthorId +"201","Post 1","Post 1 content","2015-01-31T14:00Z" +"202","Post 2","Post 2 content","2015-02-05T08:10Z" +"203","Post 3","Post 3 content","2015-02-07T11:11Z" +"204","Post 4","Post 4 content","2015-02-08T06:59Z" \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostLongId.csv b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostLongId.csv new file mode 100644 index 00000000..ac3f5a42 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Data/PostLongId.csv @@ -0,0 +1,5 @@ +Id,Title,Content,Created,AuthorId +201,"Post 1","Post 1 content","2015-01-31T14:00Z" +202,"Post 2","Post 2 content","2015-02-05T08:10Z" +203,"Post 3","Post 3 content","2015-02-07T11:11Z" +204,"Post 4","Post 4 content","2015-02-08T06:59Z" \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/DeletingResourcesTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/DeletingResourcesTests.cs index c5902256..f77715f5 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/DeletingResourcesTests.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/DeletingResourcesTests.cs @@ -11,11 +11,11 @@ namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests public class DeletingResourcesTests : AcceptanceTestsBase { [TestMethod] - [DeploymentItem(@"Data\Comment.csv", @"Acceptance\Data")] - [DeploymentItem(@"Data\Post.csv", @"Acceptance\Data")] - [DeploymentItem(@"Data\PostTagLink.csv", @"Acceptance\Data")] - [DeploymentItem(@"Data\Tag.csv", @"Acceptance\Data")] - [DeploymentItem(@"Data\User.csv", @"Acceptance\Data")] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] public async Task Delete() { using (var effortConnection = GetEffortConnection()) @@ -28,10 +28,54 @@ public async Task Delete() using (var dbContext = new TestDbContext(effortConnection, false)) { - var allTodos = dbContext.Posts.ToArray(); - allTodos.Length.Should().Be(3); - var actualTodo = allTodos.FirstOrDefault(t => t.Id == "203"); - actualTodo.Should().BeNull(); + var allPosts = dbContext.Posts.ToArray(); + allPosts.Length.Should().Be(3); + var actualPosts = allPosts.FirstOrDefault(t => t.Id == "203"); + actualPosts.Should().BeNull(); + } + } + } + + [TestMethod] + [DeploymentItem(@"Data\PostID.csv", @"Data")] + public async Task DeleteID() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitDelete(effortConnection, "post-i-ds/203"); + + var responseContent = await response.Content.ReadAsStringAsync(); + responseContent.Should().Be(""); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.PostsID.ToArray(); + allPosts.Length.Should().Be(3); + var actualPosts = allPosts.FirstOrDefault(t => t.ID == "203"); + actualPosts.Should().BeNull(); + } + } + } + + [TestMethod] + [DeploymentItem(@"Data\PostLongId.csv", @"Data")] + public async Task DeleteLongId() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitDelete(effortConnection, "post-long-ids/203"); + + var responseContent = await response.Content.ReadAsStringAsync(); + responseContent.Should().Be(""); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.PostsLongId.ToArray(); + allPosts.Length.Should().Be(3); + var actualPosts = allPosts.FirstOrDefault(t => t.Id == 203); + actualPosts.Should().BeNull(); } } } diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Requests/PostID_with_client_provided_id_Request.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Requests/PostID_with_client_provided_id_Request.json new file mode 100644 index 00000000..41424e02 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Requests/PostID_with_client_provided_id_Request.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "post-i-ds", + "id": "205", + "attributes": { + "title": "Added post", + "content": "Added post content", + "created": "2015-03-11T04:31:00+00:00" + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Requests/PostLongId_with_client_provided_id_Request.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Requests/PostLongId_with_client_provided_id_Request.json new file mode 100644 index 00000000..b52fe5c4 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Requests/PostLongId_with_client_provided_id_Request.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "post-long-ids", + "id": "205", + "attributes": { + "title": "Added post", + "content": "Added post content", + "created": "2015-03-11T04:31:00+00:00" + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/PostID_with_client_provided_id_Response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/PostID_with_client_provided_id_Response.json new file mode 100644 index 00000000..7122e9e9 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/PostID_with_client_provided_id_Response.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "post-i-ds", + "id": "205", + "attributes": { + "content": "Added post content", + "created": "2015-03-11T04:31:00.0000000+00:00", + "title": "Added post" + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/PostLongId_with_client_provided_id_Response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/PostLongId_with_client_provided_id_Response.json new file mode 100644 index 00000000..dc5ee0f8 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/PostLongId_with_client_provided_id_Response.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "post-long-ids", + "id": "205", + "attributes": { + "content": "Added post content", + "created": "2015-03-11T04:31:00.0000000+00:00", + "title": "Added post" + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Requests/PatchWithAttributeUpdateRequestID.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Requests/PatchWithAttributeUpdateRequestID.json new file mode 100644 index 00000000..ac7fbb4b --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Requests/PatchWithAttributeUpdateRequestID.json @@ -0,0 +1,9 @@ +{ + "data": { + "type": "post-i-ds", + "id": "202", + "attributes": { + "title": "New post title" + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Requests/PatchWithAttributeUpdateRequestLongId.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Requests/PatchWithAttributeUpdateRequestLongId.json new file mode 100644 index 00000000..7358d7a8 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Requests/PatchWithAttributeUpdateRequestLongId.json @@ -0,0 +1,9 @@ +{ + "data": { + "type": "post-long-ids", + "id": "202", + "attributes": { + "title": "New post title" + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateResponseID.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateResponseID.json new file mode 100644 index 00000000..b0448cb5 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateResponseID.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "post-i-ds", + "id": "202", + "attributes": { + "content": "Post 2 content", + "created": "2015-02-05T08:10:00.0000000+00:00", + "title": "New post title" + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateResponseLongId.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateResponseLongId.json new file mode 100644 index 00000000..fea5fa0a --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateResponseLongId.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "post-long-ids", + "id": "202", + "attributes": { + "content": "Post 2 content", + "created": "2015-02-05T08:10:00.0000000+00:00", + "title": "New post title" + } + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj index f0cfb3c7..f7a3c706 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj @@ -132,6 +132,12 @@ Always + + Always + + + Always + Always @@ -240,6 +246,14 @@ + + + + + + + + diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/UpdatingResourcesTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/UpdatingResourcesTests.cs index 9d898537..48df90f9 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/UpdatingResourcesTests.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/UpdatingResourcesTests.cs @@ -41,6 +41,52 @@ public async Task PatchWithAttributeUpdate() } } + [TestMethod] + [DeploymentItem(@"Data\PostID.csv", @"Data")] + public async Task PatchWithAttributeUpdateID() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitPatch(effortConnection, "post-i-ds/202", @"Fixtures\UpdatingResources\Requests\PatchWithAttributeUpdateRequestID.json"); + + await AssertResponseContent(response, @"Fixtures\UpdatingResources\Responses\PatchWithAttributeUpdateResponseID.json", HttpStatusCode.OK); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.PostsID; + allPosts.Count().Should().Be(4); + var actualPost = allPosts.First(t => t.ID == "202"); + actualPost.ID.Should().Be("202"); + actualPost.Title.Should().Be("New post title"); + actualPost.Content.Should().Be("Post 2 content"); + actualPost.Created.Should().Be(new DateTimeOffset(2015, 02, 05, 08, 10, 0, new TimeSpan(0))); + } + } + } + + [TestMethod] + [DeploymentItem(@"Data\PostLongId.csv", @"Data")] + public async Task PatchWithAttributeUpdateLongId() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitPatch(effortConnection, "post-long-ids/202", @"Fixtures\UpdatingResources\Requests\PatchWithAttributeUpdateRequestLongId.json"); + + await AssertResponseContent(response, @"Fixtures\UpdatingResources\Responses\PatchWithAttributeUpdateResponseLongId.json", HttpStatusCode.OK); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.PostsLongId; + allPosts.Count().Should().Be(4); + var actualPost = allPosts.First(t => t.Id == 202); + actualPost.Id.Should().Be(202); + actualPost.Title.Should().Be("New post title"); + actualPost.Content.Should().Be("Post 2 content"); + actualPost.Created.Should().Be(new DateTimeOffset(2015, 02, 05, 08, 10, 0, new TimeSpan(0))); + } + } + } + [TestMethod] [DeploymentItem(@"Data\Comment.csv", @"Data")] [DeploymentItem(@"Data\Post.csv", @"Data")] diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj index d7b1469b..0a5148ea 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.csproj @@ -138,6 +138,8 @@ + + @@ -204,11 +206,11 @@ - \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostID.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostID.cs new file mode 100644 index 00000000..4e7bd60f --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostID.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models +{ + public class PostID + { + [Key] + public string ID { get; set; } + + public string Title { get; set; } + + public string Content { get; set; } + + public DateTimeOffset Created { get; set; } + + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostLongId.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostLongId.cs new file mode 100644 index 00000000..24e830d5 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/PostLongId.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models +{ + public class PostLongId + { + [Key] + public long Id { get; set; } + + public string Title { get; set; } + + public string Content { get; set; } + + public DateTimeOffset Created { get; set; } + + } +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/TestDbContext.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/TestDbContext.cs index d0d8aec8..4850d0a0 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/TestDbContext.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/TestDbContext.cs @@ -40,6 +40,8 @@ protected override void OnModelCreating(DbModelBuilder modelBuilder) public DbSet Comments { get; set; } public DbSet Languages { get; set; } public DbSet Posts { get; set; } + public DbSet PostsID { get; set; } + public DbSet PostsLongId { get; set; } public DbSet Tags { get; set; } public DbSet Users { get; set; } public DbSet LanguageUserLinks { get; set; } diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs index b4fee0ec..371b9a22 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs @@ -48,6 +48,8 @@ public void Configuration(IAppBuilder app) c.OverrideDefaultSortById(LanguageUserLinkSortByIdFactory); }); configuration.RegisterResourceType(); + configuration.RegisterResourceType(); + configuration.RegisterResourceType(); configuration.RegisterEntityFrameworkResourceType(); configuration.RegisterEntityFrameworkResourceType(); configuration.RegisterEntityFrameworkResourceType(); diff --git a/JSONAPI.EntityFramework.Tests/DbContextExtensionsTests.cs b/JSONAPI.EntityFramework.Tests/DbContextExtensionsTests.cs index 4b8d4ed2..8548b6bd 100644 --- a/JSONAPI.EntityFramework.Tests/DbContextExtensionsTests.cs +++ b/JSONAPI.EntityFramework.Tests/DbContextExtensionsTests.cs @@ -17,6 +17,7 @@ private class TestDbContext : DbContext { public DbSet Backlinks { get; set; } public DbSet Posts { get; set; } + public DbSet PostIDs { get; set; } public TestDbContext(DbConnection conn) : base(conn, true) { @@ -34,6 +35,10 @@ private class SubPost : Post { public string Foo { get; set; } } + private class SubPostID : PostID + { + public string Foo { get; set; } + } private DbConnection _conn; private TestDbContext _context; @@ -71,6 +76,17 @@ public void GetKeyNamesStandardIdTest() keyNames.First().Should().Be("Id"); } + [TestMethod] + public void GetKeyNamesStandardIDTest() + { + // Act + IEnumerable keyNames = _context.GetKeyNames(typeof(PostID)).ToArray(); + + // Assert + keyNames.Count().Should().Be(1); + keyNames.First().Should().Be("ID"); + } + [TestMethod] public void GetKeyNamesNonStandardIdTest() { @@ -93,6 +109,17 @@ public void GetKeyNamesForChildClass() keyNames.First().Should().Be("Id"); } + [TestMethod] + public void GetKeyNamesForChildClassID() + { + // Act + IEnumerable keyNames = _context.GetKeyNames(typeof(SubPostID)).ToArray(); + + // Assert + keyNames.Count().Should().Be(1); + keyNames.First().Should().Be("ID"); + } + [TestMethod] public void GetKeyNamesNotAnEntityTest() { diff --git a/JSONAPI.EntityFramework.Tests/JSONAPI.EntityFramework.Tests.csproj b/JSONAPI.EntityFramework.Tests/JSONAPI.EntityFramework.Tests.csproj index 2938cf78..6e341345 100644 --- a/JSONAPI.EntityFramework.Tests/JSONAPI.EntityFramework.Tests.csproj +++ b/JSONAPI.EntityFramework.Tests/JSONAPI.EntityFramework.Tests.csproj @@ -119,6 +119,7 @@ + diff --git a/JSONAPI.EntityFramework.Tests/Models/PostID.cs b/JSONAPI.EntityFramework.Tests/Models/PostID.cs new file mode 100644 index 00000000..420e047a --- /dev/null +++ b/JSONAPI.EntityFramework.Tests/Models/PostID.cs @@ -0,0 +1,21 @@ +namespace JSONAPI.EntityFramework.Tests.Models +{ + using System; + using System.Collections.Generic; + using System.ComponentModel.DataAnnotations.Schema; + + public partial class PostID + { + public PostID() + { + this.Comments = new HashSet(); + } + + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public Guid ID { get; set; } + public string Title { get; set; } + + public virtual Author Author { get; set; } + public virtual ICollection Comments { get; set; } + } +} diff --git a/JSONAPI.EntityFramework/EntityFrameworkResourceObjectMaterializer.cs b/JSONAPI.EntityFramework/EntityFrameworkResourceObjectMaterializer.cs index 57c2b29b..b0b26ea9 100644 --- a/JSONAPI.EntityFramework/EntityFrameworkResourceObjectMaterializer.cs +++ b/JSONAPI.EntityFramework/EntityFrameworkResourceObjectMaterializer.cs @@ -69,8 +69,10 @@ public async Task MaterializeResourceObject(IResourceObject resourceObje /// protected virtual Task SetIdForNewResource(IResourceObject resourceObject, object newObject, IResourceTypeRegistration typeRegistration) { - typeRegistration.IdProperty.SetValue(newObject, resourceObject.Id); - + if (resourceObject.Id != null) + { + typeRegistration.IdProperty.SetValue(newObject, Convert.ChangeType(resourceObject.Id, typeRegistration.IdProperty.PropertyType)); + } return Task.FromResult(0); } @@ -82,7 +84,7 @@ protected virtual async Task GetExistingRecord(IResourceTypeRegistration ResourceTypeRelationship[] relationshipsToInclude, CancellationToken cancellationToken) { var method = _openGetExistingRecordGenericMethod.MakeGenericMethod(registration.Type); - var result = (dynamic) method.Invoke(this, new object[] {registration, id, relationshipsToInclude, cancellationToken}); + var result = (dynamic) method.Invoke(this, new object[] {registration, id, relationshipsToInclude, cancellationToken}); // no convert needed => see GetExistingRecordGeneric => filterByIdFactory will do it return await result; } @@ -170,7 +172,7 @@ protected async Task GetExistingRecordGeneric(IResourceTypeReg string id, ResourceTypeRelationship[] relationshipsToInclude, CancellationToken cancellationToken) where TRecord : class { var param = Expression.Parameter(registration.Type); - var filterExpression = registration.GetFilterByIdExpression(param, id); + var filterExpression = registration.GetFilterByIdExpression(param, id); // no conversion of id => filterByIdFactory will do it var lambda = Expression.Lambda>(filterExpression, param); var query = _dbContext.Set().AsQueryable() .Where(lambda); diff --git a/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs b/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs index db2aaf29..d7b3ce73 100644 --- a/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs +++ b/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs @@ -88,7 +88,7 @@ public virtual async Task UpdateRecord(string id, ISing public virtual async Task DeleteRecord(string id, HttpRequestMessage request, CancellationToken cancellationToken) { - var singleResource = await _dbContext.Set().FindAsync(cancellationToken, id); + var singleResource = await _dbContext.Set().FindAsync(cancellationToken, Convert.ChangeType(id, _resourceTypeRegistration.IdProperty.PropertyType)); _dbContext.Set().Remove(singleResource); await _dbContext.SaveChangesAsync(cancellationToken); diff --git a/JSONAPI/Core/ResourceTypeRegistrar.cs b/JSONAPI/Core/ResourceTypeRegistrar.cs index 5b960a91..bedd6e21 100644 --- a/JSONAPI/Core/ResourceTypeRegistrar.cs +++ b/JSONAPI/Core/ResourceTypeRegistrar.cs @@ -77,7 +77,14 @@ public IResourceTypeRegistration BuildRegistration(Type type, string resourceTyp filterByIdFactory = (param, id) => { var propertyExpr = Expression.Property(param, idProperty); - var idExpr = Expression.Constant(Convert.ChangeType(id, idProperty.PropertyType)); + object obj = id; + if (obj == null) + { + var t = propertyExpr.Type; + if (t.IsValueType) + obj = Activator.CreateInstance(t); + } + var idExpr = Expression.Constant(Convert.ChangeType(obj, propertyExpr.Type)); return Expression.Equal(propertyExpr, idExpr); }; } @@ -194,7 +201,7 @@ protected virtual PropertyInfo CalculateIdProperty(Type type) type .GetProperties() .FirstOrDefault(p => p.CustomAttributes.Any(attr => attr.AttributeType == typeof(UseAsIdAttribute))) - ?? type.GetProperty("Id"); + ?? type.GetProperty("Id") ?? type.GetProperty("ID"); } } } \ No newline at end of file diff --git a/JSONAPI/Extensions/StringExtensions.cs b/JSONAPI/Extensions/StringExtensions.cs index 89826301..7f4e9ad4 100644 --- a/JSONAPI/Extensions/StringExtensions.cs +++ b/JSONAPI/Extensions/StringExtensions.cs @@ -2,7 +2,7 @@ namespace JSONAPI.Extensions { - internal static class StringExtensions + public static class StringExtensions { private static readonly Regex PascalizeRegex = new Regex(@"(?:^|_|\-|\.)(.)"); From 5ae40e89f2be28b408a23be808a3dd9e237d2066 Mon Sep 17 00:00:00 2001 From: Simon Hofer Date: Fri, 5 Aug 2016 16:48:32 +0200 Subject: [PATCH 12/22] Feat: context path for api (#114) * extended JsonApiHttpConfiguration to allow BaseUrlService with configured context path * Feat: context path for api - option to configure context path for api which will be respected in routing and for urls in response - option to configure the public origin address which will be used in response urls closes #112 --- .../AcceptanceTestsBase.cs | 41 ++- .../BaseUrlTest.cs | 166 ++++++++++ ...sts.EntityFrameworkTestWebApp.Tests.csproj | 17 ++ .../packages.config | 3 + .../Properties/AssemblyInfo.cs | 2 + .../Startup.cs | 51 +++- JSONAPI.Autofac/JsonApiAutofacModule.cs | 9 +- .../JsonApiHttpAutofacConfigurator.cs | 2 +- JSONAPI.Tests/Http/BaseUrlServiceTest.cs | 287 ++++++++++++++++++ JSONAPI.Tests/JSONAPI.Tests.csproj | 1 + JSONAPI/Configuration/JsonApiConfiguration.cs | 6 + .../Configuration/JsonApiHttpConfiguration.cs | 15 +- JSONAPI/Http/BaseUrlService.cs | 103 ++++++- JSONAPI/Http/IBaseUrlService.cs | 6 + README.md | 39 +++ 15 files changed, 726 insertions(+), 22 deletions(-) create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/BaseUrlTest.cs create mode 100644 JSONAPI.Tests/Http/BaseUrlServiceTest.cs diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/AcceptanceTestsBase.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/AcceptanceTestsBase.cs index 40aa3945..133548dc 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/AcceptanceTestsBase.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/AcceptanceTestsBase.cs @@ -1,5 +1,6 @@ using System; using System.Data.Common; +using System.IO; using System.Net; using System.Net.Http; using System.Text; @@ -10,6 +11,7 @@ using JSONAPI.Json; using Microsoft.Owin.Testing; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Owin; namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests { @@ -20,19 +22,18 @@ public abstract class AcceptanceTestsBase private static readonly Regex GuidRegex = new Regex(@"\b[A-F0-9]{8}(?:-[A-F0-9]{4}){3}-[A-F0-9]{12}\b", RegexOptions.IgnoreCase); //private static readonly Regex StackTraceRegex = new Regex(@"""stackTrace"":[\s]*""[\w\:\\\.\s\,\-]*"""); private static readonly Regex StackTraceRegex = new Regex(@"""stackTrace""[\s]*:[\s]*"".*?"""); - private static readonly Uri BaseUri = new Uri("https://www.example.com"); + protected static Uri BaseUri = new Uri("https://www.example.com"); protected static DbConnection GetEffortConnection() { return TestHelpers.GetEffortConnection(@"Data"); } - protected static async Task AssertResponseContent(HttpResponseMessage response, string expectedResponseTextResourcePath, HttpStatusCode expectedStatusCode, bool redactErrorData = false) + protected virtual async Task AssertResponseContent(HttpResponseMessage response, string expectedResponseTextResourcePath, HttpStatusCode expectedStatusCode, bool redactErrorData = false) { var responseContent = await response.Content.ReadAsStringAsync(); - var expectedResponse = - JsonHelpers.MinifyJson(TestHelpers.ReadEmbeddedFile(expectedResponseTextResourcePath)); + var expectedResponse = ExpectedResponse(expectedResponseTextResourcePath); string actualResponse; if (redactErrorData) { @@ -51,6 +52,13 @@ protected static async Task AssertResponseContent(HttpResponseMessage response, response.StatusCode.Should().Be(expectedStatusCode); } + protected virtual string ExpectedResponse(string expectedResponseTextResourcePath) + { + var expectedResponse = + JsonHelpers.MinifyJson(TestHelpers.ReadEmbeddedFile(expectedResponseTextResourcePath)); + return expectedResponse; + } + #region GET protected async Task SubmitGet(DbConnection effortConnection, string requestPath) @@ -58,7 +66,7 @@ protected async Task SubmitGet(DbConnection effortConnectio using (var server = TestServer.Create(app => { var startup = new Startup(() => new TestDbContext(effortConnection, false)); - startup.Configuration(app); + StartupConfiguration(startup, app); })) { var uri = new Uri(BaseUri, requestPath); @@ -75,7 +83,7 @@ protected async Task SubmitPost(DbConnection effortConnecti using (var server = TestServer.Create(app => { var startup = new Startup(() => new TestDbContext(effortConnection, false)); - startup.Configuration(app); + StartupConfiguration(startup, app); })) { var uri = new Uri(BaseUri, requestPath); @@ -100,7 +108,7 @@ protected async Task SubmitPatch(DbConnection effortConnect using (var server = TestServer.Create(app => { var startup = new Startup(() => new TestDbContext(effortConnection, false)); - startup.Configuration(app); + StartupConfiguration(startup, app); })) { var uri = new Uri(BaseUri, requestPath); @@ -124,7 +132,7 @@ protected async Task SubmitDelete(DbConnection effortConnec using (var server = TestServer.Create(app => { var startup = new Startup(() => new TestDbContext(effortConnection, false)); - startup.Configuration(app); + StartupConfiguration(startup, app); })) { var uri = new Uri(BaseUri, requestPath); @@ -137,5 +145,22 @@ protected async Task SubmitDelete(DbConnection effortConnec } #endregion + + + + #region configure startup + + /// + /// Startup process was divided into 4 steps to support better acceptance tests. + /// This method can be overridden by subclass to change behavior of setup. + /// + /// + /// + protected virtual void StartupConfiguration(Startup startup, IAppBuilder app) + { + startup.Configuration(app); + } + + #endregion } } diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/BaseUrlTest.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/BaseUrlTest.cs new file mode 100644 index 00000000..cda8ccf8 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/BaseUrlTest.cs @@ -0,0 +1,166 @@ +using System; +using System.Data.SqlTypes; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using FluentAssertions; +using JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models; +using JSONAPI.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Owin; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests +{ + [TestClass] + public class BaseUrlTest : AcceptanceTestsBase + { + [TestInitialize] + public void TestInit() + { + if (!BaseUri.AbsoluteUri.EndsWith("api/")) + { + BaseUri = new Uri(BaseUri.AbsoluteUri + "api/"); + } + } + [TestCleanup] + public void TestCleanup() + { + if (BaseUri.AbsoluteUri.EndsWith("api/")) + { + BaseUri = new Uri(BaseUri.AbsoluteUri.Substring(0,BaseUri.AbsoluteUri.Length -4)); + } + } + + // custom startup process for this test + protected override void StartupConfiguration(Startup startup, IAppBuilder app) + { + + var configuration = startup.BuildConfiguration(); + // here we add the custom BaseUrlServcie + configuration.CustomBaseUrlService = new BaseUrlService("api"); + var configurator = startup.BuildAutofacConfigurator(app); + var httpConfig = startup.BuildHttpConfiguration(); + startup.MergeAndSetupConfiguration(app, configurator, httpConfig, configuration); + } + + // custom expected response method + protected override string ExpectedResponse(string expectedResponseTextResourcePath) + { + var expected = base.ExpectedResponse(expectedResponseTextResourcePath); + return Regex.Replace(expected, @"www\.example\.com\/", @"www.example.com/api/"); + } + + // copied some tests in here + + // copied from ComputedIdTests + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Language.csv", @"Data")] + [DeploymentItem(@"Data\LanguageUserLink.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task Get_resource_with_computed_id_by_id() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "language-user-links/9001_402"); + + await AssertResponseContent(response, @"Fixtures\ComputedId\Responses\Get_resource_with_computed_id_by_id_Response.json", HttpStatusCode.OK); + } + } + + + // copied from CreatingResourcesTests + + + [TestMethod] + [DeploymentItem(@"Data\PostLongId.csv", @"Data")] + public async Task PostLongId_with_client_provided_id() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitPost(effortConnection, "post-long-ids", @"Fixtures\CreatingResources\Requests\PostLongId_with_client_provided_id_Request.json"); + + await AssertResponseContent(response, @"Fixtures\CreatingResources\Responses\PostLongId_with_client_provided_id_Response.json", HttpStatusCode.OK); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.PostsLongId.ToArray(); + allPosts.Length.Should().Be(5); + var actualPost = allPosts.First(t => t.Id == 205); + actualPost.Id.Should().Be(205); + actualPost.Title.Should().Be("Added post"); + actualPost.Content.Should().Be("Added post content"); + actualPost.Created.Should().Be(new DateTimeOffset(2015, 03, 11, 04, 31, 0, new TimeSpan(0))); + } + } + } + + + + // copied from DeletingResourcesTests + + [TestMethod] + [DeploymentItem(@"Data\PostID.csv", @"Data")] + public async Task DeleteID() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitDelete(effortConnection, "post-i-ds/203"); + + var responseContent = await response.Content.ReadAsStringAsync(); + responseContent.Should().Be(""); + response.StatusCode.Should().Be(HttpStatusCode.NoContent); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.PostsID.ToArray(); + allPosts.Length.Should().Be(3); + var actualPosts = allPosts.FirstOrDefault(t => t.ID == "203"); + actualPosts.Should().BeNull(); + } + } + } + + + + // copied from FetchingResourcesTests + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task GetWithFilter() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "posts?filter[title]=Post 4"); + + await AssertResponseContent(response, @"Fixtures\FetchingResources\GetWithFilterResponse.json", HttpStatusCode.OK); + } + } + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task GetById() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "posts/202"); + + await AssertResponseContent(response, @"Fixtures\FetchingResources\GetByIdResponse.json", HttpStatusCode.OK); + } + } + + } +} diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj index f7a3c706..b335e6c5 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj @@ -61,6 +61,10 @@ ..\packages\Microsoft.Owin.Testing.3.0.0\lib\net45\Microsoft.Owin.Testing.dll + + ..\packages\Newtonsoft.Json.6.0.8\lib\net45\Newtonsoft.Json.dll + True + ..\packages\NMemory.1.0.1\lib\net45\NMemory.dll @@ -71,7 +75,15 @@ + + ..\packages\Microsoft.AspNet.WebApi.Client.5.2.2\lib\net45\System.Net.Http.Formatting.dll + True + + + ..\packages\Microsoft.AspNet.WebApi.Core.5.2.2\lib\net45\System.Web.Http.dll + True + @@ -98,6 +110,7 @@ + @@ -107,6 +120,10 @@ {76dee472-723b-4be6-8b97-428ac326e30f} JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp + + {AF7861F3-550B-4F70-A33E-1E5F48D39333} + JSONAPI.Autofac + {52b19fd6-efaa-45b5-9c3e-a652e27608d1} JSONAPI diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/packages.config b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/packages.config index f704445b..0243217e 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/packages.config +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/packages.config @@ -3,9 +3,12 @@ + + + \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Properties/AssemblyInfo.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Properties/AssemblyInfo.cs index 02097fc4..130abbb8 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Properties/AssemblyInfo.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Properties/AssemblyInfo.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; // General Information about an assembly is controlled through the following @@ -32,3 +33,4 @@ // by using the '*' as shown below: [assembly: AssemblyVersion("1.0.0.0")] [assembly: AssemblyFileVersion("1.0.0.0")] +[assembly: InternalsVisibleTo("JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests")] \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs index 371b9a22..aec594d2 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs @@ -33,9 +33,35 @@ public Startup(Func dbContextFactory) } public void Configuration(IAppBuilder app) + { + /* these steps are divided in multiple methods to support better acceptance tests + * in production all the steps can be made inside the Configuration method. + */ + var configuration = BuildConfiguration(); + + var configurator = BuildAutofacConfigurator(app); + + var httpConfig = BuildHttpConfiguration(); + + MergeAndSetupConfiguration(app, configurator, httpConfig, configuration); + } + + internal void MergeAndSetupConfiguration(IAppBuilder app, JsonApiHttpAutofacConfigurator configurator, + HttpConfiguration httpConfig, JsonApiConfiguration configuration) + { + configurator.Apply(httpConfig, configuration); + app.UseWebApi(httpConfig); + app.UseAutofacWebApi(httpConfig); + } + + /// + /// Build up the which registers all the model types and their mappings. + /// + /// + internal JsonApiConfiguration BuildConfiguration() { var configuration = new JsonApiConfiguration( - new Core.PluralizationService( + new Core.PluralizationService( new Dictionary { { "Child", "Children" } })); configuration.RegisterEntityFrameworkResourceType(); configuration.RegisterEntityFrameworkResourceType(); @@ -65,7 +91,16 @@ public void Configuration(IAppBuilder app) configuration.RegisterResourceType(); configuration.RegisterEntityFrameworkResourceType(); configuration.RegisterEntityFrameworkResourceType(); + return configuration; + } + /// + /// Build up the which registers , modules and materializer + /// + /// + /// + internal JsonApiHttpAutofacConfigurator BuildAutofacConfigurator(IAppBuilder app) + { var configurator = new JsonApiHttpAutofacConfigurator(); configurator.OnApplicationLifetimeScopeCreating(builder => { @@ -83,7 +118,15 @@ public void Configuration(IAppBuilder app) // TODO: is this a candidate for spinning into a JSONAPI.Autofac.WebApi.Owin package? Yuck app.UseAutofacMiddleware(applicationLifetimeScope); }); + return configurator; + } + /// + /// Build up the with additional routes + /// + /// + internal HttpConfiguration BuildHttpConfiguration() + { var httpConfig = new HttpConfiguration { IncludeErrorDetailPolicy = IncludeErrorDetailPolicy.Always @@ -93,12 +136,10 @@ public void Configuration(IAppBuilder app) httpConfig.Routes.MapHttpRoute("Samples", "samples", new { Controller = "Samples" }); httpConfig.Routes.MapHttpRoute("Search", "search", new { Controller = "Search" }); httpConfig.Routes.MapHttpRoute("Trees", "trees", new { Controller = "Trees" }); - - configurator.Apply(httpConfig, configuration); - app.UseWebApi(httpConfig); - app.UseAutofacWebApi(httpConfig); + return httpConfig; } + private BinaryExpression LanguageUserLinkFilterByIdFactory(ParameterExpression param, string id) { var split = id.Split('_'); diff --git a/JSONAPI.Autofac/JsonApiAutofacModule.cs b/JSONAPI.Autofac/JsonApiAutofacModule.cs index e937de35..d388dafc 100644 --- a/JSONAPI.Autofac/JsonApiAutofacModule.cs +++ b/JSONAPI.Autofac/JsonApiAutofacModule.cs @@ -126,7 +126,14 @@ protected override void Load(ContainerBuilder builder) }); builder.RegisterType().SingleInstance(); - builder.RegisterType().As().SingleInstance(); + if (_jsonApiConfiguration.CustomBaseUrlService != null) + { + builder.Register(c => _jsonApiConfiguration.CustomBaseUrlService).As().SingleInstance(); + } + else + { + builder.RegisterType().As().SingleInstance(); + } builder.RegisterType().As().InstancePerRequest(); // Serialization diff --git a/JSONAPI.Autofac/JsonApiHttpAutofacConfigurator.cs b/JSONAPI.Autofac/JsonApiHttpAutofacConfigurator.cs index 2fa79374..035f8c09 100644 --- a/JSONAPI.Autofac/JsonApiHttpAutofacConfigurator.cs +++ b/JSONAPI.Autofac/JsonApiHttpAutofacConfigurator.cs @@ -52,7 +52,7 @@ public void Apply(HttpConfiguration httpConfiguration, IJsonApiConfiguration jso _appLifetimeScopeBegunAction(applicationLifetimeScope); var jsonApiHttpConfiguration = applicationLifetimeScope.Resolve(); - jsonApiHttpConfiguration.Apply(httpConfiguration); + jsonApiHttpConfiguration.Apply(httpConfiguration, jsonApiConfiguration); httpConfiguration.DependencyResolver = new AutofacWebApiDependencyResolver(applicationLifetimeScope); } diff --git a/JSONAPI.Tests/Http/BaseUrlServiceTest.cs b/JSONAPI.Tests/Http/BaseUrlServiceTest.cs new file mode 100644 index 00000000..304ae60f --- /dev/null +++ b/JSONAPI.Tests/Http/BaseUrlServiceTest.cs @@ -0,0 +1,287 @@ +using System; +using System.Net.Http; +using FluentAssertions; +using JSONAPI.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace JSONAPI.Tests.Http +{ + [TestClass] + public class BaseUrlServiceTest + { + [TestMethod] + public void BaseUrlRootTest() + { + // Arrange + const string uri = "http://api.example.com/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(); + + // Act + var baseUrl =baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/"); + } + + [TestMethod] + public void BaseUrlOneLevelTest() + { + // Arrange + const string uri = "http://api.example.com/api/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService("api"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/"); + } + + [TestMethod] + public void BaseUrlOneLevelSlashTest() + { + // Arrange + const string uri = "http://api.example.com/api/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService("/api"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/"); + } + + [TestMethod] + public void BaseUrlOneLevelSlash2Test() + { + // Arrange + const string uri = "http://api.example.com/api/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService("api/"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/"); + } + + [TestMethod] + public void BaseUrlTwoLevelTest() + { + // Arrange + const string uri = "http://api.example.com/api/superapi/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService("api/superapi"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/superapi/"); + } + + [TestMethod] + public void BaseUrlTwoLevelSlashTest() + { + // Arrange + const string uri = "http://api.example.com/api/superapi/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService("api/superapi/"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/superapi/"); + } + + [TestMethod] + public void BaseUrlTwoLevelSlash2Test() + { + // Arrange + const string uri = "http://api.example.com/api/superapi/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService("/api/superapi/"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/superapi/"); + } + + [TestMethod] + public void BaseUrlConflictingNameTest() + { + // Arrange + const string uri = "http://api.example.com/api/superapi?sort=api-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService("api"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/"); + } + + + [TestMethod] + public void BaseUrlPublicOriginTest() + { + // Arrange + const string uri = "http://wwwhost123/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("http://api.example.com/"), ""); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/"); + } + + [TestMethod] + public void BaseUrlPublicOriginNoSlashTest() + { + // Arrange + const string uri = "http://wwwhost123/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("http://api.example.com"), ""); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/"); + } + + [TestMethod] + public void BaseUrlPublicOriginHttpsTest() + { + // Arrange + const string uri = "http://wwwhost123/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("https://api.example.com/"), ""); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("https://api.example.com/"); + } + + [TestMethod] + public void BaseUrlPublicOriginHttpsHighPortTest() + { + // Arrange + const string uri = "http://wwwhost123/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("https://api.example.com:12443/"), ""); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("https://api.example.com:12443/"); + } + + [TestMethod] + public void BaseUrlPublicOriginInternalPortTest() + { + // Arrange + const string uri = "http://wwwhost123:8080/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("http://api.example.com/"), ""); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/"); + } + + + + [TestMethod] + public void BaseUrlPublicOriginContextPathTest() + { + // Arrange + const string uri = "http://wwwhost123/api/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("http://api.example.com/"), "api"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/"); + } + + [TestMethod] + public void BaseUrlPublicOriginNoSlashContextPathTest() + { + // Arrange + const string uri = "http://wwwhost123/api/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("http://api.example.com"), "/api/"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/"); + } + + [TestMethod] + public void BaseUrlPublicOriginHttpsContextPathTest() + { + // Arrange + const string uri = "http://wwwhost123/api/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("https://api.example.com/"), "/api"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("https://api.example.com/api/"); + } + + [TestMethod] + public void BaseUrlPublicOriginHttpsHighPortContextPathTest() + { + // Arrange + const string uri = "http://wwwhost123/api/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("https://api.example.com:12443/"), "api"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("https://api.example.com:12443/api/"); + } + + [TestMethod] + public void BaseUrlPublicOriginInternalPortContextPathTest() + { + // Arrange + const string uri = "http://wwwhost123:8080/api/dummies?sort=first-name"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + var baseUrlService = new BaseUrlService(new Uri("http://api.example.com/"), "api"); + + // Act + var baseUrl = baseUrlService.GetBaseUrl(request); + + // Assert + baseUrl.Should().BeEquivalentTo("http://api.example.com/api/"); + } + + + } +} diff --git a/JSONAPI.Tests/JSONAPI.Tests.csproj b/JSONAPI.Tests/JSONAPI.Tests.csproj index 7b2fff70..4a86feae 100644 --- a/JSONAPI.Tests/JSONAPI.Tests.csproj +++ b/JSONAPI.Tests/JSONAPI.Tests.csproj @@ -90,6 +90,7 @@ + diff --git a/JSONAPI/Configuration/JsonApiConfiguration.cs b/JSONAPI/Configuration/JsonApiConfiguration.cs index 22722ba4..27752c1a 100644 --- a/JSONAPI/Configuration/JsonApiConfiguration.cs +++ b/JSONAPI/Configuration/JsonApiConfiguration.cs @@ -14,6 +14,7 @@ public class JsonApiConfiguration : IJsonApiConfiguration private readonly IResourceTypeRegistrar _resourceTypeRegistrar; public ILinkConventions LinkConventions { get; private set; } public IEnumerable ResourceTypeConfigurations { get { return _resourceTypeConfigurations; } } + public IBaseUrlService CustomBaseUrlService { get; set; } private readonly IList _resourceTypeConfigurations; @@ -104,5 +105,10 @@ public interface IJsonApiConfiguration /// by the ResourceTypeRegistrar /// IEnumerable ResourceTypeConfigurations { get; } + + /// + /// A custom configured + /// + IBaseUrlService CustomBaseUrlService { get; } } } diff --git a/JSONAPI/Configuration/JsonApiHttpConfiguration.cs b/JSONAPI/Configuration/JsonApiHttpConfiguration.cs index 32ce73b1..4451eda8 100644 --- a/JSONAPI/Configuration/JsonApiHttpConfiguration.cs +++ b/JSONAPI/Configuration/JsonApiHttpConfiguration.cs @@ -34,7 +34,8 @@ public JsonApiHttpConfiguration(JsonApiFormatter formatter, /// Applies the running configuration to an HttpConfiguration instance /// /// The HttpConfiguration to apply this JsonApiHttpConfiguration to - public void Apply(HttpConfiguration httpConfig) + /// configuration holding BaseUrlService wich could provide a context path. + public void Apply(HttpConfiguration httpConfig, IJsonApiConfiguration jsonApiConfiguration) { httpConfig.Formatters.Clear(); httpConfig.Formatters.Add(_formatter); @@ -42,10 +43,16 @@ public void Apply(HttpConfiguration httpConfig) httpConfig.Filters.Add(_fallbackDocumentBuilderAttribute); httpConfig.Filters.Add(_jsonApiExceptionFilterAttribute); + var contextPath = jsonApiConfiguration.CustomBaseUrlService?.GetContextPath(); + if (contextPath != null && !contextPath.Equals(string.Empty)) + { + contextPath += "/"; + } + // Web API routes - httpConfig.Routes.MapHttpRoute("ResourceCollection", "{resourceType}", new { controller = "Main" }); - httpConfig.Routes.MapHttpRoute("Resource", "{resourceType}/{id}", new { controller = "Main" }); - httpConfig.Routes.MapHttpRoute("RelatedResource", "{resourceType}/{id}/{relationshipName}", new { controller = "Main" }); + httpConfig.Routes.MapHttpRoute("ResourceCollection", contextPath + "{resourceType}", new { controller = "Main" }); + httpConfig.Routes.MapHttpRoute("Resource", contextPath + "{resourceType}/{id}", new { controller = "Main" }); + httpConfig.Routes.MapHttpRoute("RelatedResource", contextPath + "{resourceType}/{id}/{relationshipName}", new { controller = "Main" }); } } } diff --git a/JSONAPI/Http/BaseUrlService.cs b/JSONAPI/Http/BaseUrlService.cs index 174e83e5..df1ee1a1 100644 --- a/JSONAPI/Http/BaseUrlService.cs +++ b/JSONAPI/Http/BaseUrlService.cs @@ -8,11 +8,108 @@ namespace JSONAPI.Http /// public class BaseUrlService : IBaseUrlService { + private string _contextPath = string.Empty; + private Uri _publicOrigin; + + /// + /// Default constructor + /// + public BaseUrlService() { } + + /// + /// Constructor which provides a context path for the routes of JSONAPI.NET + /// + /// context path for the routes + public BaseUrlService(string contextPath) + { + CleanContextPath(contextPath); + } + + /// + /// Constructor which provides a public origin host and a context path for the routes of JSONAPI.NET. + /// If only public origin is desired provide emtpy string to contextPath. + /// + /// public hostname + /// context path for the routes + public BaseUrlService(Uri publicOrigin, string contextPath) + { + CleanContextPath(contextPath); + this._publicOrigin = publicOrigin; + } + + /// + /// Retrieve the base path to provide in responses. + /// + /// + /// public virtual string GetBaseUrl(HttpRequestMessage requestMessage) { - return - new Uri(requestMessage.RequestUri.AbsoluteUri.Replace(requestMessage.RequestUri.PathAndQuery, - String.Empty)).ToString(); + string pathAndQuery; + string absolutUri = requestMessage.RequestUri.AbsoluteUri; + if (_publicOrigin != null) + { + var publicUriBuilder = new UriBuilder(absolutUri) + { + Host = _publicOrigin.Host, + Scheme = _publicOrigin.Scheme, + Port = _publicOrigin.Port + }; + absolutUri = publicUriBuilder.Uri.AbsoluteUri; + pathAndQuery = publicUriBuilder.Uri.PathAndQuery; + } + else + { + pathAndQuery = requestMessage.RequestUri.PathAndQuery; + } + pathAndQuery = RemoveFromBegin(pathAndQuery, GetContextPath()); + pathAndQuery= pathAndQuery.TrimStart('/'); + var baseUrl = RemoveFromEnd(absolutUri, pathAndQuery); + return baseUrl; + } + + /// + /// Provides the context path to serve JSONAPI.NET without leading and trailing slash. + /// + /// + public string GetContextPath() + { + return _contextPath; + } + + /// + /// Makes sure thre are no slashes at the beginnig or end. + /// + /// + private void CleanContextPath(string contextPath) + { + if (!string.IsNullOrEmpty(contextPath) && !contextPath.EndsWith("/")) + { + contextPath = contextPath.TrimEnd('/'); + } + if (!string.IsNullOrEmpty(contextPath) && contextPath.StartsWith("/")) + { + contextPath = contextPath.TrimStart('/'); + } + _contextPath = contextPath; + } + + + private string RemoveFromEnd(string input, string suffix) + { + if (input.EndsWith(suffix)) + { + return input.Substring(0, input.Length - suffix.Length); + } + return input; + } + private string RemoveFromBegin(string input, string prefix) + { + prefix = "/" + prefix; + if (input.StartsWith(prefix)) + { + return input.Substring(prefix.Length, input.Length - prefix.Length); + } + return input; } } } \ No newline at end of file diff --git a/JSONAPI/Http/IBaseUrlService.cs b/JSONAPI/Http/IBaseUrlService.cs index ac5aa9f0..dd9f3177 100644 --- a/JSONAPI/Http/IBaseUrlService.cs +++ b/JSONAPI/Http/IBaseUrlService.cs @@ -11,5 +11,11 @@ public interface IBaseUrlService /// Gets the base URL for a request /// string GetBaseUrl(HttpRequestMessage requestMessage); + + /// + /// Gets the context path JSONAPI is served under without slashes at the beginning and end. + /// + /// + string GetContextPath(); } } diff --git a/README.md b/README.md index b75c7ca0..b930f0e6 100644 --- a/README.md +++ b/README.md @@ -64,3 +64,42 @@ A `JSONAPI.IMaterializer` object can be added to that `ApiController` to broker # Didn't I read something about using Entity Framework? The classes in the `JSONAPI.EntityFramework` namespace take great advantage of the patterns set out in the `JSONAPI` namespace. The `EntityFrameworkMaterializer` is an `IMaterializer` that can operate with your own `DbContext` class to retrieve objects by Id/Primary Key, and can retrieve and update existing objects from your context in a way that Entity Framework expects for change tracking…that means, in theory, you can use the provided `JSONAPI.EntityFramework.ApiController` base class to handle GET, PUT, POST, and DELETE without writing any additional code! You will still almost certainly subclass `ApiController` to implement your business logic, but that means you only have to worry about your business logic--not implementing the JSON API spec or messing with your persistence layer. + + + +# Configuration JSONAPI.EntityFramework + +- [ ] Add some hints about the configuration of JSONAPI.EntityFramework + +## Set the context path of JSONAPI.EntityFramework + +Per default the routes created for the registered models from EntityFramework will appear in root folder. This can conflict with folders of the file system or other routes you may want to serve from the same project. +To solve the issue we can create an instance of the `BaseUrlService` and put it in the configuration. +The `BaseUrlService` can be created with the context path parameter which will be used to register the routes and put into responses. + +```C# +var configuration = new JsonApiConfiguration(); +configuration.RegisterEntityFrameworkResourceType(); +// ... registration stuff you need + +// this makes JSONAPI.NET create the route 'api/posts' +configuration.CustomBaseUrlService = new BaseUrlService("api"); +``` + +## Set the public origin host of JSONAPI +Since JSONAPI.NET returns urls it could result wrong links if JSONAPI runs behind a reverse proxy. Configure the public origin address as follows: +```C# +var configuration = new JsonApiConfiguration(); +configuration.RegisterEntityFrameworkResourceType(); +// ... registration stuff you need + +// this makes JSONAPI.NET to set the urls in responses to https://api.example.com/posts +// Important: don't leave the second string parameter! see below. +configuration.CustomBaseUrlService = new BaseUrlService(new Uri("https://api.example.com/"), ""); + +// this can also be combined with the context paht for routing like that: +// this makes JSONAPI.NET create the route 'api/posts' and response urls to be https://api.example.com:9443/api/posts +configuration.CustomBaseUrlService = new BaseUrlService(new Uri("https://api.example.com:9443/"), "api"); + +``` + From 8dd97a9db251cf3d4afe509f4ed69b4ebeb934b5 Mon Sep 17 00:00:00 2001 From: Simon Hofer Date: Wed, 10 Aug 2016 13:47:32 +0200 Subject: [PATCH 13/22] Feat: added handler methods to intercept jsonapi entity framework (#116) * Feat: added handler methods to intercept jsonapi entity framework for custom entity manipulations * make the dbcontext accessible to subclasses for interception * @csantero implemented your suggestions * implemented as void method --- .../EntityFrameworkDocumentMaterializer.cs | 64 ++++++++++++++----- README.md | 36 +++++++++++ 2 files changed, 85 insertions(+), 15 deletions(-) diff --git a/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs b/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs index d7b3ce73..0b86166f 100644 --- a/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs +++ b/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs @@ -18,7 +18,7 @@ namespace JSONAPI.EntityFramework.Http /// public class EntityFrameworkDocumentMaterializer : IDocumentMaterializer where T : class { - private readonly DbContext _dbContext; + protected readonly DbContext DbContext; private readonly IResourceTypeRegistration _resourceTypeRegistration; private readonly IQueryableResourceCollectionDocumentBuilder _queryableResourceCollectionDocumentBuilder; private readonly ISingleResourceDocumentBuilder _singleResourceDocumentBuilder; @@ -38,7 +38,7 @@ public EntityFrameworkDocumentMaterializer( ISortExpressionExtractor sortExpressionExtractor, IBaseUrlService baseUrlService) { - _dbContext = dbContext; + DbContext = dbContext; _resourceTypeRegistration = resourceTypeRegistration; _queryableResourceCollectionDocumentBuilder = queryableResourceCollectionDocumentBuilder; _singleResourceDocumentBuilder = singleResourceDocumentBuilder; @@ -49,7 +49,7 @@ public EntityFrameworkDocumentMaterializer( public virtual Task GetRecords(HttpRequestMessage request, CancellationToken cancellationToken) { - var query = _dbContext.Set().AsQueryable(); + var query = DbContext.Set().AsQueryable(); var sortExpressions = _sortExpressionExtractor.ExtractSortExpressions(request); return _queryableResourceCollectionDocumentBuilder.BuildDocument(query, request, sortExpressions, cancellationToken); } @@ -68,29 +68,33 @@ public virtual async Task CreateRecord(ISingleResourceD HttpRequestMessage request, CancellationToken cancellationToken) { var apiBaseUrl = GetBaseUrlFromRequest(request); - var newRecord = await MaterializeAsync(requestDocument.PrimaryData, cancellationToken); - await _dbContext.SaveChangesAsync(cancellationToken); - var returnDocument = _singleResourceDocumentBuilder.BuildDocument(newRecord, apiBaseUrl, null, null); + var newRecord = MaterializeAsync(requestDocument.PrimaryData, cancellationToken); + await OnCreate(newRecord); + await DbContext.SaveChangesAsync(cancellationToken); + var returnDocument = _singleResourceDocumentBuilder.BuildDocument(await newRecord, apiBaseUrl, null, null); return returnDocument; } + public virtual async Task UpdateRecord(string id, ISingleResourceDocument requestDocument, HttpRequestMessage request, CancellationToken cancellationToken) { var apiBaseUrl = GetBaseUrlFromRequest(request); - var newRecord = await MaterializeAsync(requestDocument.PrimaryData, cancellationToken); - var returnDocument = _singleResourceDocumentBuilder.BuildDocument(newRecord, apiBaseUrl, null, null); - await _dbContext.SaveChangesAsync(cancellationToken); + var newRecord = MaterializeAsync(requestDocument.PrimaryData, cancellationToken); + await OnUpdate(newRecord); + var returnDocument = _singleResourceDocumentBuilder.BuildDocument(await newRecord, apiBaseUrl, null, null); + await DbContext.SaveChangesAsync(cancellationToken); return returnDocument; } public virtual async Task DeleteRecord(string id, HttpRequestMessage request, CancellationToken cancellationToken) { - var singleResource = await _dbContext.Set().FindAsync(cancellationToken, Convert.ChangeType(id, _resourceTypeRegistration.IdProperty.PropertyType)); - _dbContext.Set().Remove(singleResource); - await _dbContext.SaveChangesAsync(cancellationToken); + var singleResource = DbContext.Set().FindAsync(cancellationToken, Convert.ChangeType(id, _resourceTypeRegistration.IdProperty.PropertyType)); + await OnDelete(singleResource); + DbContext.Set().Remove(await singleResource); + await DbContext.SaveChangesAsync(cancellationToken); return null; } @@ -107,9 +111,9 @@ protected string GetBaseUrlFromRequest(HttpRequestMessage request) /// Convert a resource object into a material record managed by EntityFramework. /// /// - protected virtual async Task MaterializeAsync(IResourceObject resourceObject, CancellationToken cancellationToken) + protected virtual async Task MaterializeAsync(IResourceObject resourceObject, CancellationToken cancellationToken) { - return await _entityFrameworkResourceObjectMaterializer.MaterializeResourceObject(resourceObject, cancellationToken); + return (T) await _entityFrameworkResourceObjectMaterializer.MaterializeResourceObject(resourceObject, cancellationToken); } /// @@ -155,10 +159,40 @@ protected async Task GetRelatedToOne(string i return _singleResourceDocumentBuilder.BuildDocument(relatedResource, GetBaseUrlFromRequest(request), null, null); } + + /// + /// Manipulate entity before create. + /// + /// + /// + protected virtual async Task OnCreate(Task record) + { + await record; + } + + /// + /// Manipulate entity before update. + /// + /// + protected virtual async Task OnUpdate(Task record) + { + await record; + } + + /// + /// Manipulate entity before delete. + /// + /// + /// + protected virtual async Task OnDelete(Task record) + { + await record; + } + private IQueryable Filter(Expression> predicate, params Expression>[] includes) where TResource : class { - IQueryable query = _dbContext.Set(); + IQueryable query = DbContext.Set(); if (includes != null && includes.Any()) query = includes.Aggregate(query, (current, include) => current.Include(include)); diff --git a/README.md b/README.md index b930f0e6..129060b7 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,42 @@ The classes in the `JSONAPI.EntityFramework` namespace take great advantage of t - [ ] Add some hints about the configuration of JSONAPI.EntityFramework +## Manipulate entities before JSONAPI.EntityFramework persists them +To change your entities before they get persisted you can extend the `EntityFrameworkDocumentMaterializer` class. You need to register your custom DocumentMaterializer in your `JsonApiConfiguration` like that: +```C# +configuration.RegisterEntityFrameworkResourceType(c =>c.UseDocumentMaterializer()); +``` +Afterwards you can override the `OnCreate`, `OnUpdate` or `OnDelete` methods in your `CustomDocumentMaterializer`. + +```C# +protected override async Task OnCreate(Task record) +{ + await base.OnUpdate(record); + var entity = await record; + entity.CreatedOn = DateTime.Now; + entity.CreatedBy = Principal?.Identity; +} +``` + +> :information_source: HINT: To get the `Principal` you can add the following part into your `Startup.cs` which registers the `Principal` in Autofac and define a constructor Parameter on your `CustomDocumentMaterializer` of type `IPrincipal`. + +```C# + +configurator.OnApplicationLifetimeScopeCreating(builder => +{ +// ... +builder.Register(ctx => HttpContext.Current.GetOwinContext()).As(); +builder.Register((c, p) => + { + var owin = c.Resolve(); + return owin.Authentication.User; + }) + .As() + .InstancePerRequest(); +} +``` + + ## Set the context path of JSONAPI.EntityFramework Per default the routes created for the registered models from EntityFramework will appear in root folder. This can conflict with folders of the file system or other routes you may want to serve from the same project. From 90a399d30a7acd56ac29fe03787a538fa119a727 Mon Sep 17 00:00:00 2001 From: Simon Hofer Date: Wed, 17 Aug 2016 20:11:15 +0200 Subject: [PATCH 14/22] changed AcceptanceTestBase to print out proper formatted and valid json to make compare and copy/paste to test files easier (#119) --- .../AcceptanceTestsBase.cs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/AcceptanceTestsBase.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/AcceptanceTestsBase.cs index 133548dc..d0594337 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/AcceptanceTestsBase.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/AcceptanceTestsBase.cs @@ -1,8 +1,10 @@ using System; using System.Data.Common; +using System.Globalization; using System.IO; using System.Net; using System.Net.Http; +using System.Net.Http.Formatting; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -11,6 +13,9 @@ using JSONAPI.Json; using Microsoft.Owin.Testing; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; using Owin; namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests @@ -39,13 +44,28 @@ protected virtual async Task AssertResponseContent(HttpResponseMessage response, { var redactedResponse = GuidRegex.Replace(responseContent, "{{SOME_GUID}}"); actualResponse = StackTraceRegex.Replace(redactedResponse, "\"stackTrace\":\"{{STACK_TRACE}}\""); + actualResponse.Should().Be(expectedResponse); } else { actualResponse = responseContent; + JsonSerializerSettings settings = new JsonSerializerSettings + { + DateTimeZoneHandling = DateTimeZoneHandling.Utc, + DateFormatString = "yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'fffffff+00:00", + Culture = CultureInfo.InvariantCulture, + Formatting = Formatting.Indented + }; + + var actualResponseJObject = JsonConvert.DeserializeObject(actualResponse) as JObject; + var expectedResponseJObject = JsonConvert.DeserializeObject(expectedResponse) as JObject; + var equals = JToken.DeepEquals(actualResponseJObject, expectedResponseJObject); + if (!equals) + { + Assert.Fail("should be: " + JsonConvert.SerializeObject(expectedResponseJObject, settings) + "\n but was: " + JsonConvert.SerializeObject(actualResponseJObject, settings)); + } } - actualResponse.Should().Be(expectedResponse); response.Content.Headers.ContentType.MediaType.Should().Be(JsonApiContentType); response.Content.Headers.ContentType.CharSet.Should().Be("utf-8"); From 4635c82e3bf1dd685caa7d74a5f49df8a29abd54 Mon Sep 17 00:00:00 2001 From: Simon Hofer Date: Mon, 12 Sep 2016 19:25:23 +0200 Subject: [PATCH 15/22] Feat: implementation of includes (#118) * added basic IncludeExpressionExractor and introduced includes on EFDocumentMaterializer * added includes to FallbackDocumentBuilder * added tests for DefaultIncludeExpressionExtractor / fixed ugly typo * added includes in ToManyRelatedResourceDocument * removed obsolete method * includes on create and update * reverse deletion of GetDefaultSortExpressions * fixed typo * removed related to one and related to many methods from EntityFrameworkDocumentMaterializer because they are never used and replaced with EntityFrameworkToManyRelatedResourceDocumentMaterializer and EntityFrameworkToOneRelatedResourceDocumentMaterializer * a try for the GetIncludes method (renamed to GetNavigationPropertiesIncludes) * GetNavigationPropertiesIncludes added in EntityFrameworkToManyRelatedResourceDocumentMaterializer * bring back get default sort expression --- .../CreatingResourcesTests.cs | 29 ++++ .../FetchingResourcesTests.cs | 34 +++++ ..._empty_id_and_include_author_Response.json | 65 ++++++++ .../Get_included_to_one_response.json | 65 ++++++++ .../Get_related_to_many_include_response.json | 140 ++++++++++++++++++ ...ithAttributeUpdateWithIncludeResponse.json | 65 ++++++++ ...sts.EntityFrameworkTestWebApp.Tests.csproj | 4 + .../UpdatingResourcesTests.cs | 29 ++++ ...shipOfficersRelatedResourceMaterializer.cs | 3 +- JSONAPI.Autofac/JsonApiAutofacModule.cs | 1 + .../EntityFrameworkDocumentMaterializer.cs | 94 +++++------- ...ManyRelatedResourceDocumentMaterializer.cs | 34 ++++- .../Builders/FallbackDocumentBuilderTests.cs | 24 ++- .../DefaultIncludeExpressionExtractorTests.cs | 57 +++++++ JSONAPI.Tests/JSONAPI.Tests.csproj | 1 + .../Builders/FallbackDocumentBuilder.cs | 10 +- .../Http/DefaultIncludeExpressionExtractor.cs | 21 +++ JSONAPI/Http/IIncludeExpressionExtractor.cs | 17 +++ JSONAPI/Http/JsonApiController.cs | 2 +- ...ManyRelatedResourceDocumentMaterializer.cs | 24 +-- JSONAPI/JSONAPI.csproj | 2 + 21 files changed, 641 insertions(+), 80 deletions(-) create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/Post_with_empty_id_and_include_author_Response.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_included_to_one_response.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_include_response.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateWithIncludeResponse.json create mode 100644 JSONAPI.Tests/Http/DefaultIncludeExpressionExtractorTests.cs create mode 100644 JSONAPI/Http/DefaultIncludeExpressionExtractor.cs create mode 100644 JSONAPI/Http/IIncludeExpressionExtractor.cs diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/CreatingResourcesTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/CreatingResourcesTests.cs index e1b5a308..b189e933 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/CreatingResourcesTests.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/CreatingResourcesTests.cs @@ -112,5 +112,34 @@ public async Task Post_with_empty_id() } } } + + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task Post_with_empty_id_and_include() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitPost(effortConnection, "posts?include=author", @"Fixtures\CreatingResources\Requests\Post_with_empty_id_Request.json"); + + await AssertResponseContent(response, @"Fixtures\CreatingResources\Responses\Post_with_empty_id_and_include_author_Response.json", HttpStatusCode.OK); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.Posts.ToArray(); + allPosts.Length.Should().Be(5); + var actualPost = allPosts.First(t => t.Id == "230"); + actualPost.Id.Should().Be("230"); + actualPost.Title.Should().Be("New post"); + actualPost.Content.Should().Be("The server generated my ID"); + actualPost.Created.Should().Be(new DateTimeOffset(2015, 04, 13, 12, 09, 0, new TimeSpan(0, 3, 0, 0))); + actualPost.AuthorId.Should().Be("401"); + } + } + } } } diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs index 36321862..18e83042 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs @@ -99,6 +99,23 @@ public async Task Get_related_to_many() } } + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task Get_related_to_many_included() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "posts/201/comments?include=author"); + + await AssertResponseContent(response, @"Fixtures\FetchingResources\Get_related_to_many_include_response.json", HttpStatusCode.OK); + } + } + [TestMethod] [DeploymentItem(@"Data\Comment.csv", @"Data")] [DeploymentItem(@"Data\Post.csv", @"Data")] @@ -115,6 +132,23 @@ public async Task Get_related_to_one() } } + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task Get_included_to_one() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "posts/201?include=author"); + + await AssertResponseContent(response, @"Fixtures\FetchingResources\Get_included_to_one_response.json", HttpStatusCode.OK); + } + } + [TestMethod] [DeploymentItem(@"Data\Comment.csv", @"Data")] [DeploymentItem(@"Data\Post.csv", @"Data")] diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/Post_with_empty_id_and_include_author_Response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/Post_with_empty_id_and_include_author_Response.json new file mode 100644 index 00000000..f9996946 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/CreatingResources/Responses/Post_with_empty_id_and_include_author_Response.json @@ -0,0 +1,65 @@ +{ + "data": { + "type": "posts", + "id": "230", + "attributes": { + "content": "The server generated my ID", + "created": "2015-04-13T09:09:00.0000000+00:00", + "title": "New post" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/230/relationships/author", + "related": "https://www.example.com/posts/230/author" + }, + "data": { + "type": "users", + "id": "401" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/230/relationships/comments", + "related": "https://www.example.com/posts/230/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/230/relationships/tags", + "related": "https://www.example.com/posts/230/tags" + } + } + } + }, + "included": [ + { + "type": "users", + "id": "401", + "attributes": { + "first-name": "Alice", + "last-name": "Smith" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/401/relationships/comments", + "related": "https://www.example.com/users/401/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/401/relationships/posts", + "related": "https://www.example.com/users/401/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/401/relationships/user-groups", + "related": "https://www.example.com/users/401/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_included_to_one_response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_included_to_one_response.json new file mode 100644 index 00000000..98fcd282 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_included_to_one_response.json @@ -0,0 +1,65 @@ +{ + "data": { + "type": "posts", + "id": "201", + "attributes": { + "content": "Post 1 content", + "created": "2015-01-31T14:00:00.0000000+00:00", + "title": "Post 1" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/201/relationships/author", + "related": "https://www.example.com/posts/201/author" + }, + "data": { + "type": "users", + "id": "401" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/201/relationships/comments", + "related": "https://www.example.com/posts/201/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/201/relationships/tags", + "related": "https://www.example.com/posts/201/tags" + } + } + } + }, + "included": [ + { + "type": "users", + "id": "401", + "attributes": { + "first-name": "Alice", + "last-name": "Smith" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/401/relationships/comments", + "related": "https://www.example.com/users/401/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/401/relationships/posts", + "related": "https://www.example.com/users/401/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/401/relationships/user-groups", + "related": "https://www.example.com/users/401/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_include_response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_include_response.json new file mode 100644 index 00000000..e1a01091 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_include_response.json @@ -0,0 +1,140 @@ +{ + "data": [ + { + "type": "comments", + "id": "101", + "attributes": { + "created": "2015-01-31T14:30:00.0000000+00:00", + "text": "Comment 1" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/comments/101/relationships/author", + "related": "https://www.example.com/comments/101/author" + }, + "data": { + "type": "users", + "id": "403" + } + }, + "post": { + "links": { + "self": "https://www.example.com/comments/101/relationships/post", + "related": "https://www.example.com/comments/101/post" + } + } + } + }, + { + "type": "comments", + "id": "102", + "attributes": { + "created": "2015-01-31T14:35:00.0000000+00:00", + "text": "Comment 2" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/comments/102/relationships/author", + "related": "https://www.example.com/comments/102/author" + }, + "data": { + "type": "users", + "id": "402" + } + }, + "post": { + "links": { + "self": "https://www.example.com/comments/102/relationships/post", + "related": "https://www.example.com/comments/102/post" + } + } + } + }, + { + "type": "comments", + "id": "103", + "attributes": { + "created": "2015-01-31T14:41:00.0000000+00:00", + "text": "Comment 3" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/comments/103/relationships/author", + "related": "https://www.example.com/comments/103/author" + }, + "data": { + "type": "users", + "id": "403" + } + }, + "post": { + "links": { + "self": "https://www.example.com/comments/103/relationships/post", + "related": "https://www.example.com/comments/103/post" + } + } + } + } + ], + "included": [ + { + "type": "users", + "id": "403", + "attributes": { + "first-name": "Charlie", + "last-name": "Michaels" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/403/relationships/comments", + "related": "https://www.example.com/users/403/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/403/relationships/posts", + "related": "https://www.example.com/users/403/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/403/relationships/user-groups", + "related": "https://www.example.com/users/403/user-groups" + } + } + } + }, + { + "type": "users", + "id": "402", + "attributes": { + "first-name": "Bob", + "last-name": "Jones" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/402/relationships/comments", + "related": "https://www.example.com/users/402/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/402/relationships/posts", + "related": "https://www.example.com/users/402/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/402/relationships/user-groups", + "related": "https://www.example.com/users/402/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateWithIncludeResponse.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateWithIncludeResponse.json new file mode 100644 index 00000000..6bff40d9 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/UpdatingResources/Responses/PatchWithAttributeUpdateWithIncludeResponse.json @@ -0,0 +1,65 @@ +{ + "data": { + "type": "posts", + "id": "202", + "attributes": { + "content": "Post 2 content", + "created": "2015-02-05T08:10:00.0000000+00:00", + "title": "New post title" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/202/relationships/author", + "related": "https://www.example.com/posts/202/author" + }, + "data": { + "type": "users", + "id": "401" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/202/relationships/comments", + "related": "https://www.example.com/posts/202/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/202/relationships/tags", + "related": "https://www.example.com/posts/202/tags" + } + } + } + }, + "included": [ + { + "type": "users", + "id": "401", + "attributes": { + "first-name": "Alice", + "last-name": "Smith" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/401/relationships/comments", + "related": "https://www.example.com/users/401/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/401/relationships/posts", + "related": "https://www.example.com/users/401/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/401/relationships/user-groups", + "related": "https://www.example.com/users/401/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj index b335e6c5..b8546fbf 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj @@ -271,6 +271,10 @@ + + + + diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/UpdatingResourcesTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/UpdatingResourcesTests.cs index 48df90f9..e24ae49b 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/UpdatingResourcesTests.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/UpdatingResourcesTests.cs @@ -41,6 +41,35 @@ public async Task PatchWithAttributeUpdate() } } + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task PatchWithAttributeUpdateAndInclude() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitPatch(effortConnection, "posts/202?include=author", @"Fixtures\UpdatingResources\Requests\PatchWithAttributeUpdateRequest.json"); + + await AssertResponseContent(response, @"Fixtures\UpdatingResources\Responses\PatchWithAttributeUpdateWithIncludeResponse.json", HttpStatusCode.OK); + + using (var dbContext = new TestDbContext(effortConnection, false)) + { + var allPosts = dbContext.Posts.Include(p => p.Tags).ToArray(); + allPosts.Length.Should().Be(4); + var actualPost = allPosts.First(t => t.Id == "202"); + actualPost.Id.Should().Be("202"); + actualPost.Title.Should().Be("New post title"); + actualPost.Content.Should().Be("Post 2 content"); + actualPost.Created.Should().Be(new DateTimeOffset(2015, 02, 05, 08, 10, 0, new TimeSpan(0))); + actualPost.AuthorId.Should().Be("401"); + actualPost.Tags.Select(t => t.Id).Should().BeEquivalentTo("302", "303"); + } + } + } + [TestMethod] [DeploymentItem(@"Data\PostID.csv", @"Data")] public async Task PatchWithAttributeUpdateID() diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipOfficersRelatedResourceMaterializer.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipOfficersRelatedResourceMaterializer.cs index 5cb186b3..9fb646b3 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipOfficersRelatedResourceMaterializer.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/DocumentMaterializers/StarshipOfficersRelatedResourceMaterializer.cs @@ -17,8 +17,9 @@ public class StarshipOfficersRelatedResourceMaterializer : EntityFrameworkToMany public StarshipOfficersRelatedResourceMaterializer(ResourceTypeRelationship relationship, DbContext dbContext, IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor, IResourceTypeRegistration primaryTypeRegistration) - : base(relationship, dbContext, queryableResourceCollectionDocumentBuilder, sortExpressionExtractor, primaryTypeRegistration) + : base(relationship, dbContext, queryableResourceCollectionDocumentBuilder, sortExpressionExtractor, includeExpressionExtractor, primaryTypeRegistration) { _dbContext = dbContext; } diff --git a/JSONAPI.Autofac/JsonApiAutofacModule.cs b/JSONAPI.Autofac/JsonApiAutofacModule.cs index d388dafc..9c57ca4a 100644 --- a/JSONAPI.Autofac/JsonApiAutofacModule.cs +++ b/JSONAPI.Autofac/JsonApiAutofacModule.cs @@ -166,6 +166,7 @@ protected override void Load(ContainerBuilder builder) // Misc builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); } } } diff --git a/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs b/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs index 0b86166f..114f7877 100644 --- a/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs +++ b/JSONAPI.EntityFramework/Http/EntityFrameworkDocumentMaterializer.cs @@ -9,6 +9,7 @@ using JSONAPI.Core; using JSONAPI.Documents; using JSONAPI.Documents.Builders; +using JSONAPI.Extensions; using JSONAPI.Http; namespace JSONAPI.EntityFramework.Http @@ -24,6 +25,7 @@ public class EntityFrameworkDocumentMaterializer : IDocumentMaterializer wher private readonly ISingleResourceDocumentBuilder _singleResourceDocumentBuilder; private readonly IEntityFrameworkResourceObjectMaterializer _entityFrameworkResourceObjectMaterializer; private readonly ISortExpressionExtractor _sortExpressionExtractor; + private readonly IIncludeExpressionExtractor _includeExpressionExtractor; private readonly IBaseUrlService _baseUrlService; /// @@ -36,6 +38,7 @@ public EntityFrameworkDocumentMaterializer( ISingleResourceDocumentBuilder singleResourceDocumentBuilder, IEntityFrameworkResourceObjectMaterializer entityFrameworkResourceObjectMaterializer, ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor, IBaseUrlService baseUrlService) { DbContext = dbContext; @@ -44,24 +47,27 @@ public EntityFrameworkDocumentMaterializer( _singleResourceDocumentBuilder = singleResourceDocumentBuilder; _entityFrameworkResourceObjectMaterializer = entityFrameworkResourceObjectMaterializer; _sortExpressionExtractor = sortExpressionExtractor; + _includeExpressionExtractor = includeExpressionExtractor; _baseUrlService = baseUrlService; } public virtual Task GetRecords(HttpRequestMessage request, CancellationToken cancellationToken) { - var query = DbContext.Set().AsQueryable(); var sortExpressions = _sortExpressionExtractor.ExtractSortExpressions(request); - return _queryableResourceCollectionDocumentBuilder.BuildDocument(query, request, sortExpressions, cancellationToken); + var includes = _includeExpressionExtractor.ExtractIncludeExpressions(request); + var query = QueryIncludeNavigationProperties(null, GetNavigationPropertiesIncludes(includes)); + return _queryableResourceCollectionDocumentBuilder.BuildDocument(query, request, sortExpressions, cancellationToken, includes); } public virtual async Task GetRecordById(string id, HttpRequestMessage request, CancellationToken cancellationToken) { var apiBaseUrl = GetBaseUrlFromRequest(request); - var singleResource = await FilterById(id, _resourceTypeRegistration).FirstOrDefaultAsync(cancellationToken); + var includes = _includeExpressionExtractor.ExtractIncludeExpressions(request); + var singleResource = await FilterById(id, _resourceTypeRegistration, GetNavigationPropertiesIncludes(includes)).FirstOrDefaultAsync(cancellationToken); if (singleResource == null) throw JsonApiException.CreateForNotFound(string.Format("No resource of type `{0}` exists with id `{1}`.", _resourceTypeRegistration.ResourceTypeName, id)); - return _singleResourceDocumentBuilder.BuildDocument(singleResource, apiBaseUrl, null, null); + return _singleResourceDocumentBuilder.BuildDocument(singleResource, apiBaseUrl, includes, null); } public virtual async Task CreateRecord(ISingleResourceDocument requestDocument, @@ -71,7 +77,8 @@ public virtual async Task CreateRecord(ISingleResourceD var newRecord = MaterializeAsync(requestDocument.PrimaryData, cancellationToken); await OnCreate(newRecord); await DbContext.SaveChangesAsync(cancellationToken); - var returnDocument = _singleResourceDocumentBuilder.BuildDocument(await newRecord, apiBaseUrl, null, null); + var includes = _includeExpressionExtractor.ExtractIncludeExpressions(request); + var returnDocument = _singleResourceDocumentBuilder.BuildDocument(await newRecord, apiBaseUrl, includes, null); return returnDocument; } @@ -83,8 +90,9 @@ public virtual async Task UpdateRecord(string id, ISing var apiBaseUrl = GetBaseUrlFromRequest(request); var newRecord = MaterializeAsync(requestDocument.PrimaryData, cancellationToken); await OnUpdate(newRecord); - var returnDocument = _singleResourceDocumentBuilder.BuildDocument(await newRecord, apiBaseUrl, null, null); await DbContext.SaveChangesAsync(cancellationToken); + var includes = _includeExpressionExtractor.ExtractIncludeExpressions(request); + var returnDocument = _singleResourceDocumentBuilder.BuildDocument(await newRecord, apiBaseUrl, includes, null); return returnDocument; } @@ -116,50 +124,7 @@ protected virtual async Task MaterializeAsync(IResourceObject resourceObject, return (T) await _entityFrameworkResourceObjectMaterializer.MaterializeResourceObject(resourceObject, cancellationToken); } - /// - /// Generic method for getting the related resources for a to-many relationship - /// - protected async Task GetRelatedToMany(string id, - ResourceTypeRelationship relationship, HttpRequestMessage request, CancellationToken cancellationToken) - { - var param = Expression.Parameter(typeof(T)); - var accessorExpr = Expression.Property(param, relationship.Property); - var lambda = Expression.Lambda>>(accessorExpr, param); - - var primaryEntityQuery = FilterById(id, _resourceTypeRegistration); - - // We have to see if the resource even exists, so we can throw a 404 if it doesn't - var relatedResource = await primaryEntityQuery.FirstOrDefaultAsync(cancellationToken); - if (relatedResource == null) - throw JsonApiException.CreateForNotFound(string.Format("No resource of type `{0}` exists with id `{1}`.", - _resourceTypeRegistration.ResourceTypeName, id)); - - var relatedResourceQuery = primaryEntityQuery.SelectMany(lambda); - var sortExpressions = _sortExpressionExtractor.ExtractSortExpressions(request); - - return await _queryableResourceCollectionDocumentBuilder.BuildDocument(relatedResourceQuery, request, sortExpressions, cancellationToken); - } - - /// - /// Generic method for getting the related resources for a to-one relationship - /// - protected async Task GetRelatedToOne(string id, - ResourceTypeRelationship relationship, HttpRequestMessage request, CancellationToken cancellationToken) - { - var param = Expression.Parameter(typeof(T)); - var accessorExpr = Expression.Property(param, relationship.Property); - var lambda = Expression.Lambda>(accessorExpr, param); - - var primaryEntityQuery = FilterById(id, _resourceTypeRegistration); - var primaryEntityExists = await primaryEntityQuery.AnyAsync(cancellationToken); - if (!primaryEntityExists) - throw JsonApiException.CreateForNotFound(string.Format("No resource of type `{0}` exists with id `{1}`.", - _resourceTypeRegistration.ResourceTypeName, id)); - var relatedResource = await primaryEntityQuery.Select(lambda).FirstOrDefaultAsync(cancellationToken); - return _singleResourceDocumentBuilder.BuildDocument(relatedResource, GetBaseUrlFromRequest(request), null, null); - } - - + /// /// Manipulate entity before create. /// @@ -189,11 +154,34 @@ protected virtual async Task OnDelete(Task record) await record; } - private IQueryable Filter(Expression> predicate, + /// + /// This method allows to include into query. + /// This can reduce the number of queries (eager loading) + /// + /// + /// + /// + protected virtual Expression>[] GetNavigationPropertiesIncludes(string[] includes) + { + List>> list = new List>>(); + foreach (var include in includes) + { + var incl = include.Pascalize(); + var param = Expression.Parameter(typeof(TResource)); + var lambda = + Expression.Lambda>( + Expression.PropertyOrField(param, incl),param); + list.Add(lambda); + } + return list.ToArray(); + } + + + private IQueryable QueryIncludeNavigationProperties(Expression> predicate, params Expression>[] includes) where TResource : class { IQueryable query = DbContext.Set(); - if (includes != null && includes.Any()) + if (includes != null && includes.Any()) // eager loading query = includes.Aggregate(query, (current, include) => current.Include(include)); if (predicate != null) @@ -208,7 +196,7 @@ private IQueryable FilterById(string id, IResourceTypeRegi var param = Expression.Parameter(typeof(TResource)); var filterByIdExpression = resourceTypeRegistration.GetFilterByIdExpression(param, id); var predicate = Expression.Lambda>(filterByIdExpression, param); - return Filter(predicate, includes); + return QueryIncludeNavigationProperties(predicate, includes); } } } diff --git a/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs b/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs index 2a52338e..50004e16 100644 --- a/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs +++ b/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using JSONAPI.Core; using JSONAPI.Documents.Builders; +using JSONAPI.Extensions; using JSONAPI.Http; namespace JSONAPI.EntityFramework.Http @@ -29,8 +30,9 @@ public EntityFrameworkToManyRelatedResourceDocumentMaterializer( DbContext dbContext, IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor, IResourceTypeRegistration primaryTypeRegistration) - : base(queryableResourceCollectionDocumentBuilder, sortExpressionExtractor) + : base(queryableResourceCollectionDocumentBuilder, sortExpressionExtractor, includeExpressionExtractor) { _relationship = relationship; _dbContext = dbContext; @@ -43,8 +45,7 @@ protected override async Task> GetRelatedQuery(string prima var param = Expression.Parameter(typeof (TPrimaryResource)); var accessorExpr = Expression.Property(param, _relationship.Property); var lambda = Expression.Lambda>>(accessorExpr, param); - - var primaryEntityQuery = FilterById(primaryResourceId, _primaryTypeRegistration); + var primaryEntityQuery = FilterById(primaryResourceId, _primaryTypeRegistration, GetNavigationPropertiesIncludes(Includes)); // We have to see if the resource even exists, so we can throw a 404 if it doesn't var relatedResource = await primaryEntityQuery.FirstOrDefaultAsync(cancellationToken); @@ -56,6 +57,29 @@ protected override async Task> GetRelatedQuery(string prima return primaryEntityQuery.SelectMany(lambda); } + + /// + /// This method allows to include into query. + /// This can reduce the number of queries (eager loading) + /// + /// + /// + /// + protected virtual Expression>[] GetNavigationPropertiesIncludes(string[] includes) + { + List>> list = new List>>(); + foreach (var include in includes) + { + var incl = include.Pascalize(); + var param = Expression.Parameter(typeof(TResource)); + var lambda = + Expression.Lambda>( + Expression.PropertyOrField(param, incl), param); + list.Add(lambda); + } + return list.ToArray(); + } + private IQueryable FilterById(string id, IResourceTypeRegistration resourceTypeRegistration, params Expression>[] includes) where TResource : class @@ -63,10 +87,10 @@ private IQueryable FilterById(string id, var param = Expression.Parameter(typeof (TResource)); var filterByIdExpression = resourceTypeRegistration.GetFilterByIdExpression(param, id); var predicate = Expression.Lambda>(filterByIdExpression, param); - return Filter(predicate, includes); + return QueryIncludeNavigationProperties(predicate, includes); } - private IQueryable Filter(Expression> predicate, + private IQueryable QueryIncludeNavigationProperties(Expression> predicate, params Expression>[] includes) where TResource : class { IQueryable query = _dbContext.Set(); diff --git a/JSONAPI.Tests/Documents/Builders/FallbackDocumentBuilderTests.cs b/JSONAPI.Tests/Documents/Builders/FallbackDocumentBuilderTests.cs index 24943f4a..a6b9f14d 100644 --- a/JSONAPI.Tests/Documents/Builders/FallbackDocumentBuilderTests.cs +++ b/JSONAPI.Tests/Documents/Builders/FallbackDocumentBuilderTests.cs @@ -30,9 +30,10 @@ public async Task Creates_single_resource_document_for_registered_non_collection var objectContent = new Fruit { Id = "984", Name = "Kiwi" }; var mockDocument = new Mock(MockBehavior.Strict); + var includePathExpression = new string[] {}; var singleResourceDocumentBuilder = new Mock(MockBehavior.Strict); - singleResourceDocumentBuilder.Setup(b => b.BuildDocument(objectContent, It.IsAny(), null, null, null)).Returns(mockDocument.Object); + singleResourceDocumentBuilder.Setup(b => b.BuildDocument(objectContent, It.IsAny(), includePathExpression, null, null)).Returns(mockDocument.Object); var mockQueryableDocumentBuilder = new Mock(MockBehavior.Strict); var mockResourceCollectionDocumentBuilder = new Mock(MockBehavior.Strict); @@ -44,11 +45,14 @@ public async Task Creates_single_resource_document_for_registered_non_collection mockBaseUrlService.Setup(s => s.GetBaseUrl(request)).Returns("https://www.example.com"); var mockSortExpressionExtractor = new Mock(MockBehavior.Strict); - mockSortExpressionExtractor.Setup(e => e.ExtractSortExpressions(request)).Returns(new [] { "id "}); + mockSortExpressionExtractor.Setup(e => e.ExtractSortExpressions(request)).Returns(new[] { "id " }); + + var mockIncludeExpressionExtractor = new Mock(MockBehavior.Strict); + mockIncludeExpressionExtractor.Setup(e => e.ExtractIncludeExpressions(request)).Returns(includePathExpression); // Act var fallbackDocumentBuilder = new FallbackDocumentBuilder(singleResourceDocumentBuilder.Object, - mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockSortExpressionExtractor.Object, mockBaseUrlService.Object); + mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockSortExpressionExtractor.Object, mockIncludeExpressionExtractor.Object, mockBaseUrlService.Object); var resultDocument = await fallbackDocumentBuilder.BuildDocument(objectContent, request, cancellationTokenSource.Token); // Assert @@ -75,12 +79,14 @@ public async Task Creates_resource_collection_document_for_queryables() mockBaseUrlService.Setup(s => s.GetBaseUrl(request)).Returns("https://www.example.com/"); var sortExpressions = new[] { "id" }; + + var includeExpressions = new string[] { }; var cancellationTokenSource = new CancellationTokenSource(); var mockQueryableDocumentBuilder = new Mock(MockBehavior.Strict); mockQueryableDocumentBuilder - .Setup(b => b.BuildDocument(items, request, sortExpressions, cancellationTokenSource.Token, null)) + .Setup(b => b.BuildDocument(items, request, sortExpressions, cancellationTokenSource.Token, includeExpressions)) .Returns(Task.FromResult(mockDocument.Object)); var mockResourceCollectionDocumentBuilder = new Mock(MockBehavior.Strict); @@ -88,9 +94,12 @@ public async Task Creates_resource_collection_document_for_queryables() var mockSortExpressionExtractor = new Mock(MockBehavior.Strict); mockSortExpressionExtractor.Setup(e => e.ExtractSortExpressions(request)).Returns(sortExpressions); + var mockIncludeExpressionExtractor = new Mock(MockBehavior.Strict); + mockIncludeExpressionExtractor.Setup(e => e.ExtractIncludeExpressions(request)).Returns(includeExpressions); + // Act var fallbackDocumentBuilder = new FallbackDocumentBuilder(singleResourceDocumentBuilder.Object, - mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockSortExpressionExtractor.Object, mockBaseUrlService.Object); + mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockSortExpressionExtractor.Object, mockIncludeExpressionExtractor.Object, mockBaseUrlService.Object); var resultDocument = await fallbackDocumentBuilder.BuildDocument(items, request, cancellationTokenSource.Token); // Assert @@ -127,9 +136,12 @@ public async Task Creates_resource_collection_document_for_non_queryable_enumera var mockSortExpressionExtractor = new Mock(MockBehavior.Strict); mockSortExpressionExtractor.Setup(e => e.ExtractSortExpressions(request)).Returns(new[] { "id " }); + var mockIncludeExpressionExtractor = new Mock(MockBehavior.Strict); + mockIncludeExpressionExtractor.Setup(e => e.ExtractIncludeExpressions(request)).Returns(new string[] { }); + // Act var fallbackDocumentBuilder = new FallbackDocumentBuilder(singleResourceDocumentBuilder.Object, - mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockSortExpressionExtractor.Object, mockBaseUrlService.Object); + mockQueryableDocumentBuilder.Object, mockResourceCollectionDocumentBuilder.Object, mockSortExpressionExtractor.Object, mockIncludeExpressionExtractor.Object, mockBaseUrlService.Object); var resultDocument = await fallbackDocumentBuilder.BuildDocument(items, request, cancellationTokenSource.Token); // Assert diff --git a/JSONAPI.Tests/Http/DefaultIncludeExpressionExtractorTests.cs b/JSONAPI.Tests/Http/DefaultIncludeExpressionExtractorTests.cs new file mode 100644 index 00000000..0fb65380 --- /dev/null +++ b/JSONAPI.Tests/Http/DefaultIncludeExpressionExtractorTests.cs @@ -0,0 +1,57 @@ +using System.Net.Http; +using FluentAssertions; +using JSONAPI.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace JSONAPI.Tests.Http +{ + [TestClass] + public class DefaultIncludeExpressionExtractorTests + { + [TestMethod] + public void ExtractsSingleIncludeExpressionFromUri() + { + // Arrange + const string uri = "http://api.example.com/dummies?include=boss"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + + // Act + var extractor = new DefaultIncludeExpressionExtractor(); + var inclExpressions = extractor.ExtractIncludeExpressions(request); + + // Assert + inclExpressions.Should().BeEquivalentTo("boss"); + } + + + [TestMethod] + public void ExtractsMultipleIncludeExpressionsFromUri() + { + // Arrange + const string uri = "http://api.example.com/dummies?include=boss,office-address"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + + // Act + var extractor = new DefaultIncludeExpressionExtractor(); + var inclExpressions = extractor.ExtractIncludeExpressions(request); + + // Assert + inclExpressions.Should().BeEquivalentTo("boss", "office-address"); + } + + [TestMethod] + public void ExtractsNothingWhenThereIsNoIncludeParam() + { + // Arrange + const string uri = "http://api.example.com/dummies"; + var request = new HttpRequestMessage(HttpMethod.Get, uri); + + // Act + var extractor = new DefaultIncludeExpressionExtractor(); + var inclExpression = extractor.ExtractIncludeExpressions(request); + + // Assert + inclExpression.Length.Should().Be(0); + } + } +} diff --git a/JSONAPI.Tests/JSONAPI.Tests.csproj b/JSONAPI.Tests/JSONAPI.Tests.csproj index 4a86feae..7f487003 100644 --- a/JSONAPI.Tests/JSONAPI.Tests.csproj +++ b/JSONAPI.Tests/JSONAPI.Tests.csproj @@ -91,6 +91,7 @@ + diff --git a/JSONAPI/Documents/Builders/FallbackDocumentBuilder.cs b/JSONAPI/Documents/Builders/FallbackDocumentBuilder.cs index f6882571..8208f274 100644 --- a/JSONAPI/Documents/Builders/FallbackDocumentBuilder.cs +++ b/JSONAPI/Documents/Builders/FallbackDocumentBuilder.cs @@ -18,6 +18,7 @@ public class FallbackDocumentBuilder : IFallbackDocumentBuilder private readonly IQueryableResourceCollectionDocumentBuilder _queryableResourceCollectionDocumentBuilder; private readonly IResourceCollectionDocumentBuilder _resourceCollectionDocumentBuilder; private readonly ISortExpressionExtractor _sortExpressionExtractor; + private readonly IIncludeExpressionExtractor _includeExpressionExtractor; private readonly IBaseUrlService _baseUrlService; private readonly Lazy _openBuildDocumentFromQueryableMethod; private readonly Lazy _openBuildDocumentFromEnumerableMethod; @@ -29,12 +30,14 @@ public FallbackDocumentBuilder(ISingleResourceDocumentBuilder singleResourceDocu IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, IResourceCollectionDocumentBuilder resourceCollectionDocumentBuilder, ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor, IBaseUrlService baseUrlService) { _singleResourceDocumentBuilder = singleResourceDocumentBuilder; _queryableResourceCollectionDocumentBuilder = queryableResourceCollectionDocumentBuilder; _resourceCollectionDocumentBuilder = resourceCollectionDocumentBuilder; _sortExpressionExtractor = sortExpressionExtractor; + _includeExpressionExtractor = includeExpressionExtractor; _baseUrlService = baseUrlService; _openBuildDocumentFromQueryableMethod = @@ -53,6 +56,9 @@ public async Task BuildDocument(object obj, HttpRequestMessage { var type = obj.GetType(); + // TODO: test includes + var includeExpressions = _includeExpressionExtractor.ExtractIncludeExpressions(requestMessage); + var queryableInterfaces = type.GetInterfaces(); var queryableInterface = queryableInterfaces.FirstOrDefault( @@ -66,7 +72,7 @@ public async Task BuildDocument(object obj, HttpRequestMessage var sortExpressions = _sortExpressionExtractor.ExtractSortExpressions(requestMessage); dynamic materializedQueryTask = buildDocumentMethod.Invoke(_queryableResourceCollectionDocumentBuilder, - new[] { obj, requestMessage, sortExpressions, cancellationToken, null }); + new[] { obj, requestMessage, sortExpressions, cancellationToken, includeExpressions }); return await materializedQueryTask; } @@ -89,7 +95,7 @@ public async Task BuildDocument(object obj, HttpRequestMessage } // Single resource object - return _singleResourceDocumentBuilder.BuildDocument(obj, linkBaseUrl, null, null); + return _singleResourceDocumentBuilder.BuildDocument(obj, linkBaseUrl, includeExpressions, null); } private static Type GetEnumerableElementType(Type collectionType) diff --git a/JSONAPI/Http/DefaultIncludeExpressionExtractor.cs b/JSONAPI/Http/DefaultIncludeExpressionExtractor.cs new file mode 100644 index 00000000..cb040b19 --- /dev/null +++ b/JSONAPI/Http/DefaultIncludeExpressionExtractor.cs @@ -0,0 +1,21 @@ +using System.Linq; +using System.Net.Http; + +namespace JSONAPI.Http +{ + /// + /// Default implementation of + /// + public class DefaultIncludeExpressionExtractor: IIncludeExpressionExtractor + { + private const string IncludeQueryParamKey = "include"; + + public string[] ExtractIncludeExpressions(HttpRequestMessage requestMessage) + { + var queryParams = requestMessage.GetQueryNameValuePairs(); + var includeParam = queryParams.FirstOrDefault(kvp => kvp.Key == IncludeQueryParamKey); + if (includeParam.Key != IncludeQueryParamKey) return new string[] { }; + return includeParam.Value.Split(','); + } + } +} diff --git a/JSONAPI/Http/IIncludeExpressionExtractor.cs b/JSONAPI/Http/IIncludeExpressionExtractor.cs new file mode 100644 index 00000000..c8cf1358 --- /dev/null +++ b/JSONAPI/Http/IIncludeExpressionExtractor.cs @@ -0,0 +1,17 @@ +using System.Net.Http; + +namespace JSONAPI.Http +{ + /// + /// Service to extract include expressions from an HTTP request + /// + public interface IIncludeExpressionExtractor + { + /// + /// Extracts include expressions from the request + /// + /// + /// + string[] ExtractIncludeExpressions(HttpRequestMessage requestMessage); + } +} diff --git a/JSONAPI/Http/JsonApiController.cs b/JSONAPI/Http/JsonApiController.cs index 09deb1b1..8bc9bf22 100644 --- a/JSONAPI/Http/JsonApiController.cs +++ b/JSONAPI/Http/JsonApiController.cs @@ -63,7 +63,7 @@ public virtual async Task Post(string resourceType, [FromBody } /// - /// Updates the record with the given ID with data from the request payloaad. + /// Updates the record with the given ID with data from the request payload. /// public virtual async Task Patch(string resourceType, string id, [FromBody]ISingleResourceDocument requestDocument, CancellationToken cancellationToken) { diff --git a/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs b/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs index 7d190960..6aa2286f 100644 --- a/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs +++ b/JSONAPI/Http/QueryableToManyRelatedResourceDocumentMaterializer.cs @@ -15,27 +15,36 @@ public abstract class QueryableToManyRelatedResourceDocumentMaterializer + /// List of includes given by url. + /// + protected string[] Includes = {}; /// /// Creates a new QueryableRelatedResourceDocumentMaterializer /// protected QueryableToManyRelatedResourceDocumentMaterializer( IQueryableResourceCollectionDocumentBuilder queryableResourceCollectionDocumentBuilder, - ISortExpressionExtractor sortExpressionExtractor) + ISortExpressionExtractor sortExpressionExtractor, + IIncludeExpressionExtractor includeExpressionExtractor) { _queryableResourceCollectionDocumentBuilder = queryableResourceCollectionDocumentBuilder; _sortExpressionExtractor = sortExpressionExtractor; + _includeExpressionExtractor = includeExpressionExtractor; } public async Task GetRelatedResourceDocument(string primaryResourceId, HttpRequestMessage request, CancellationToken cancellationToken) { + Includes = _includeExpressionExtractor.ExtractIncludeExpressions(request); var query = await GetRelatedQuery(primaryResourceId, cancellationToken); - var includes = GetIncludePaths(); var sortExpressions = _sortExpressionExtractor.ExtractSortExpressions(request); if (sortExpressions == null || sortExpressions.Length < 1) sortExpressions = GetDefaultSortExpressions(); - return await _queryableResourceCollectionDocumentBuilder.BuildDocument(query, request, sortExpressions, cancellationToken, includes); // TODO: allow implementors to specify metadata + + + return await _queryableResourceCollectionDocumentBuilder.BuildDocument(query, request, sortExpressions, cancellationToken, Includes); // TODO: allow implementors to specify metadata } /// @@ -43,15 +52,6 @@ public async Task GetRelatedResourceDocument(string primaryRes /// protected abstract Task> GetRelatedQuery(string primaryResourceId, CancellationToken cancellationToken); - /// - /// Gets a list of relationship paths to include - /// - /// - protected virtual string[] GetIncludePaths() - { - return null; - } - /// /// If the client doesn't request any sort expressions, these expressions will be used for sorting instead. /// diff --git a/JSONAPI/JSONAPI.csproj b/JSONAPI/JSONAPI.csproj index d6ed5c23..4336261f 100644 --- a/JSONAPI/JSONAPI.csproj +++ b/JSONAPI/JSONAPI.csproj @@ -90,10 +90,12 @@ + + From 4374719e79c24778f2af2f3229fdb541b1106caf Mon Sep 17 00:00:00 2001 From: Simon Hofer Date: Thu, 15 Sep 2016 20:09:25 +0200 Subject: [PATCH 16/22] Feat: added metadata on pagination responses (#115) * Feat: added pagination with metadata - metadata to indicate the count of pages and total count of records - acceptance tests pagination in combination with filters and sorting * moved total-pages and total-count to Entity Framework specific subclass. reduced the number of count calls to save roundtrips * awaits corrected --- .../Pagination/GetAllResponsePaged-2-2.json | 66 ++++++++++++++++ .../GetFilterPaged-1-2-sorted-desc.json | 64 +++++++++++++++ .../Pagination/GetFilterPaged-1-2-sorted.json | 64 +++++++++++++++ .../Pagination/GetFilterPaged-2-1.json | 36 +++++++++ ...sts.EntityFrameworkTestWebApp.Tests.csproj | 6 ++ .../PaginationTests.cs | 77 +++++++++++++++++++ .../Startup.cs | 4 + ...ryableResourceCollectionDocumentBuilder.cs | 55 +++++++++++++ .../JSONAPI.EntityFramework.csproj | 1 + ...tionDocument_for_all_possible_members.json | 20 ++--- ...nt_for_primary_data_only_and_metadata.json | 10 +-- ...ryableResourceCollectionDocumentBuilder.cs | 2 +- JSONAPI/Documents/Metadata.cs | 16 ++++ JSONAPI/JSONAPI.csproj | 1 + .../ResourceCollectionDocumentFormatter.cs | 12 +-- README.md | 10 +++ 16 files changed, 423 insertions(+), 21 deletions(-) create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetAllResponsePaged-2-2.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-1-2-sorted-desc.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-1-2-sorted.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-2-1.json create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/PaginationTests.cs create mode 100644 JSONAPI.EntityFramework/Documents/Builders/EntityFrameworkQueryableResourceCollectionDocumentBuilder.cs create mode 100644 JSONAPI/Documents/Metadata.cs diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetAllResponsePaged-2-2.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetAllResponsePaged-2-2.json new file mode 100644 index 00000000..7bf2f5ba --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetAllResponsePaged-2-2.json @@ -0,0 +1,66 @@ +{ + "meta": { + "total-pages": 2, + "total-count": 4 + }, + "data": [ + { + "type": "posts", + "id": "203", + "attributes": { + "content": "Post 3 content", + "created": "2015-02-07T11:11:00.0000000+00:00", + "title": "Post 3" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/203/relationships/author", + "related": "https://www.example.com/posts/203/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/203/relationships/comments", + "related": "https://www.example.com/posts/203/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/203/relationships/tags", + "related": "https://www.example.com/posts/203/tags" + } + } + } + }, + { + "type": "posts", + "id": "204", + "attributes": { + "content": "Post 4 content", + "created": "2015-02-08T06:59:00.0000000+00:00", + "title": "Post 4" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/204/relationships/author", + "related": "https://www.example.com/posts/204/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/204/relationships/comments", + "related": "https://www.example.com/posts/204/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/204/relationships/tags", + "related": "https://www.example.com/posts/204/tags" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-1-2-sorted-desc.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-1-2-sorted-desc.json new file mode 100644 index 00000000..b65cea48 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-1-2-sorted-desc.json @@ -0,0 +1,64 @@ +{ + "meta": { + "total-pages": 2, + "total-count": 3 + }, + "data": [ + { + "type": "users", + "id": "410", + "attributes": { + "first-name": "Sally", + "last-name": "Burns" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/410/relationships/comments", + "related": "https://www.example.com/users/410/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/410/relationships/posts", + "related": "https://www.example.com/users/410/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/410/relationships/user-groups", + "related": "https://www.example.com/users/410/user-groups" + } + } + } + }, + { + "type": "users", + "id": "406", + "attributes": { + "first-name": "Ed", + "last-name": "Burns" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/406/relationships/comments", + "related": "https://www.example.com/users/406/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/406/relationships/posts", + "related": "https://www.example.com/users/406/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/406/relationships/user-groups", + "related": "https://www.example.com/users/406/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-1-2-sorted.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-1-2-sorted.json new file mode 100644 index 00000000..ee562655 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-1-2-sorted.json @@ -0,0 +1,64 @@ +{ + "meta": { + "total-pages": 2, + "total-count": 3 + }, + "data": [ + { + "type": "users", + "id": "409", + "attributes": { + "first-name": "Charlie", + "last-name": "Burns" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/409/relationships/comments", + "related": "https://www.example.com/users/409/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/409/relationships/posts", + "related": "https://www.example.com/users/409/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/409/relationships/user-groups", + "related": "https://www.example.com/users/409/user-groups" + } + } + } + }, + { + "type": "users", + "id": "406", + "attributes": { + "first-name": "Ed", + "last-name": "Burns" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/406/relationships/comments", + "related": "https://www.example.com/users/406/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/406/relationships/posts", + "related": "https://www.example.com/users/406/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/406/relationships/user-groups", + "related": "https://www.example.com/users/406/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-2-1.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-2-1.json new file mode 100644 index 00000000..16e92908 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/Pagination/GetFilterPaged-2-1.json @@ -0,0 +1,36 @@ +{ + "meta": { + "total-pages": 3, + "total-count": 3 + }, + "data": [ + { + "type": "users", + "id": "409", + "attributes": { + "first-name": "Charlie", + "last-name": "Burns" + }, + "relationships": { + "comments": { + "links": { + "self": "https://www.example.com/users/409/relationships/comments", + "related": "https://www.example.com/users/409/comments" + } + }, + "posts": { + "links": { + "self": "https://www.example.com/users/409/relationships/posts", + "related": "https://www.example.com/users/409/posts" + } + }, + "user-groups": { + "links": { + "self": "https://www.example.com/users/409/relationships/user-groups", + "related": "https://www.example.com/users/409/user-groups" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj index b8546fbf..e9cb8a2c 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj @@ -106,6 +106,7 @@ + @@ -275,8 +276,13 @@ + + + + + diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/PaginationTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/PaginationTests.cs new file mode 100644 index 00000000..0db20f57 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/PaginationTests.cs @@ -0,0 +1,77 @@ +using System.Net; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests +{ + [TestClass] + public class PaginationTests : AcceptanceTestsBase + { + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task GetPage2Post2() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "posts?page[number]=1&page[size]=2"); + + await AssertResponseContent(response, @"Fixtures\Pagination\GetAllResponsePaged-2-2.json", HttpStatusCode.OK); + } + } + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task GetWithFilter() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "users?filter[last-name]=Burns&page[number]=1&page[size]=1"); + + await AssertResponseContent(response, @"Fixtures\Pagination\GetFilterPaged-2-1.json", HttpStatusCode.OK); + } + } + + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task GetWithFilterSortedAscTest() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "users?filter[last-name]=Burns&page[number]=0&page[size]=2&sort=first-name"); + + await AssertResponseContent(response, @"Fixtures\Pagination\GetFilterPaged-1-2-sorted.json", HttpStatusCode.OK); + } + } + + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task GetWithFilterSortedDescTest() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "users?filter[last-name]=Burns&page[number]=0&page[size]=2&sort=-first-name"); + + await AssertResponseContent(response, @"Fixtures\Pagination\GetFilterPaged-1-2-sorted-desc.json", HttpStatusCode.OK); + } + } + + } +} diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs index aec594d2..678f8532 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Startup.cs @@ -14,6 +14,8 @@ using JSONAPI.EntityFramework.Configuration; using Owin; using System.Collections.Generic; +using JSONAPI.Documents.Builders; +using JSONAPI.EntityFramework.Documents.Builders; namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp { @@ -112,6 +114,8 @@ internal JsonApiHttpAutofacConfigurator BuildAutofacConfigurator(IAppBuilder app builder.RegisterType() .As(); builder.RegisterApiControllers(Assembly.GetExecutingAssembly()); + builder.RegisterType().As(); + }); configurator.OnApplicationLifetimeScopeBegun(applicationLifetimeScope => { diff --git a/JSONAPI.EntityFramework/Documents/Builders/EntityFrameworkQueryableResourceCollectionDocumentBuilder.cs b/JSONAPI.EntityFramework/Documents/Builders/EntityFrameworkQueryableResourceCollectionDocumentBuilder.cs new file mode 100644 index 00000000..1bd9e2c8 --- /dev/null +++ b/JSONAPI.EntityFramework/Documents/Builders/EntityFrameworkQueryableResourceCollectionDocumentBuilder.cs @@ -0,0 +1,55 @@ +using System; +using System.Data.Entity; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JSONAPI.Documents; +using JSONAPI.Documents.Builders; +using JSONAPI.Http; +using JSONAPI.QueryableTransformers; + +namespace JSONAPI.EntityFramework.Documents.Builders +{ + /// + /// Provides a entity framework implementation of an IQueryableResourceCollectionDocumentBuilder + /// + public class EntityFrameworkQueryableResourceCollectionDocumentBuilder: DefaultQueryableResourceCollectionDocumentBuilder + { + /// + /// Creates a new EntityFrameworkQueryableResourceCollectionDocumentBuilder + /// + public EntityFrameworkQueryableResourceCollectionDocumentBuilder( + IResourceCollectionDocumentBuilder resourceCollectionDocumentBuilder, + IQueryableEnumerationTransformer enumerationTransformer, + IQueryableFilteringTransformer filteringTransformer, + IQueryableSortingTransformer sortingTransformer, + IQueryablePaginationTransformer paginationTransformer, + IBaseUrlService baseUrlService) : + base(resourceCollectionDocumentBuilder, + enumerationTransformer, + filteringTransformer, + sortingTransformer, + paginationTransformer, + baseUrlService) + { + } + + /// + /// Returns the metadata that should be sent with this document. + /// + protected override async Task GetDocumentMetadata(IQueryable originalQuery, IQueryable filteredQuery, IOrderedQueryable sortedQuery, + IPaginationTransformResult paginationResult, CancellationToken cancellationToken) + { + var metadata = new Metadata(); + if (paginationResult.PaginationWasApplied) + { + var count = await filteredQuery.CountAsync(cancellationToken); + metadata.MetaObject.Add("total-pages", (int)Math.Ceiling((decimal) count / paginationResult.PageSize)); + metadata.MetaObject.Add("total-count", count); + } + if (metadata.MetaObject.HasValues) + return metadata; + return null; + } + } +} diff --git a/JSONAPI.EntityFramework/JSONAPI.EntityFramework.csproj b/JSONAPI.EntityFramework/JSONAPI.EntityFramework.csproj index 0f1dd5c8..2045b33d 100644 --- a/JSONAPI.EntityFramework/JSONAPI.EntityFramework.csproj +++ b/JSONAPI.EntityFramework/JSONAPI.EntityFramework.csproj @@ -72,6 +72,7 @@ + diff --git a/JSONAPI.Tests/Json/Fixtures/ResourceCollectionDocumentFormatter/Serialize_ResourceCollectionDocument_for_all_possible_members.json b/JSONAPI.Tests/Json/Fixtures/ResourceCollectionDocumentFormatter/Serialize_ResourceCollectionDocument_for_all_possible_members.json index 9eb2c810..fad9ad33 100644 --- a/JSONAPI.Tests/Json/Fixtures/ResourceCollectionDocumentFormatter/Serialize_ResourceCollectionDocument_for_all_possible_members.json +++ b/JSONAPI.Tests/Json/Fixtures/ResourceCollectionDocumentFormatter/Serialize_ResourceCollectionDocument_for_all_possible_members.json @@ -1,12 +1,12 @@ { - "data": [ - "Primary data 1", - "Primary data 2" - ], - "included": [ - "Related data object 1", - "Related data object 2", - "Related data object 3" - ], - "meta": "Placeholder metadata object" + "meta": "Placeholder metadata object", + "data": [ + "Primary data 1", + "Primary data 2" + ], + "included": [ + "Related data object 1", + "Related data object 2", + "Related data object 3" + ] } \ No newline at end of file diff --git a/JSONAPI.Tests/Json/Fixtures/ResourceCollectionDocumentFormatter/Serialize_ResourceCollectionDocument_for_primary_data_only_and_metadata.json b/JSONAPI.Tests/Json/Fixtures/ResourceCollectionDocumentFormatter/Serialize_ResourceCollectionDocument_for_primary_data_only_and_metadata.json index ef98daf7..6d770373 100644 --- a/JSONAPI.Tests/Json/Fixtures/ResourceCollectionDocumentFormatter/Serialize_ResourceCollectionDocument_for_primary_data_only_and_metadata.json +++ b/JSONAPI.Tests/Json/Fixtures/ResourceCollectionDocumentFormatter/Serialize_ResourceCollectionDocument_for_primary_data_only_and_metadata.json @@ -1,7 +1,7 @@ { - "data": [ - "Primary data 1", - "Primary data 2" - ], - "meta": "Placeholder metadata object" + "meta": "Placeholder metadata object", + "data": [ + "Primary data 1", + "Primary data 2" + ] } \ No newline at end of file diff --git a/JSONAPI/Documents/Builders/DefaultQueryableResourceCollectionDocumentBuilder.cs b/JSONAPI/Documents/Builders/DefaultQueryableResourceCollectionDocumentBuilder.cs index d47e93c6..d0488b1c 100644 --- a/JSONAPI/Documents/Builders/DefaultQueryableResourceCollectionDocumentBuilder.cs +++ b/JSONAPI/Documents/Builders/DefaultQueryableResourceCollectionDocumentBuilder.cs @@ -60,7 +60,7 @@ public async Task BuildDocument(IQueryable qu protected virtual Task GetDocumentMetadata(IQueryable originalQuery, IQueryable filteredQuery, IOrderedQueryable sortedQuery, IPaginationTransformResult paginationResult, CancellationToken cancellationToken) { - return Task.FromResult((IMetadata)null); + return Task.FromResult((IMetadata) null); } } } diff --git a/JSONAPI/Documents/Metadata.cs b/JSONAPI/Documents/Metadata.cs new file mode 100644 index 00000000..004de8c7 --- /dev/null +++ b/JSONAPI/Documents/Metadata.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json.Linq; + +namespace JSONAPI.Documents +{ + /// + /// Default implementation of + /// + public class Metadata : IMetadata + { + public Metadata() + { + MetaObject = new JObject(); + } + public JObject MetaObject { get; } + } +} diff --git a/JSONAPI/JSONAPI.csproj b/JSONAPI/JSONAPI.csproj index 4336261f..9bd3d2e2 100644 --- a/JSONAPI/JSONAPI.csproj +++ b/JSONAPI/JSONAPI.csproj @@ -91,6 +91,7 @@ + diff --git a/JSONAPI/Json/ResourceCollectionDocumentFormatter.cs b/JSONAPI/Json/ResourceCollectionDocumentFormatter.cs index 79cd75d8..b7a0c999 100644 --- a/JSONAPI/Json/ResourceCollectionDocumentFormatter.cs +++ b/JSONAPI/Json/ResourceCollectionDocumentFormatter.cs @@ -75,6 +75,12 @@ public Task Serialize(IResourceCollectionDocument document, JsonWriter writer) { writer.WriteStartObject(); + if (document.Metadata != null) + { + writer.WritePropertyName(MetaKeyName); + MetadataFormatter.Serialize(document.Metadata, writer); + } + writer.WritePropertyName(PrimaryDataKeyName); writer.WriteStartArray(); @@ -95,11 +101,7 @@ public Task Serialize(IResourceCollectionDocument document, JsonWriter writer) writer.WriteEndArray(); } - if (document.Metadata != null) - { - writer.WritePropertyName(MetaKeyName); - MetadataFormatter.Serialize(document.Metadata, writer); - } + writer.WriteEndObject(); diff --git a/README.md b/README.md index 129060b7..1fb411ca 100644 --- a/README.md +++ b/README.md @@ -139,3 +139,13 @@ configuration.CustomBaseUrlService = new BaseUrlService(new Uri("https://api.exa ``` +# Metadata + + + +## Pagination + +### total-pages / total-count +When using Entity Framework you can register type `EntityFrameworkQueryableResourceCollectionDocumentBuilder` to enable the `total-pages` and `total-count` meta properties. +When pagination is used the `total-pages` and `total-count` meta properties are provided to indicate the number of pages and records to the client. + From dfef2787acf856d3be7fc534240d9ec63ce4f378 Mon Sep 17 00:00:00 2001 From: Simon Hofer Date: Thu, 15 Sep 2016 22:23:01 +0200 Subject: [PATCH 17/22] bugfix: includes (eager-loading) must be applied to related not to primary resource! (#122) --- .../FetchingResourcesTests.cs | 17 ++ ...ted_to_many_include_external_response.json | 164 ++++++++++++++++++ ...sts.EntityFrameworkTestWebApp.Tests.csproj | 1 + ...ManyRelatedResourceDocumentMaterializer.cs | 36 ++-- 4 files changed, 196 insertions(+), 22 deletions(-) create mode 100644 JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_include_external_response.json diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs index 18e83042..39eb2938 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/FetchingResourcesTests.cs @@ -116,6 +116,23 @@ public async Task Get_related_to_many_included() } } + + [TestMethod] + [DeploymentItem(@"Data\Comment.csv", @"Data")] + [DeploymentItem(@"Data\Post.csv", @"Data")] + [DeploymentItem(@"Data\PostTagLink.csv", @"Data")] + [DeploymentItem(@"Data\Tag.csv", @"Data")] + [DeploymentItem(@"Data\User.csv", @"Data")] + public async Task Get_related_to_many_included_external() + { + using (var effortConnection = GetEffortConnection()) + { + var response = await SubmitGet(effortConnection, "users/401/posts?include=tags"); + + await AssertResponseContent(response, @"Fixtures\FetchingResources\Get_related_to_many_include_external_response.json", HttpStatusCode.OK); + } + } + [TestMethod] [DeploymentItem(@"Data\Comment.csv", @"Data")] [DeploymentItem(@"Data\Post.csv", @"Data")] diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_include_external_response.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_include_external_response.json new file mode 100644 index 00000000..e8008645 --- /dev/null +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/FetchingResources/Get_related_to_many_include_external_response.json @@ -0,0 +1,164 @@ +{ + "data": [ + { + "type": "posts", + "id": "201", + "attributes": { + "content": "Post 1 content", + "created": "2015-01-31T14:00:00.0000000+00:00", + "title": "Post 1" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/201/relationships/author", + "related": "https://www.example.com/posts/201/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/201/relationships/comments", + "related": "https://www.example.com/posts/201/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/201/relationships/tags", + "related": "https://www.example.com/posts/201/tags" + }, + "data": [ + { + "type": "tags", + "id": "301" + }, + { + "type": "tags", + "id": "302" + } + ] + } + } + }, + { + "type": "posts", + "id": "202", + "attributes": { + "content": "Post 2 content", + "created": "2015-02-05T08:10:00.0000000+00:00", + "title": "Post 2" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/202/relationships/author", + "related": "https://www.example.com/posts/202/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/202/relationships/comments", + "related": "https://www.example.com/posts/202/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/202/relationships/tags", + "related": "https://www.example.com/posts/202/tags" + }, + "data": [ + { + "type": "tags", + "id": "302" + }, + { + "type": "tags", + "id": "303" + } + ] + } + } + }, + { + "type": "posts", + "id": "203", + "attributes": { + "content": "Post 3 content", + "created": "2015-02-07T11:11:00.0000000+00:00", + "title": "Post 3" + }, + "relationships": { + "author": { + "links": { + "self": "https://www.example.com/posts/203/relationships/author", + "related": "https://www.example.com/posts/203/author" + } + }, + "comments": { + "links": { + "self": "https://www.example.com/posts/203/relationships/comments", + "related": "https://www.example.com/posts/203/comments" + } + }, + "tags": { + "links": { + "self": "https://www.example.com/posts/203/relationships/tags", + "related": "https://www.example.com/posts/203/tags" + }, + "data": [ + { + "type": "tags", + "id": "303" + } + ] + } + } + } + ], + "included": [ + { + "type": "tags", + "id": "301", + "attributes": { + "name": "Tag A" + }, + "relationships": { + "posts": { + "links": { + "self": "https://www.example.com/tags/301/relationships/posts", + "related": "https://www.example.com/tags/301/posts" + } + } + } + }, + { + "type": "tags", + "id": "302", + "attributes": { + "name": "Tag B" + }, + "relationships": { + "posts": { + "links": { + "self": "https://www.example.com/tags/302/relationships/posts", + "related": "https://www.example.com/tags/302/posts" + } + } + } + }, + { + "type": "tags", + "id": "303", + "attributes": { + "name": "Tag C" + }, + "relationships": { + "posts": { + "links": { + "self": "https://www.example.com/tags/303/relationships/posts", + "related": "https://www.example.com/tags/303/posts" + } + } + } + } + ] +} \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj index e9cb8a2c..35d82430 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests.csproj @@ -280,6 +280,7 @@ + diff --git a/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs b/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs index 50004e16..142075a0 100644 --- a/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs +++ b/JSONAPI.EntityFramework/Http/EntityFrameworkToManyRelatedResourceDocumentMaterializer.cs @@ -45,7 +45,8 @@ protected override async Task> GetRelatedQuery(string prima var param = Expression.Parameter(typeof (TPrimaryResource)); var accessorExpr = Expression.Property(param, _relationship.Property); var lambda = Expression.Lambda>>(accessorExpr, param); - var primaryEntityQuery = FilterById(primaryResourceId, _primaryTypeRegistration, GetNavigationPropertiesIncludes(Includes)); + + var primaryEntityQuery = FilterById(primaryResourceId, _primaryTypeRegistration); // We have to see if the resource even exists, so we can throw a 404 if it doesn't var relatedResource = await primaryEntityQuery.FirstOrDefaultAsync(cancellationToken); @@ -53,8 +54,12 @@ protected override async Task> GetRelatedQuery(string prima throw JsonApiException.CreateForNotFound(string.Format( "No resource of type `{0}` exists with id `{1}`.", _primaryTypeRegistration.ResourceTypeName, primaryResourceId)); + var includes = GetNavigationPropertiesIncludes(Includes); + var query = primaryEntityQuery.SelectMany(lambda); - return primaryEntityQuery.SelectMany(lambda); + if (includes != null && includes.Any()) + query = includes.Aggregate(query, (current, include) => current.Include(include)); + return query; } @@ -62,18 +67,17 @@ protected override async Task> GetRelatedQuery(string prima /// This method allows to include into query. /// This can reduce the number of queries (eager loading) /// - /// /// /// - protected virtual Expression>[] GetNavigationPropertiesIncludes(string[] includes) + protected virtual Expression>[] GetNavigationPropertiesIncludes(string[] includes) { - List>> list = new List>>(); + List>> list = new List>>(); foreach (var include in includes) { var incl = include.Pascalize(); - var param = Expression.Parameter(typeof(TResource)); + var param = Expression.Parameter(typeof(TRelated)); var lambda = - Expression.Lambda>( + Expression.Lambda>( Expression.PropertyOrField(param, incl), param); list.Add(lambda); } @@ -81,26 +85,14 @@ protected virtual Expression>[] GetNavigationPropertiesI } private IQueryable FilterById(string id, - IResourceTypeRegistration resourceTypeRegistration, - params Expression>[] includes) where TResource : class + IResourceTypeRegistration resourceTypeRegistration) where TResource : class { var param = Expression.Parameter(typeof (TResource)); var filterByIdExpression = resourceTypeRegistration.GetFilterByIdExpression(param, id); var predicate = Expression.Lambda>(filterByIdExpression, param); - return QueryIncludeNavigationProperties(predicate, includes); - } - - private IQueryable QueryIncludeNavigationProperties(Expression> predicate, - params Expression>[] includes) where TResource : class - { IQueryable query = _dbContext.Set(); - if (includes != null && includes.Any()) - query = includes.Aggregate(query, (current, include) => current.Include(include)); - - if (predicate != null) - query = query.Where(predicate); - - return query.AsQueryable(); + return query.Where(predicate); } + } } From 7448fa87477510fb1dd8094c083cf1490afc5904 Mon Sep 17 00:00:00 2001 From: Chris Santero Date: Thu, 15 Sep 2016 19:12:44 -0400 Subject: [PATCH 18/22] Treat JToken properties as primitive attributes for serialization (#123) --- ..._of_various_types_serialize_correctly.json | 193 ++++++++++-------- .../Controllers/SamplesController.cs | 26 ++- .../Models/Sample.cs | 7 + .../Extensions/TypeExtensionsTests.cs | 4 + JSONAPI/Extensions/TypeExtensions.cs | 3 +- 5 files changed, 144 insertions(+), 89 deletions(-) diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/AttributeSerialization/Attributes_of_various_types_serialize_correctly.json b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/AttributeSerialization/Attributes_of_various_types_serialize_correctly.json index ba0978cf..ca94936a 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/AttributeSerialization/Attributes_of_various_types_serialize_correctly.json +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Tests/Fixtures/AttributeSerialization/Attributes_of_various_types_serialize_correctly.json @@ -1,89 +1,110 @@ { - "data": [ - { - "type": "samples", - "id": "1", - "attributes": { - "boolean-field": false, - "byte-field": 0, - "complex-attribute-field": null, - "date-time-field": "0001-01-01T00:00:00", - "date-time-offset-field": "0001-01-01T00:00:00.0000000+00:00", - "decimal-field": "0", - "double-field": 0.0, - "enum-field": 0, - "guid-field": "00000000-0000-0000-0000-000000000000", - "int16-field": 0, - "int32-field": 0, - "int64-field": 0, - "nullable-boolean-field": false, - "nullable-byte-field": null, - "nullable-date-time-field": null, - "nullable-date-time-offset-field": null, - "nullable-decimal-field": null, - "nullable-double-field": null, - "nullable-enum-field": null, - "nullable-guid-field": null, - "nullable-int16-field": null, - "nullable-int32-field": null, - "nullable-int64-field": null, - "nullable-sbyte-field": null, - "nullable-single-field": null, - "nullable-uint16-field": null, - "nullable-uint32-field": null, - "nullable-uint64-field": null, - "sbyte-field": 0, - "single-field": 0.0, - "string-field": null, - "uint16-field": 0, - "uint32-field": 0, - "uint64-field": 0 - } + "data": [ + { + "type": "samples", + "id": "1", + "attributes": { + "boolean-field": false, + "byte-field": 0, + "complex-attribute-field": null, + "date-time-field": "0001-01-01T00:00:00", + "date-time-offset-field": "0001-01-01T00:00:00.0000000+00:00", + "decimal-field": "0", + "double-field": 0.0, + "enum-field": 0, + "guid-field": "00000000-0000-0000-0000-000000000000", + "int16-field": 0, + "int32-field": 0, + "int64-field": 0, + "j-token-array-field": null, + "j-token-object-field": null, + "j-token-string-field": null, + "nullable-boolean-field": false, + "nullable-byte-field": null, + "nullable-date-time-field": null, + "nullable-date-time-offset-field": null, + "nullable-decimal-field": null, + "nullable-double-field": null, + "nullable-enum-field": null, + "nullable-guid-field": null, + "nullable-int16-field": null, + "nullable-int32-field": null, + "nullable-int64-field": null, + "nullable-sbyte-field": null, + "nullable-single-field": null, + "nullable-uint16-field": null, + "nullable-uint32-field": null, + "nullable-uint64-field": null, + "sbyte-field": 0, + "single-field": 0.0, + "string-field": null, + "uint16-field": 0, + "uint32-field": 0, + "uint64-field": 0 + } + }, + { + "type": "samples", + "id": "2", + "attributes": { + "boolean-field": true, + "byte-field": 253, + "complex-attribute-field": { + "foo": { + "baz": [ 11 ] + }, + "bar": 5 }, - { - "type": "samples", - "id": "2", - "attributes": { - "boolean-field": true, - "byte-field": 253, - "complex-attribute-field": { - "foo": { - "baz": [ 11 ] - }, - "bar": 5 - }, - "date-time-field": "1776-07-04T00:00:00", - "date-time-offset-field": "1776-07-04T00:00:00.0000000-05:00", - "decimal-field": "1056789.123", - "double-field": 1056789.123, - "enum-field": 1, - "guid-field": "6566f9b4-5245-40de-890d-98b40a4ad656", - "int16-field": 32000, - "int32-field": 2000000000, - "int64-field": 9223372036854775807, - "nullable-boolean-field": true, - "nullable-byte-field": 253, - "nullable-date-time-field": "1776-07-04T00:00:00", - "nullable-date-time-offset-field": "1776-07-04T00:00:00.0000000-05:00", - "nullable-decimal-field": "1056789.123", - "nullable-double-field": 1056789.123, - "nullable-enum-field": 2, - "nullable-guid-field": "3d1fb81e-43ee-4d04-af91-c8a326341293", - "nullable-int16-field": 32000, - "nullable-int32-field": 2000000000, - "nullable-int64-field": 9223372036854775807, - "nullable-sbyte-field": 123, - "nullable-single-field": 1056789.13, - "nullable-uint16-field": 64000, - "nullable-uint32-field": 3000000000, - "nullable-uint64-field": 9223372036854775808, - "sbyte-field": 123, - "single-field": 1056789.13, - "string-field": "Some string 156", - "uint16-field": 64000, - "uint32-field": 3000000000, - "uint64-field": 9223372036854775808 - } - } - ] + "date-time-field": "1776-07-04T00:00:00", + "date-time-offset-field": "1776-07-04T00:00:00.0000000-05:00", + "decimal-field": "1056789.123", + "double-field": 1056789.123, + "enum-field": 1, + "guid-field": "6566f9b4-5245-40de-890d-98b40a4ad656", + "int16-field": 32000, + "int32-field": 2000000000, + "int64-field": 9223372036854775807, + "j-token-array-field": [ + { + "my-field1": "George Washington", + "overridden-field2": null, + "MyField3": 216 + }, + { + "my-field1": "Thomas Jefferson", + "overridden-field2": false, + "MyField3": 631 + } + ], + "j-token-object-field": { + "my-field1": "Abraham Lincoln", + "overridden-field2": true, + "MyField3": 439 + }, + "j-token-string-field": "Andrew Jackson", + "nullable-boolean-field": true, + "nullable-byte-field": 253, + "nullable-date-time-field": "1776-07-04T00:00:00", + "nullable-date-time-offset-field": "1776-07-04T00:00:00.0000000-05:00", + "nullable-decimal-field": "1056789.123", + "nullable-double-field": 1056789.123, + "nullable-enum-field": 2, + "nullable-guid-field": "3d1fb81e-43ee-4d04-af91-c8a326341293", + "nullable-int16-field": 32000, + "nullable-int32-field": 2000000000, + "nullable-int64-field": 9223372036854775807, + "nullable-sbyte-field": 123, + "nullable-single-field": 1056789.13, + "nullable-uint16-field": 64000, + "nullable-uint32-field": 3000000000, + "nullable-uint64-field": 9223372036854775808, + "sbyte-field": 123, + "single-field": 1056789.13, + "string-field": "Some string 156", + "uint16-field": 64000, + "uint32-field": 3000000000, + "uint64-field": 9223372036854775808 + } + } + ] } diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Controllers/SamplesController.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Controllers/SamplesController.cs index 66588415..4d6d7546 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Controllers/SamplesController.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Controllers/SamplesController.cs @@ -1,6 +1,8 @@ using System; using System.Web.Http; using JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Controllers { @@ -44,7 +46,10 @@ public IHttpActionResult GetSamples() StringField = null, EnumField = default(SampleEnum), NullableEnumField = null, - ComplexAttributeField = null + ComplexAttributeField = null, + JTokenStringField = null, + JTokenObjectField = null, + JTokenArrayField = null }; var s2 = new Sample { @@ -82,10 +87,27 @@ public IHttpActionResult GetSamples() StringField = "Some string 156", EnumField = SampleEnum.Value1, NullableEnumField = SampleEnum.Value2, - ComplexAttributeField = "{\"foo\": { \"baz\": [11] }, \"bar\": 5}" + ComplexAttributeField = "{\"foo\": { \"baz\": [11] }, \"bar\": 5}", + JTokenStringField = "Andrew Jackson", + JTokenObjectField = JToken.FromObject(new SomeSerializableClass { MyField1 = "Abraham Lincoln", MyField2 = true, MyField3 = 439 }), + JTokenArrayField = new JArray( + JToken.FromObject(new SomeSerializableClass { MyField1 = "George Washington", MyField2 = null, MyField3 = 216 }), + JToken.FromObject(new SomeSerializableClass { MyField1 = "Thomas Jefferson", MyField2 = false, MyField3 = 631 })) }; return Ok(new[] { s1, s2 }); } + + [Serializable] + public class SomeSerializableClass + { + [JsonProperty("my-field1")] + public string MyField1 { get; set; } + + [JsonProperty("overridden-field2")] + public bool? MyField2 { get; set; } + + public int MyField3 { get; set; } + } } } \ No newline at end of file diff --git a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Sample.cs b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Sample.cs index c56ea671..e8096599 100644 --- a/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Sample.cs +++ b/JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp/Models/Sample.cs @@ -1,5 +1,6 @@ using System; using JSONAPI.Attributes; +using Newtonsoft.Json.Linq; namespace JSONAPI.AcceptanceTests.EntityFrameworkTestWebApp.Models { @@ -48,5 +49,11 @@ public class Sample [SerializeAsComplex] public string ComplexAttributeField { get; set; } + + public JToken JTokenStringField { get; set; } + + public JToken JTokenObjectField { get; set; } + + public JToken JTokenArrayField { get; set; } } } diff --git a/JSONAPI.Tests/Extensions/TypeExtensionsTests.cs b/JSONAPI.Tests/Extensions/TypeExtensionsTests.cs index 19b51b69..9d593abc 100644 --- a/JSONAPI.Tests/Extensions/TypeExtensionsTests.cs +++ b/JSONAPI.Tests/Extensions/TypeExtensionsTests.cs @@ -1,6 +1,7 @@ using System; using JSONAPI.Extensions; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; namespace JSONAPI.Tests.Extensions { @@ -46,6 +47,9 @@ public void CanWriteAsJsonApiAttributeTest() Assert.IsTrue(typeof(String).CanWriteAsJsonApiAttribute(), "CanWriteTypeAsAttribute returned wrong answer for String!"); Assert.IsTrue(typeof(TestEnum).CanWriteAsJsonApiAttribute(), "CanWriteTypeAsAttribute returned wrong answer for enum!"); Assert.IsTrue(typeof(TestEnum?).CanWriteAsJsonApiAttribute(), "CanWriteTypeAsAttribute returned wrong answer for nullable enum!"); + Assert.IsTrue(typeof(JToken).CanWriteAsJsonApiAttribute(), "CanWriteTypeAsAttribute returned wrong answer for JToken!"); + Assert.IsTrue(typeof(JObject).CanWriteAsJsonApiAttribute(), "CanWriteTypeAsAttribute returned wrong answer for JObject!"); + Assert.IsTrue(typeof(JArray).CanWriteAsJsonApiAttribute(), "CanWriteTypeAsAttribute returned wrong answer for Jarray!"); Assert.IsFalse(typeof(Object).CanWriteAsJsonApiAttribute(), "CanWriteTypeAsAttribute returned wrong answer for Object!"); } diff --git a/JSONAPI/Extensions/TypeExtensions.cs b/JSONAPI/Extensions/TypeExtensions.cs index 8df43e56..c7d849ca 100644 --- a/JSONAPI/Extensions/TypeExtensions.cs +++ b/JSONAPI/Extensions/TypeExtensions.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace JSONAPI.Extensions { @@ -17,6 +17,7 @@ public static bool CanWriteAsJsonApiAttribute(this Type objectType) || typeof (DateTime).IsAssignableFrom(objectType) || typeof (DateTimeOffset).IsAssignableFrom(objectType) || typeof (String).IsAssignableFrom(objectType) + || typeof (JToken).IsAssignableFrom(objectType) || objectType.IsEnum; } From f4f2862ca0df8d8bd6c9e5be87507bb22ddb09b9 Mon Sep 17 00:00:00 2001 From: Chris Santero Date: Wed, 30 Nov 2016 12:58:55 -0500 Subject: [PATCH 19/22] make module constructor public --- JSONAPI.Autofac/JsonApiAutofacModule.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/JSONAPI.Autofac/JsonApiAutofacModule.cs b/JSONAPI.Autofac/JsonApiAutofacModule.cs index 9c57ca4a..dd248aa8 100644 --- a/JSONAPI.Autofac/JsonApiAutofacModule.cs +++ b/JSONAPI.Autofac/JsonApiAutofacModule.cs @@ -16,7 +16,7 @@ public class JsonApiAutofacModule : Module { private readonly IJsonApiConfiguration _jsonApiConfiguration; - internal JsonApiAutofacModule(IJsonApiConfiguration jsonApiConfiguration) + public JsonApiAutofacModule(IJsonApiConfiguration jsonApiConfiguration) { _jsonApiConfiguration = jsonApiConfiguration; } From 2e0226a2fc1792b9267a28176a66889b058b5108 Mon Sep 17 00:00:00 2001 From: jakubozga Date: Mon, 12 Dec 2016 16:24:45 +0100 Subject: [PATCH 20/22] Added static method MethodNotAllowed in JsonApiException (#127) --- JSONAPI/Documents/Builders/JsonApiException.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/JSONAPI/Documents/Builders/JsonApiException.cs b/JSONAPI/Documents/Builders/JsonApiException.cs index 00472ea0..4b72176c 100644 --- a/JSONAPI/Documents/Builders/JsonApiException.cs +++ b/JSONAPI/Documents/Builders/JsonApiException.cs @@ -99,5 +99,20 @@ public static JsonApiException CreateForForbidden(string detail = null) }; return new JsonApiException(error); } + + /// + /// Creates a JsonApiException to send a 405 Method Not Allowed error. + /// + public static JsonApiException CreateForMethodNotAllowed(string detail = null) + { + var error = new Error + { + Id = Guid.NewGuid().ToString(), + Status = HttpStatusCode.MethodNotAllowed, + Title = "Method not allowed", + Detail = detail + }; + return new JsonApiException(error); + } } } From 751296d5082eaad01e5588c35ada0a0669bf5ece Mon Sep 17 00:00:00 2001 From: Chris Santero Date: Tue, 4 Apr 2017 16:56:03 -0400 Subject: [PATCH 21/22] Update README.md --- README.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1fb411ca..ee1dc2e9 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,11 @@ JSONAPI.NET =========== [![jsonapi MyGet Build Status](https://www.myget.org/BuildSource/Badge/jsonapi?identifier=caf48269-c15b-4850-a29e-b41a23d9854d)](https://www.myget.org/) -News ----- +_Deprecation notice_ +------------------ -The NuGet packages are out! -* [JSONAPI](https://www.nuget.org/packages/JSONAPI/) -* [JSONAPI.EntityFramework](https://www.nuget.org/packages/JSONAPI.EntityFramework/) +JSONAPI.NET is no longer actively maintained. It is not recommended for use in new projects. It does NOT work with .NET Core. Please consider [one of the other .NET server implementations](http://jsonapi.org/implementations/#server-libraries-net) instead. -JSON API Compliance ----------------------- - -The master branch is roughly compatible with the RC3 version of JSON API. The major missing feature is inclusion of related resources. Many changes made to the spec since RC3 are not yet available in this library. Full 1.0 compliance is planned, so stay tuned! What is JSONAPI.NET? ==================== From aee11a70dbaa00620944b80506c3d34dc283216f Mon Sep 17 00:00:00 2001 From: Chris Santero Date: Fri, 23 Feb 2018 16:48:40 -0500 Subject: [PATCH 22/22] give JsonApiException more descriptive message --- JSONAPI/Documents/Builders/JsonApiException.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/JSONAPI/Documents/Builders/JsonApiException.cs b/JSONAPI/Documents/Builders/JsonApiException.cs index 4b72176c..ee6d04f9 100644 --- a/JSONAPI/Documents/Builders/JsonApiException.cs +++ b/JSONAPI/Documents/Builders/JsonApiException.cs @@ -19,7 +19,7 @@ public class JsonApiException : Exception /// Creates a new JsonApiException /// /// - public JsonApiException(IError error) + public JsonApiException(IError error) : base(error?.Detail ?? "An error occurred in JSONAPI") { Error = error; }