diff --git a/src/GlobalSuppressions.cs b/src/GlobalSuppressions.cs index 6d81d668..1bffd79b 100644 --- a/src/GlobalSuppressions.cs +++ b/src/GlobalSuppressions.cs @@ -43,6 +43,7 @@ #region CA1020 Few types in namespace [assembly: SuppressMessage("Microsoft.Design", "CA1020:AvoidNamespacesWithFewTypes", Scope = "namespace", Target = "Microsoft.Restier.Core")] +[assembly: SuppressMessage("Microsoft.Design", "CA1020:AvoidNamespacesWithFewTypes", Scope = "namespace", Target = "Microsoft.Restier.Core.Exceptions")] [assembly: SuppressMessage("Microsoft.Design", "CA1020:AvoidNamespacesWithFewTypes", Scope = "namespace", Target = "Microsoft.Restier.Core.Model")] [assembly: SuppressMessage("Microsoft.Design", "CA1020:AvoidNamespacesWithFewTypes", Scope = "namespace", Target = "Microsoft.Restier.Core.Operation")] [assembly: SuppressMessage("Microsoft.Design", "CA1020:AvoidNamespacesWithFewTypes", Scope = "namespace", Target = "Microsoft.Restier.Providers.EntityFramework")] @@ -78,12 +79,15 @@ [assembly: SuppressMessage("Microsoft.Design", "CA1026:DefaultParametersShouldNotBeUsed", Scope = "member", Target = "Microsoft.Restier.Publishers.OData.Routing.HttpConfigurationExtensions.#MapRestierRoute`1(System.Web.Http.HttpConfiguration,System.String,System.String,System.Func`1,Microsoft.Restier.Publishers.OData.Batch.RestierBatchHandler)")] [assembly: SuppressMessage("Microsoft.Design", "CA1026:DefaultParametersShouldNotBeUsed", Scope = "member", Target = "Microsoft.Restier.Publishers.OData.Routing.HttpConfigurationExtensions.#MapRestierRoute`1(System.Web.Http.HttpConfiguration,System.String,System.String,Microsoft.Restier.Publishers.OData.Batch.RestierBatchHandler)")] [assembly: SuppressMessage("Microsoft.Design", "CA1026:DefaultParametersShouldNotBeUsed", Scope = "member", Target = "Microsoft.Restier.Publishers.OData.Batch.RestierBatchHandler.#.ctor(System.Web.Http.HttpServer,System.Func`1)")] +[assembly: SuppressMessage("Microsoft.Design", "CA1032:ImplementStandardExceptionConstructors", Scope = "type", Target = "Microsoft.Restier.Core.Exceptions.PreconditionFailedException")] +[assembly: SuppressMessage("Microsoft.Design", "CA1032:ImplementStandardExceptionConstructors", Scope = "type", Target = "Microsoft.Restier.Core.Exceptions.ResourceNotFoundException")] #endregion #region CA1704 Identifiers spelling [assembly: SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Sourcer", Scope = "type", Target = "Microsoft.Restier.Core.Query.IQueryExpressionSourcer")] [assembly: SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Sourcer", Scope = "type", Target = "Microsoft.Restier.Providers.EntityFramework.Query.QueryExpressionSourcer")] [assembly: SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Ef", Scope = "member", Target = "Microsoft.Restier.Providers.EntityFramework.ServiceCollectionExtensions.#AddEfProviderServices`1(Microsoft.Extensions.DependencyInjection.IServiceCollection)")] +[assembly: SuppressMessage("Microsoft.Naming", "CA1704:IdentifiersShouldBeSpelledCorrectly", MessageId = "Etag", Scope = "member", Target = "Microsoft.Restier.Core.Submit.DataModificationItem.#ApplyEtag(System.Linq.IQueryable)")] #endregion #region CA1709 Identifiers case @@ -176,14 +180,10 @@ #endregion #region CA2000 Dispose objects -[assembly: SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Restier.Publishers.OData.RestierController`1.#GetSource(System.Web.OData.Routing.ODataPath,Microsoft.OData.Edm.IEdmEntityType&)")] -[assembly: SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Restier.Publishers.OData.RestierController`1.#CreateQueryResponse(System.Linq.IQueryable,Microsoft.OData.Edm.IEdmType)")] -[assembly: SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Restier.Publishers.OData.RestierController.#CreateQueryResponse(System.Linq.IQueryable,Microsoft.OData.Edm.IEdmType)")] -[assembly: SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Restier.Publishers.OData.RestierController.#GetQuery(System.Web.OData.Extensions.HttpRequestMessageProperties)")] -[assembly: SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Restier.Publishers.OData.RestierController.#GetSource(System.Web.OData.Routing.ODataPath,Microsoft.OData.Edm.IEdmEntityType&)")] -[assembly: SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Restier.Publishers.OData.RestierController.#GetQuery()")] +[assembly: SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Restier.Publishers.OData.RestierController.#CreateQueryResponse(System.Linq.IQueryable,Microsoft.OData.Edm.IEdmType,System.Boolean,System.Web.OData.Formatter.ETag)")] [assembly: SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Restier.Publishers.OData.Filters.RestierExceptionFilterAttribute.#Handler403(System.Web.Http.Filters.HttpActionExecutedContext,System.Threading.CancellationToken)")] [assembly: SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Restier.Publishers.OData.Filters.RestierExceptionFilterAttribute.#Handler404(System.Web.Http.Filters.HttpActionExecutedContext,System.Threading.CancellationToken)")] +[assembly: SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Restier.Publishers.OData.Filters.RestierExceptionFilterAttribute.#Handler412(System.Web.Http.Filters.HttpActionExecutedContext,System.Threading.CancellationToken)")] [assembly: SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Scope = "member", Target = "Microsoft.Restier.Publishers.OData.Filters.RestierExceptionFilterAttribute.#Handler501(System.Web.Http.Filters.HttpActionExecutedContext,System.Threading.CancellationToken)")] #endregion diff --git a/src/Microsoft.Restier.Core/ApiBase.cs b/src/Microsoft.Restier.Core/ApiBase.cs index e0c0c00f..486fb535 100644 --- a/src/Microsoft.Restier.Core/ApiBase.cs +++ b/src/Microsoft.Restier.Core/ApiBase.cs @@ -60,6 +60,11 @@ public ApiContext Context } } + /// + /// Gets a value indicating whether this API has been disposed. + /// + public bool IsDisposed { get; private set; } + /// /// Gets the API configuration for this API. /// @@ -86,11 +91,6 @@ protected ApiConfiguration Configuration } } - /// - /// Gets a value indicating whether this API has been disposed. - /// - protected bool IsDisposed { get; private set; } - /// /// Performs application-defined tasks associated with /// freeing, releasing, or resetting unmanaged resources. diff --git a/src/Microsoft.Restier.Core/Conventions/ConventionBasedChangeSetItemAuthorizer.cs b/src/Microsoft.Restier.Core/Conventions/ConventionBasedChangeSetItemAuthorizer.cs index 3a3a8d84..294f3f11 100644 --- a/src/Microsoft.Restier.Core/Conventions/ConventionBasedChangeSetItemAuthorizer.cs +++ b/src/Microsoft.Restier.Core/Conventions/ConventionBasedChangeSetItemAuthorizer.cs @@ -80,15 +80,15 @@ private static string GetAuthorizeMethodName(ChangeSetItem item) case ChangeSetItemType.DataModification: DataModificationItem dataModification = (DataModificationItem)item; string operationName = null; - if (dataModification.IsNewRequest) + if (dataModification.ChangeSetItemAction == ChangeSetItemAction.Insert) { operationName = ConventionBasedChangeSetConstants.AuthorizeMethodDataModificationInsert; } - else if (dataModification.IsUpdateRequest) + else if (dataModification.ChangeSetItemAction == ChangeSetItemAction.Update) { operationName = ConventionBasedChangeSetConstants.AuthorizeMethodDataModificationUpdate; } - else if (dataModification.IsDeleteRequest) + else if (dataModification.ChangeSetItemAction == ChangeSetItemAction.Remove) { operationName = ConventionBasedChangeSetConstants.AuthorizeMethodDataModificationDelete; } diff --git a/src/Microsoft.Restier.Core/Conventions/ConventionBasedChangeSetItemProcessor.cs b/src/Microsoft.Restier.Core/Conventions/ConventionBasedChangeSetItemProcessor.cs index cf11d659..256d3d74 100644 --- a/src/Microsoft.Restier.Core/Conventions/ConventionBasedChangeSetItemProcessor.cs +++ b/src/Microsoft.Restier.Core/Conventions/ConventionBasedChangeSetItemProcessor.cs @@ -64,15 +64,15 @@ private static string GetMethodName(ChangeSetItem item, string suffix) case ChangeSetItemType.DataModification: DataModificationItem dataModification = (DataModificationItem)item; string operationName = null; - if (dataModification.IsNewRequest) + if (dataModification.ChangeSetItemAction == ChangeSetItemAction.Insert) { operationName = ConventionBasedChangeSetConstants.FilterMethodDataModificationInsert; } - else if (dataModification.IsUpdateRequest) + else if (dataModification.ChangeSetItemAction == ChangeSetItemAction.Update) { operationName = ConventionBasedChangeSetConstants.FilterMethodDataModificationUpdate; } - else if (dataModification.IsDeleteRequest) + else if (dataModification.ChangeSetItemAction == ChangeSetItemAction.Remove) { operationName = ConventionBasedChangeSetConstants.FilterMethodDataModificationDelete; } diff --git a/src/Microsoft.Restier.Core/Exceptions/PreconditionFailedException.cs b/src/Microsoft.Restier.Core/Exceptions/PreconditionFailedException.cs new file mode 100644 index 00000000..9e77a022 --- /dev/null +++ b/src/Microsoft.Restier.Core/Exceptions/PreconditionFailedException.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Restier.Core.Exceptions +{ + /// + /// This exception is used for 412 Precondition Failed response. + /// + [Serializable] + public class PreconditionFailedException : System.Exception + { + /// + /// Initializes a new instance of the PreconditionFailedException class. + /// + public PreconditionFailedException() + : this(null, null) + { + } + + /// + /// Initializes a new instance of the PreconditionFailedException class. + /// + /// Plain text error message for this exception. + public PreconditionFailedException(string message) + : this(message, null) + { + } + + /// + /// Initializes a new instance of the PreconditionFailedException class. + /// + /// Plain text error message for this exception. + /// Exception that caused this exception to be thrown. + public PreconditionFailedException(string message, System.Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/Microsoft.Restier.Core/Exceptions/ResourceNotFoundException.cs b/src/Microsoft.Restier.Core/Exceptions/ResourceNotFoundException.cs new file mode 100644 index 00000000..a15ec7d5 --- /dev/null +++ b/src/Microsoft.Restier.Core/Exceptions/ResourceNotFoundException.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Restier.Core.Exceptions +{ + /// + /// This exception is used for 404 Not found response. + /// + [Serializable] + public class ResourceNotFoundException : System.Exception + { + /// + /// Initializes a new instance of the ResourceNotFoundException class. + /// + public ResourceNotFoundException() + : this(null, null) + { + } + + /// + /// Initializes a new instance of the ResourceNotFoundException class. + /// + /// Plain text error message for this exception. + public ResourceNotFoundException(string message) + : this(message, null) + { + } + + /// + /// Initializes a new instance of the ResourceNotFoundException class. + /// + /// Plain text error message for this exception. + /// Exception that caused this exception to be thrown. + public ResourceNotFoundException(string message, System.Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/Microsoft.Restier.Core/Microsoft.Restier.Core.csproj b/src/Microsoft.Restier.Core/Microsoft.Restier.Core.csproj index 25582abb..3daa61b4 100644 --- a/src/Microsoft.Restier.Core/Microsoft.Restier.Core.csproj +++ b/src/Microsoft.Restier.Core/Microsoft.Restier.Core.csproj @@ -65,6 +65,8 @@ + + diff --git a/src/Microsoft.Restier.Core/Submit/ChangeSetItem.cs b/src/Microsoft.Restier.Core/Submit/ChangeSetItem.cs index bce4ff3a..d5396e02 100644 --- a/src/Microsoft.Restier.Core/Submit/ChangeSetItem.cs +++ b/src/Microsoft.Restier.Core/Submit/ChangeSetItem.cs @@ -128,6 +128,8 @@ public bool HasChanged() /// public class DataModificationItem : ChangeSetItem { + private const string IfNoneMatchKey = "@IfNoneMatchKey"; + /// /// Initializes a new instance of the class. /// @@ -140,6 +142,9 @@ public class DataModificationItem : ChangeSetItem /// /// The type of the actual entity type in question. /// + /// + /// The ChangeSetItemAction for the request. + /// /// /// The key of the entity being modified. /// @@ -153,6 +158,7 @@ public DataModificationItem( string entitySetName, Type expectedEntityType, Type actualEntityType, + ChangeSetItemAction action, IReadOnlyDictionary entityKey, IReadOnlyDictionary originalValues, IReadOnlyDictionary localValues) @@ -166,7 +172,7 @@ public DataModificationItem( this.EntityKey = entityKey; this.OriginalValues = originalValues; this.LocalValues = localValues; - this.ChangeSetItemAction = ChangeSetItemAction.Undefined; + this.ChangeSetItemAction = action; } /// @@ -195,28 +201,6 @@ public DataModificationItem( /// public ChangeSetItemAction ChangeSetItemAction { get; set; } - /// - /// Gets a value indicating whether the modification is a new entity. - /// - public bool IsNewRequest - { - get - { - return this.OriginalValues == null && this.EntityKey == null; - } - } - - /// - /// Gets a value indicating whether the modification is updating an entity. - /// - public bool IsUpdateRequest - { - get - { - return this.OriginalValues != null && this.LocalValues != null; - } - } - /// /// Gets or sets a value indicating whether the entity should be fully replaced by the modification. /// @@ -226,17 +210,6 @@ public bool IsUpdateRequest /// public bool IsFullReplaceUpdateRequest { get; set; } - /// - /// Gets a value indicating whether the modification is deleting an entity. - /// - public bool IsDeleteRequest - { - get - { - return this.LocalValues == null; - } - } - /// /// Gets or sets the entity object in question. /// @@ -294,7 +267,7 @@ public IReadOnlyDictionary LocalValues public IQueryable ApplyTo(IQueryable query) { Ensure.NotNull(query, "query"); - if (this.IsNewRequest) + if (this.ChangeSetItemAction == ChangeSetItemAction.Insert) { throw new InvalidOperationException(Resources.DataModificationNotSupportCreateEntity); } @@ -316,6 +289,25 @@ public IQueryable ApplyTo(IQueryable query) throw new InvalidOperationException(Resources.DataModificationRequiresEntityKey); } + LambdaExpression whereLambda = Expression.Lambda(where, param); + return ExpressionHelpers.Where(query, whereLambda, type); + } + + /// + /// Applies the current DataModificationItem's OriginalValues to the + /// specified query and returns the new query. + /// + /// The IQueryable to apply the property values to. + /// + /// The new IQueryable with the property values applied to it in a Where condition. + /// + public IQueryable ApplyEtag(IQueryable query) + { + Ensure.NotNull(query, "query"); + Type type = query.ElementType; + ParameterExpression param = Expression.Parameter(type); + Expression where = null; + if (this.OriginalValues != null) { foreach (KeyValuePair item in this.OriginalValues) @@ -325,6 +317,11 @@ public IQueryable ApplyTo(IQueryable query) where = ApplyPredicate(param, where, item); } } + + if (this.OriginalValues.ContainsKey(IfNoneMatchKey)) + { + where = Expression.Not(where); + } } LambdaExpression whereLambda = Expression.Lambda(where, param); @@ -373,6 +370,9 @@ public class DataModificationItem : DataModificationItem /// /// The type of the actual entity type in question. /// + /// + /// The ChangeSetItemAction for the request. + /// /// /// The key of the entity being modified. /// @@ -386,10 +386,11 @@ public DataModificationItem( string entitySetName, Type expectedEntityType, Type actualEntityType, + ChangeSetItemAction action, IReadOnlyDictionary entityKey, IReadOnlyDictionary originalValues, IReadOnlyDictionary localValues) - : base(entitySetName, expectedEntityType, actualEntityType, entityKey, originalValues, localValues) + : base(entitySetName, expectedEntityType, actualEntityType, action, entityKey, originalValues, localValues) { } diff --git a/src/Microsoft.Restier.Core/Submit/DefaultSubmitHandler.cs b/src/Microsoft.Restier.Core/Submit/DefaultSubmitHandler.cs index 106933db..9f684518 100644 --- a/src/Microsoft.Restier.Core/Submit/DefaultSubmitHandler.cs +++ b/src/Microsoft.Restier.Core/Submit/DefaultSubmitHandler.cs @@ -74,15 +74,15 @@ private static string GetAuthorizeFailedMessage(ChangeSetItem item) case ChangeSetItemType.DataModification: DataModificationItem dataModification = (DataModificationItem)item; string message = null; - if (dataModification.IsNewRequest) + if (dataModification.ChangeSetItemAction == ChangeSetItemAction.Insert) { message = Resources.NoPermissionToInsertEntity; } - else if (dataModification.IsUpdateRequest) + else if (dataModification.ChangeSetItemAction == ChangeSetItemAction.Update) { message = Resources.NoPermissionToUpdateEntity; } - else if (dataModification.IsDeleteRequest) + else if (dataModification.ChangeSetItemAction == ChangeSetItemAction.Remove) { message = Resources.NoPermissionToDeleteEntity; } @@ -221,28 +221,6 @@ private static async Task PerformPersist( IEnumerable changeSetItems, CancellationToken cancellationToken) { - // Once the change is persisted, the EntityState is lost. - // In order to invoke the correct post-CUD event, remember which action was performed on the entity. - foreach (ChangeSetItem item in changeSetItems) - { - if (item.Type == ChangeSetItemType.DataModification) - { - DataModificationItem dataModification = (DataModificationItem)item; - if (dataModification.IsNewRequest) - { - dataModification.ChangeSetItemAction = ChangeSetItemAction.Insert; - } - else if (dataModification.IsUpdateRequest) - { - dataModification.ChangeSetItemAction = ChangeSetItemAction.Update; - } - else if (dataModification.IsDeleteRequest) - { - dataModification.ChangeSetItemAction = ChangeSetItemAction.Remove; - } - } - } - var executor = context.GetApiService(); if (executor == null) { diff --git a/src/Microsoft.Restier.Providers.EntityFramework/Properties/Resources.Designer.cs b/src/Microsoft.Restier.Providers.EntityFramework/Properties/Resources.Designer.cs index 9cf96699..6d1dc91f 100644 --- a/src/Microsoft.Restier.Providers.EntityFramework/Properties/Resources.Designer.cs +++ b/src/Microsoft.Restier.Providers.EntityFramework/Properties/Resources.Designer.cs @@ -69,6 +69,15 @@ internal static string DataModificationMustBeCUD { } } + /// + /// Looks up a localized string similar to The precondition check for request {0} on resource {1} is failed.. + /// + internal static string PreconditionCheckFailed { + get { + return ResourceManager.GetString("PreconditionCheckFailed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Could not find the specified resource.. /// diff --git a/src/Microsoft.Restier.Providers.EntityFramework/Properties/Resources.resx b/src/Microsoft.Restier.Providers.EntityFramework/Properties/Resources.resx index 103f5e27..e49873a3 100644 --- a/src/Microsoft.Restier.Providers.EntityFramework/Properties/Resources.resx +++ b/src/Microsoft.Restier.Providers.EntityFramework/Properties/Resources.resx @@ -120,6 +120,9 @@ A DataModificationEntry must be either New, Update or Delete. + + The precondition check for request {0} on resource {1} is failed. + Could not find the specified resource. The target entity not found for modification diff --git a/src/Microsoft.Restier.Providers.EntityFramework/Submit/ChangeSetInitializer.cs b/src/Microsoft.Restier.Providers.EntityFramework/Submit/ChangeSetInitializer.cs index 5c3034e7..8fd00f9f 100644 --- a/src/Microsoft.Restier.Providers.EntityFramework/Submit/ChangeSetInitializer.cs +++ b/src/Microsoft.Restier.Providers.EntityFramework/Submit/ChangeSetInitializer.cs @@ -13,6 +13,7 @@ using System.Threading.Tasks; using Microsoft.OData.Edm.Library; using Microsoft.Restier.Core; +using Microsoft.Restier.Core.Exceptions; using Microsoft.Restier.Core.Query; using Microsoft.Restier.Core.Submit; using Microsoft.Restier.Providers.EntityFramework.Properties; @@ -51,7 +52,7 @@ public async Task InitializeAsync( object entity; - if (entry.IsNewRequest) + if (entry.ChangeSetItemAction == ChangeSetItemAction.Insert) { entity = set.Create(); @@ -59,12 +60,12 @@ public async Task InitializeAsync( set.Add(entity); } - else if (entry.IsDeleteRequest) + else if (entry.ChangeSetItemAction == ChangeSetItemAction.Remove) { entity = await FindEntity(context, entry, cancellationToken); set.Remove(entity); } - else if (entry.IsUpdateRequest) + else if (entry.ChangeSetItemAction == ChangeSetItemAction.Update) { entity = await FindEntity(context, entry, cancellationToken); @@ -93,24 +94,27 @@ private static async Task FindEntity( object entity = result.Results.SingleOrDefault(); if (entity == null) { - // TODO GitHubIssue#38 : Handle the case when entity is resolved - // there are 2 cases where the entity is not found: - // 1) it doesn't exist - // 2) concurrency checks have failed - // we should account for both - I can see 3 options: - // a. always return "PreConditionFailed" result - // - this is the canonical behavior of WebAPI OData, see the following post: - // "Getting started with ASP.NET Web API 2.2 for OData v4.0" on http://blogs.msdn.com/b/webdev/. - // - this makes sense because if someone deleted the record, then you still have a concurrency error - // b. possibly doing a 2nd query with just the keys to see if the record still exists - // c. only query with the keys, and then set the DbEntityEntry's OriginalValues to the ETag values, - // letting the save fail if there are concurrency errors - - ////throw new EntityNotFoundException - throw new InvalidOperationException(Resources.ResourceNotFound); + throw new ResourceNotFoundException(Resources.ResourceNotFound); } - return entity; + // This means no If-Match or If-None-Match header + if (item.OriginalValues == null || item.OriginalValues.Count == 0) + { + return entity; + } + + var etagEntity = item.ApplyEtag(result.Results.AsQueryable()).SingleOrDefault(); + if (etagEntity == null) + { + // If ETAG does not match, should return 412 Precondition Failed + var message = string.Format( + CultureInfo.InvariantCulture, + Resources.PreconditionCheckFailed, + new object[] { item.ChangeSetItemAction, entity }); + throw new PreconditionFailedException(message); + } + + return etagEntity; } private static void SetValues(DbEntityEntry dbEntry, DataModificationItem item, Type entityType) diff --git a/src/Microsoft.Restier.Publishers.OData/Extensions.cs b/src/Microsoft.Restier.Publishers.OData/Extensions.cs index b3f43d52..fda1a5d2 100644 --- a/src/Microsoft.Restier.Publishers.OData/Extensions.cs +++ b/src/Microsoft.Restier.Publishers.OData/Extensions.cs @@ -2,14 +2,18 @@ // Licensed under the MIT License. See License.txt in the project root for license information. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Reflection; using System.Threading.Tasks; using System.Web.OData; using System.Web.OData.Formatter; using Microsoft.OData.Edm; +using Microsoft.OData.Edm.Annotations; using Microsoft.OData.Edm.Library; +using Microsoft.OData.Edm.Vocabularies.V1; using Microsoft.Restier.Core; using Microsoft.Restier.Publishers.OData.Model; using Microsoft.Restier.Publishers.OData.Properties; @@ -23,6 +27,9 @@ internal static class Extensions private static PropertyInfo etagConcurrencyPropertiesProperty = typeof(ETag).GetProperty( PropertyNameOfConcurrencyProperties, BindingFlags.NonPublic | BindingFlags.Instance); + private static ConcurrentDictionary concurrencyCheckFlags + = new ConcurrentDictionary(); + public static void ApplyTo(this ETag etag, IDictionary propertyValues) { if (etag != null) @@ -36,6 +43,27 @@ public static void ApplyTo(this ETag etag, IDictionary propertyV } } + public static bool IsConcurrencyCheckEnabled(this IEdmModel model, IEdmEntitySet entitySet) + { + bool needCurrencyCheck; + if (concurrencyCheckFlags.TryGetValue(entitySet, out needCurrencyCheck)) + { + return needCurrencyCheck; + } + + needCurrencyCheck = false; + var annotations = model.FindVocabularyAnnotations( + entitySet, CoreVocabularyModel.ConcurrencyTerm); + IEdmValueAnnotation annotation = annotations.FirstOrDefault(); + if (annotation != null) + { + needCurrencyCheck = true; + } + + concurrencyCheckFlags[entitySet] = needCurrencyCheck; + return needCurrencyCheck; + } + public static IReadOnlyDictionary CreatePropertyDictionary(this Delta entity) { Dictionary propertyValues = new Dictionary(); diff --git a/src/Microsoft.Restier.Publishers.OData/Filters/RestierExceptionFilterAttribute.cs b/src/Microsoft.Restier.Publishers.OData/Filters/RestierExceptionFilterAttribute.cs index 3eec604c..1f0740d3 100644 --- a/src/Microsoft.Restier.Publishers.OData/Filters/RestierExceptionFilterAttribute.cs +++ b/src/Microsoft.Restier.Publishers.OData/Filters/RestierExceptionFilterAttribute.cs @@ -1,12 +1,13 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. +extern alias Net; + using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; -using System.Net.Http.Formatting; using System.Security; using System.Threading; using System.Threading.Tasks; @@ -14,8 +15,10 @@ using System.Web.Http.Filters; using System.Web.Http.Results; using Microsoft.OData.Core; +using Microsoft.Restier.Core.Exceptions; using Microsoft.Restier.Core.Submit; using Microsoft.Restier.Publishers.OData.Query; +using Net::System.Net.Http.Formatting; namespace Microsoft.Restier.Publishers.OData.Filters { @@ -30,6 +33,7 @@ internal sealed class RestierExceptionFilterAttribute : ExceptionFilterAttribute Handler400, Handler403, Handler404, + Handler412, Handler501 }; @@ -101,15 +105,24 @@ private static Task Handler404( HttpActionExecutedContext context, CancellationToken cancellationToken) { - var notSupportedException = context.Exception as NotSupportedException; - if (notSupportedException != null) + var notFoundException = context.Exception as ResourceNotFoundException; + if (notFoundException != null) { - if (notSupportedException.TargetSite.DeclaringType == typeof(RestierQueryBuilder)) - { - throw new HttpResponseException(context.Request.CreateErrorResponse( - HttpStatusCode.NotFound, - notSupportedException.Message)); - } + return Task.FromResult( + context.Request.CreateErrorResponse(HttpStatusCode.NotFound, context.Exception)); + } + + return Task.FromResult(null); + } + + private static Task Handler412( + HttpActionExecutedContext context, + CancellationToken cancellationToken) + { + if (context.Exception is PreconditionFailedException) + { + return Task.FromResult( + context.Request.CreateErrorResponse(HttpStatusCode.PreconditionFailed, context.Exception)); } return Task.FromResult(null); diff --git a/src/Microsoft.Restier.Publishers.OData/Formatter/Serialization/DefaultRestierSerializerProvider.cs b/src/Microsoft.Restier.Publishers.OData/Formatter/Serialization/DefaultRestierSerializerProvider.cs index 10669e4e..57a9665f 100644 --- a/src/Microsoft.Restier.Publishers.OData/Formatter/Serialization/DefaultRestierSerializerProvider.cs +++ b/src/Microsoft.Restier.Publishers.OData/Formatter/Serialization/DefaultRestierSerializerProvider.cs @@ -18,7 +18,6 @@ private static readonly DefaultRestierSerializerProvider SingletonInstanceField = new DefaultRestierSerializerProvider(); private RestierFeedSerializer feedSerializer; - private RestierEntityTypeSerializer entityTypeSerializer; private RestierPrimitiveSerializer primitiveSerializer; private RestierRawSerializer rawSerializer; private RestierComplexTypeSerializer complexTypeSerializer; @@ -31,7 +30,6 @@ private static readonly DefaultRestierSerializerProvider SingletonInstanceField public DefaultRestierSerializerProvider() { this.feedSerializer = new RestierFeedSerializer(this); - this.entityTypeSerializer = new RestierEntityTypeSerializer(this); this.primitiveSerializer = new RestierPrimitiveSerializer(); this.rawSerializer = new RestierRawSerializer(); this.complexTypeSerializer = new RestierComplexTypeSerializer(this); @@ -67,10 +65,6 @@ public override ODataSerializer GetODataPayloadSerializer( { serializer = this.feedSerializer; } - else if (type == typeof(EntityResult)) - { - serializer = this.entityTypeSerializer; - } else if (type == typeof(PrimitiveResult)) { serializer = this.primitiveSerializer; @@ -106,11 +100,6 @@ public override ODataSerializer GetODataPayloadSerializer( /// The serializer instance. public override ODataEdmTypeSerializer GetEdmTypeSerializer(IEdmTypeReference edmType) { - if (edmType.IsEntity()) - { - return this.entityTypeSerializer; - } - if (edmType.IsComplex()) { return this.complexTypeSerializer; diff --git a/src/Microsoft.Restier.Publishers.OData/Formatter/Serialization/RestierEntityTypeSerializer.cs b/src/Microsoft.Restier.Publishers.OData/Formatter/Serialization/RestierEntityTypeSerializer.cs deleted file mode 100644 index 8764baf6..00000000 --- a/src/Microsoft.Restier.Publishers.OData/Formatter/Serialization/RestierEntityTypeSerializer.cs +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See License.txt in the project root for license information. - -using System; -using System.Web.OData; -using System.Web.OData.Formatter.Serialization; -using Microsoft.OData.Core; -using Microsoft.Restier.Publishers.OData.Results; - -namespace Microsoft.Restier.Publishers.OData.Formatter.Serialization -{ - /// - /// The serializer for entity result. - /// - public class RestierEntityTypeSerializer : ODataEntityTypeSerializer - { - /// - /// Initializes a new instance of the class. - /// - /// The serializer provider. - public RestierEntityTypeSerializer(ODataSerializerProvider provider) - : base(provider) - { - } - - /// - /// Writes the entity result to the response message. - /// - /// The entity result to write. - /// The type of the entity. - /// The message writer. - /// The writing context. - public override void WriteObject( - object graph, - Type type, - ODataMessageWriter messageWriter, - ODataSerializerContext writeContext) - { - EntityResult entityResult = graph as EntityResult; - if (entityResult != null) - { - graph = entityResult.Result; - type = entityResult.Type; - } - - base.WriteObject(graph, type, messageWriter, writeContext); - } - - /// - /// Creates ETag for the entity instance. - /// - /// The context that contains the entity instance. - /// The ETag created. - public override string CreateETag(EntityInstanceContext entityInstanceContext) - { - Ensure.NotNull(entityInstanceContext, "entityInstanceContext"); - string etag = null; - object etagGetterObject; - if (entityInstanceContext.Request.Properties.TryGetValue("ETagGetter", out etagGetterObject)) - { - Func etagGetter = etagGetterObject as Func; - if (etagGetter != null) - { - etag = etagGetter(entityInstanceContext.EntityInstance); - } - } - - if (etag == null) - { - etag = base.CreateETag(entityInstanceContext); - } - - return etag; - } - } -} diff --git a/src/Microsoft.Restier.Publishers.OData/Formatter/Serialization/RestierSerializerProviderProxy.cs b/src/Microsoft.Restier.Publishers.OData/Formatter/Serialization/RestierSerializerProviderProxy.cs index cfb0abc5..ef930a80 100644 --- a/src/Microsoft.Restier.Publishers.OData/Formatter/Serialization/RestierSerializerProviderProxy.cs +++ b/src/Microsoft.Restier.Publishers.OData/Formatter/Serialization/RestierSerializerProviderProxy.cs @@ -52,7 +52,7 @@ public override ODataSerializer GetODataPayloadSerializer( /// The serializer instance. public override ODataEdmTypeSerializer GetEdmTypeSerializer(IEdmTypeReference edmType) { - if (this.api != null) + if (this.api != null && !this.api.IsDisposed) { ODataSerializerProvider provider = api.Context.GetApiService(); if (provider != null) diff --git a/src/Microsoft.Restier.Publishers.OData/Microsoft.Restier.Publishers.OData.csproj b/src/Microsoft.Restier.Publishers.OData/Microsoft.Restier.Publishers.OData.csproj index 30281844..81409d47 100644 --- a/src/Microsoft.Restier.Publishers.OData/Microsoft.Restier.Publishers.OData.csproj +++ b/src/Microsoft.Restier.Publishers.OData/Microsoft.Restier.Publishers.OData.csproj @@ -43,6 +43,7 @@ ..\..\packages\Microsoft.AspNet.WebApi.Client.5.2.3\lib\net45\System.Net.Http.Formatting.dll True + Net ..\..\packages\Microsoft.AspNet.WebApi.Core.5.2.3\lib\net45\System.Web.Http.dll @@ -99,7 +100,6 @@ - @@ -126,7 +126,6 @@ - diff --git a/src/Microsoft.Restier.Publishers.OData/Query/RestierQueryBuilder.cs b/src/Microsoft.Restier.Publishers.OData/Query/RestierQueryBuilder.cs index c56dfc58..de16f56b 100644 --- a/src/Microsoft.Restier.Publishers.OData/Query/RestierQueryBuilder.cs +++ b/src/Microsoft.Restier.Publishers.OData/Query/RestierQueryBuilder.cs @@ -67,7 +67,7 @@ public IQueryable BuildQuery() Action handler; if (!this.handlers.TryGetValue(segment.SegmentKind, out handler)) { - throw new NotSupportedException( + throw new NotImplementedException( string.Format(CultureInfo.InvariantCulture, Resources.PathSegmentNotSupported, segment)); } diff --git a/src/Microsoft.Restier.Publishers.OData/RestierController.cs b/src/Microsoft.Restier.Publishers.OData/RestierController.cs index 8c5e2343..2b1a5238 100644 --- a/src/Microsoft.Restier.Publishers.OData/RestierController.cs +++ b/src/Microsoft.Restier.Publishers.OData/RestierController.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. See License.txt in the project root for license information. +extern alias Net; using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Net; @@ -25,11 +27,13 @@ using Microsoft.Restier.Publishers.OData.Batch; using Microsoft.Restier.Publishers.OData.Filters; using Microsoft.Restier.Publishers.OData.Formatter; -using Microsoft.Restier.Publishers.OData.Formatter.Deserialization; using Microsoft.Restier.Publishers.OData.Properties; using Microsoft.Restier.Publishers.OData.Query; using Microsoft.Restier.Publishers.OData.Results; +// This is a must for creating response with correct extension method +using Net::System.Net.Http; + namespace Microsoft.Restier.Publishers.OData { /// @@ -37,10 +41,10 @@ namespace Microsoft.Restier.Publishers.OData /// [RestierFormatting] [RestierExceptionFilter] - public sealed class RestierController : ODataController + public class RestierController : ODataController { - private const string ETagGetterKey = "ETagGetter"; - private const string ETagHeaderKey = "@etag"; + private const string IfMatchKey = "@IfMatchKey"; + private const string IfNoneMatchKey = "@IfNoneMatchKey"; private ApiBase api; private bool shouldReturnCount; @@ -81,6 +85,10 @@ public async Task Get( // Get queryable path builder to builder IQueryable queryable = this.GetQuery(path); + ETag etag; + + // TODO This flag can be removed when Etag isIfNoneMatch is changed to public. + bool isIfNoneMatch; // TODO #365 Do not support additional path segment after function call now if (lastSegment.SegmentKind == ODataSegmentKinds.UnboundFunction) @@ -89,7 +97,7 @@ public async Task Get( Func getParaValueFunc = p => unboundSegment.GetParameterValue(p); result = await ExecuteOperationAsync( getParaValueFunc, unboundSegment.FunctionName, true, null, cancellationToken); - result = ApplyQueryOptions(result, path, true); + result = ApplyQueryOptions(result, path, true, out isIfNoneMatch, out etag); } else { @@ -110,16 +118,16 @@ public async Task Get( result = await ExecuteOperationAsync( getParaValueFunc, boundSeg.Function.Name, true, result, cancellationToken); - result = ApplyQueryOptions(result, path, true); + result = ApplyQueryOptions(result, path, true, out isIfNoneMatch, out etag); } else { - queryable = ApplyQueryOptions(queryable, path, false); + queryable = ApplyQueryOptions(queryable, path, false, out isIfNoneMatch, out etag); result = await ExecuteQuery(queryable, cancellationToken); } } - return this.CreateQueryResponse(result, path.EdmType); + return this.CreateQueryResponse(result, path.EdmType, isIfNoneMatch, etag); } /// @@ -155,6 +163,7 @@ public async Task Post(EdmEntityObject edmEntityObject, Cance entitySet.Name, expectedEntityType.GetClrType(Api), actualEntityType.GetClrType(Api), + ChangeSetItemAction.Insert, null, null, edmEntityObject.CreatePropertyDictionary()); @@ -223,12 +232,19 @@ public async Task Delete(CancellationToken cancellationToken) throw new NotImplementedException(Resources.DeleteOnlySupportedOnEntitySet); } + var propertiesInEtag = await this.GetOriginalValues(entitySet); + if (propertiesInEtag == null) + { + return this.StatusCode((HttpStatusCode)428); + } + DataModificationItem deleteItem = new DataModificationItem( entitySet.Name, path.EdmType.GetClrType(Api), null, + ChangeSetItemAction.Remove, RestierQueryBuilder.GetPathKeyValues(path), - this.GetOriginalValues(), + propertiesInEtag, null); RestierChangeSetProperty changeSetProperty = this.Request.GetChangeSet(); @@ -298,6 +314,8 @@ public async Task PostAction( if (lastSegment.SegmentKind == ODataSegmentKinds.Action) { var queryResult = await ExecuteQuery(queryable, cancellationToken); + + // TODO GitHubIssue#114, need etag check here for single bound entity var boundSeg = (BoundActionPathSegment)lastSegment; result = await ExecuteOperationAsync( getParaValueFunc, boundSeg.Action.Name, false, queryResult, cancellationToken); @@ -310,7 +328,7 @@ public async Task PostAction( return this.Request.CreateResponse(HttpStatusCode.NoContent); } - return this.CreateQueryResponse(result, path.EdmType); + return this.CreateQueryResponse(result, path.EdmType, false, null); } /// @@ -366,6 +384,12 @@ private async Task Update( throw new NotImplementedException(Resources.UpdateOnlySupportedOnEntitySet); } + var propertiesInEtag = await this.GetOriginalValues(entitySet); + if (propertiesInEtag == null) + { + return this.StatusCode((HttpStatusCode)428); + } + // In case of type inheritance, the actual type will be different from entity type // This is only needed for put case, and does not for patch case var expectedEntityType = path.EdmType; @@ -380,8 +404,9 @@ private async Task Update( entitySet.Name, expectedEntityType.GetClrType(Api), actualEntityType.GetClrType(Api), + ChangeSetItemAction.Update, RestierQueryBuilder.GetPathKeyValues(path), - this.GetOriginalValues(), + propertiesInEtag, edmEntityObject.CreatePropertyDictionary()); updateItem.IsFullReplaceUpdateRequest = isFullReplaceUpdate; @@ -403,7 +428,8 @@ private async Task Update( return this.CreateUpdatedODataResult(updateItem.Entity); } - private HttpResponseMessage CreateQueryResponse(IQueryable query, IEdmType edmType) + private HttpResponseMessage CreateQueryResponse( + IQueryable query, IEdmType edmType, bool isIfNoneMatch, ETag etag) { IEdmTypeReference typeReference = GetTypeReference(edmType); @@ -475,8 +501,8 @@ private HttpResponseMessage CreateQueryResponse(IQueryable query, IEdmType edmTy HttpStatusCode.OK, new EntityCollectionResult(query, typeReference, this.Api.Context)); } - var entityResult = new EntityResult(query, typeReference, this.Api.Context); - if (entityResult.Result == null) + var entityResult = query.SingleOrDefault(); + if (entityResult == null) { // TODO GitHubIssue#288: 204 expected when requesting single nav property which has null value // ~/People(nonexistkey) and ~/People(nonexistkey)/BestFriend, expected 404 @@ -487,8 +513,36 @@ private HttpResponseMessage CreateQueryResponse(IQueryable query, IEdmType edmTy Resources.ResourceNotFound)); } - // TODO GitHubIssue#43 : support non-Entity ($select/$value) queries - return this.Request.CreateResponse(HttpStatusCode.OK, entityResult); + // Check the ETag here + if (etag != null) + { + // request with If-Match header, if match, then should return whole content + // request with If-Match header, if not match, then should return 412 + // request with If-None-Match header, if match, then should return 304 + // request with If-None-Match header, if not match, then should return whole content + etag.EntityType = query.ElementType; + query = etag.ApplyTo(query); + entityResult = query.SingleOrDefault(); + if (entityResult == null && !isIfNoneMatch) + { + return this.Request.CreateResponse(HttpStatusCode.PreconditionFailed); + } + else if (entityResult == null) + { + return this.Request.CreateResponse(HttpStatusCode.NotModified); + } + } + + // Using reflection to create response for single entity so passed in parameter is not object type, + // but will be type of real entity type, then EtagMessageHandler can be used to set ETAG header + // when response is single entity. + // There are three HttpRequestMessageExtensions class defined in different assembles + var genericMethod = typeof(System.Net.Http.HttpRequestMessageExtensions).GetMethods() + .Where(m => m.Name == "CreateResponse" && m.GetParameters().Length == 3); + var method = genericMethod.FirstOrDefault().MakeGenericMethod(query.ElementType); + response = method.Invoke(null, new object[] { this.Request, HttpStatusCode.OK, entityResult }) + as HttpResponseMessage; + return response; } private IQueryable GetQuery(ODataPath path) @@ -501,8 +555,13 @@ private IQueryable GetQuery(ODataPath path) return queryable; } - private IQueryable ApplyQueryOptions(IQueryable queryable, ODataPath path, bool applyCount) + private IQueryable ApplyQueryOptions( + IQueryable queryable, ODataPath path, bool applyCount, out bool isIfNoneMatch, out ETag etag) { + // ETAG IsIfNoneMatch is changed to public access, this flag can be removed. + isIfNoneMatch = false; + etag = null; + if (this.shouldWriteRawValue) { // Query options don't apply to $value. @@ -514,6 +573,17 @@ private IQueryable ApplyQueryOptions(IQueryable queryable, ODataPath path, bool new ODataQueryContext(properties.Model, queryable.ElementType, path); ODataQueryOptions queryOptions = new ODataQueryOptions(queryContext, this.Request); + // Get etag for query request + if (queryOptions.IfMatch != null) + { + etag = queryOptions.IfMatch; + } + else if (queryOptions.IfNoneMatch != null) + { + isIfNoneMatch = true; + etag = queryOptions.IfNoneMatch; + } + // TODO GitHubIssue#41 : Ensure stable ordering for query ODataQuerySettings settings = Api.Context.GetApiService(); @@ -559,8 +629,6 @@ private async Task ExecuteQuery(IQueryable queryable, CancellationTo }; QueryResult queryResult = await Api.QueryAsync(queryRequest, cancellationToken); - - this.Request.Properties[ETagGetterKey] = this.Api.Context.GetProperty(ETagGetterKey); var result = queryResult.Results.AsQueryable(); return result; } @@ -596,7 +664,7 @@ private Task ExecuteOperationAsync( return result; } - private IReadOnlyDictionary GetOriginalValues() + private async Task> GetOriginalValues(IEdmEntitySet entitySet) { Dictionary originalValues = new Dictionary(); @@ -606,7 +674,26 @@ private IReadOnlyDictionary GetOriginalValues() ETag etag = this.Request.GetETag(etagHeaderValue); etag.ApplyTo(originalValues); - originalValues.Add(ETagHeaderKey, etagHeaderValue.Tag); + originalValues.Add(IfMatchKey, etagHeaderValue.Tag); + return originalValues; + } + + etagHeaderValue = this.Request.Headers.IfNoneMatch.SingleOrDefault(); + if (etagHeaderValue != null) + { + ETag etag = this.Request.GetETag(etagHeaderValue); + etag.ApplyTo(originalValues); + + originalValues.Add(IfNoneMatchKey, etagHeaderValue.Tag); + return originalValues; + } + + // return 428(Precondition Required) if entity requires concurrency check. + var model = await this.Api.Context.GetModelAsync(); + bool needEtag = model.IsConcurrencyCheckEnabled(entitySet); + if (needEtag) + { + return null; } return originalValues; diff --git a/src/Microsoft.Restier.Publishers.OData/Results/EntityResult.cs b/src/Microsoft.Restier.Publishers.OData/Results/EntityResult.cs deleted file mode 100644 index cea3bb6d..00000000 --- a/src/Microsoft.Restier.Publishers.OData/Results/EntityResult.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See License.txt in the project root for license information. - -using System.Linq; -using Microsoft.OData.Edm; -using Microsoft.Restier.Core; - -namespace Microsoft.Restier.Publishers.OData.Results -{ - /// - /// Represents a single entity instance being returned from an action. - /// - internal class EntityResult : BaseSingleResult - { - /// - /// Initializes a new instance of the class. - /// - /// The query that returns an entity. - /// The EDM type reference of the entity. - /// The context where the action is executed. - public EntityResult(IQueryable query, IEdmTypeReference edmType, ApiContext context) - : base(query, edmType, context) - { - } - } -} diff --git a/src/Microsoft.Restier.Publishers.OData/Routing/RestierRoutingConvention.cs b/src/Microsoft.Restier.Publishers.OData/Routing/RestierRoutingConvention.cs index c4e15409..81da4d04 100644 --- a/src/Microsoft.Restier.Publishers.OData/Routing/RestierRoutingConvention.cs +++ b/src/Microsoft.Restier.Publishers.OData/Routing/RestierRoutingConvention.cs @@ -2,13 +2,11 @@ // Licensed under the MIT License. See License.txt in the project root for license information. using System; -using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Web.Http; using System.Web.Http.Controllers; using System.Web.Http.Routing; -using System.Web.OData.Extensions; using System.Web.OData.Routing; using System.Web.OData.Routing.Conventions; using Microsoft.Restier.Core; diff --git a/test/Microsoft.Restier.Providers.EntityFramework.Tests/ChangeSetPreparerTests.cs b/test/Microsoft.Restier.Providers.EntityFramework.Tests/ChangeSetPreparerTests.cs index f24ca792..0ab3ca21 100644 --- a/test/Microsoft.Restier.Providers.EntityFramework.Tests/ChangeSetPreparerTests.cs +++ b/test/Microsoft.Restier.Providers.EntityFramework.Tests/ChangeSetPreparerTests.cs @@ -23,6 +23,7 @@ public async Task ComplexTypeUpdate() "Readers", typeof(Person), null, + ChangeSetItemAction.Update, new Dictionary { { "Id", new Guid("53162782-EA1B-4712-AF26-8AA1D2AC0461") } }, new Dictionary(), new Dictionary { { "Addr", new Dictionary { { "Zip", "332" } } } }); diff --git a/test/Microsoft.Restier.TestCommon/PublicApi.bsl b/test/Microsoft.Restier.TestCommon/PublicApi.bsl index 879a52b2..88031702 100644 --- a/test/Microsoft.Restier.TestCommon/PublicApi.bsl +++ b/test/Microsoft.Restier.TestCommon/PublicApi.bsl @@ -3,7 +3,7 @@ public abstract class Microsoft.Restier.Core.ApiBase : IDisposable { Microsoft.Restier.Core.ApiConfiguration Configuration { protected get; } Microsoft.Restier.Core.ApiContext Context { public get; } - bool IsDisposed { [CompilerGeneratedAttribute(),]protected get; } + bool IsDisposed { [CompilerGeneratedAttribute(),]public get; } [ CLSCompliantAttribute(), @@ -287,6 +287,24 @@ public class Microsoft.Restier.Core.InvocationContext { Microsoft.Restier.Core.ApiContext ApiContext { [CompilerGeneratedAttribute(),]public get; } } +[ +SerializableAttribute(), +] +public class Microsoft.Restier.Core.Exceptions.PreconditionFailedException : System.Exception, _Exception, ISerializable { + public PreconditionFailedException () + public PreconditionFailedException (string message) + public PreconditionFailedException (string message, System.Exception innerException) +} + +[ +SerializableAttribute(), +] +public class Microsoft.Restier.Core.Exceptions.ResourceNotFoundException : System.Exception, _Exception, ISerializable { + public ResourceNotFoundException () + public ResourceNotFoundException (string message) + public ResourceNotFoundException (string message, System.Exception innerException) +} + public interface Microsoft.Restier.Core.Model.IModelBuilder { System.Threading.Tasks.Task`1[[Microsoft.OData.Edm.IEdmModel]] GetModelAsync (Microsoft.Restier.Core.Model.ModelContext context, System.Threading.CancellationToken cancellationToken) } @@ -466,7 +484,7 @@ public class Microsoft.Restier.Core.Submit.ChangeSetValidationException : System } public class Microsoft.Restier.Core.Submit.DataModificationItem : Microsoft.Restier.Core.Submit.ChangeSetItem { - public DataModificationItem (string entitySetName, System.Type expectedEntityType, System.Type actualEntityType, System.Collections.Generic.IReadOnlyDictionary`2[[System.String],[System.Object]] entityKey, System.Collections.Generic.IReadOnlyDictionary`2[[System.String],[System.Object]] originalValues, System.Collections.Generic.IReadOnlyDictionary`2[[System.String],[System.Object]] localValues) + public DataModificationItem (string entitySetName, System.Type expectedEntityType, System.Type actualEntityType, Microsoft.Restier.Core.Submit.ChangeSetItemAction action, System.Collections.Generic.IReadOnlyDictionary`2[[System.String],[System.Object]] entityKey, System.Collections.Generic.IReadOnlyDictionary`2[[System.String],[System.Object]] originalValues, System.Collections.Generic.IReadOnlyDictionary`2[[System.String],[System.Object]] localValues) System.Type ActualEntityType { [CompilerGeneratedAttribute(),]public get; } Microsoft.Restier.Core.Submit.ChangeSetItemAction ChangeSetItemAction { [CompilerGeneratedAttribute(),]public get; [CompilerGeneratedAttribute(),]public set; } @@ -474,19 +492,17 @@ public class Microsoft.Restier.Core.Submit.DataModificationItem : Microsoft.Rest System.Collections.Generic.IReadOnlyDictionary`2[[System.String],[System.Object]] EntityKey { [CompilerGeneratedAttribute(),]public get; } string EntitySetName { [CompilerGeneratedAttribute(),]public get; } System.Type ExpectedEntityType { [CompilerGeneratedAttribute(),]public get; } - bool IsDeleteRequest { public get; } bool IsFullReplaceUpdateRequest { [CompilerGeneratedAttribute(),]public get; [CompilerGeneratedAttribute(),]public set; } - bool IsNewRequest { public get; } - bool IsUpdateRequest { public get; } System.Collections.Generic.IReadOnlyDictionary`2[[System.String],[System.Object]] LocalValues { [CompilerGeneratedAttribute(),]public get; } System.Collections.Generic.IReadOnlyDictionary`2[[System.String],[System.Object]] OriginalValues { [CompilerGeneratedAttribute(),]public get; } System.Collections.Generic.IReadOnlyDictionary`2[[System.String],[System.Object]] ServerValues { [CompilerGeneratedAttribute(),]public get; } + public System.Linq.IQueryable ApplyEtag (System.Linq.IQueryable query) public System.Linq.IQueryable ApplyTo (System.Linq.IQueryable query) } public class Microsoft.Restier.Core.Submit.DataModificationItem`1 : Microsoft.Restier.Core.Submit.DataModificationItem { - public DataModificationItem`1 (string entitySetName, System.Type expectedEntityType, System.Type actualEntityType, System.Collections.Generic.IReadOnlyDictionary`2[[System.String],[System.Object]] entityKey, System.Collections.Generic.IReadOnlyDictionary`2[[System.String],[System.Object]] originalValues, System.Collections.Generic.IReadOnlyDictionary`2[[System.String],[System.Object]] localValues) + public DataModificationItem`1 (string entitySetName, System.Type expectedEntityType, System.Type actualEntityType, Microsoft.Restier.Core.Submit.ChangeSetItemAction action, System.Collections.Generic.IReadOnlyDictionary`2[[System.String],[System.Object]] entityKey, System.Collections.Generic.IReadOnlyDictionary`2[[System.String],[System.Object]] originalValues, System.Collections.Generic.IReadOnlyDictionary`2[[System.String],[System.Object]] localValues) T Entity { public get; public set; } } @@ -540,17 +556,11 @@ public sealed class Microsoft.Restier.Publishers.OData.ServiceCollectionExtensio public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddODataServices (Microsoft.Extensions.DependencyInjection.IServiceCollection services) } -public class Microsoft.Restier.Publishers.OData.RestierPayloadValueConverter : Microsoft.OData.Core.ODataPayloadValueConverter { - public RestierPayloadValueConverter () - - public virtual object ConvertToPayloadValue (object value, Microsoft.OData.Edm.IEdmTypeReference edmTypeReference) -} - [ RestierExceptionFilterAttribute(), RestierFormattingAttribute(), ] -public sealed class Microsoft.Restier.Publishers.OData.RestierController : System.Web.OData.ODataController, IDisposable, IHttpController { +public class Microsoft.Restier.Publishers.OData.RestierController : System.Web.OData.ODataController, IDisposable, IHttpController { public RestierController () [ @@ -558,6 +568,7 @@ public sealed class Microsoft.Restier.Publishers.OData.RestierController : Syste ] public System.Threading.Tasks.Task`1[[System.Web.Http.IHttpActionResult]] Delete (System.Threading.CancellationToken cancellationToken) + protected virtual void Dispose (bool disposing) [ AsyncStateMachineAttribute(), ] @@ -584,6 +595,12 @@ public sealed class Microsoft.Restier.Publishers.OData.RestierController : Syste public System.Threading.Tasks.Task`1[[System.Web.Http.IHttpActionResult]] Put (System.Web.OData.EdmEntityObject edmEntityObject, System.Threading.CancellationToken cancellationToken) } +public class Microsoft.Restier.Publishers.OData.RestierPayloadValueConverter : Microsoft.OData.Core.ODataPayloadValueConverter { + public RestierPayloadValueConverter () + + public virtual object ConvertToPayloadValue (object value, Microsoft.OData.Edm.IEdmTypeReference edmTypeReference) +} + public class Microsoft.Restier.Publishers.OData.Batch.RestierBatchChangeSetRequestItem : System.Web.OData.Batch.ChangeSetRequestItem, IDisposable { public RestierBatchChangeSetRequestItem (System.Collections.Generic.IEnumerable`1[[System.Net.Http.HttpRequestMessage]] requests, System.Func`1[[Microsoft.Restier.Core.ApiBase]] apiFactory) @@ -670,13 +687,6 @@ public class Microsoft.Restier.Publishers.OData.Formatter.Serialization.RestierC public virtual void WriteObject (object graph, System.Type type, Microsoft.OData.Core.ODataMessageWriter messageWriter, System.Web.OData.Formatter.Serialization.ODataSerializerContext writeContext) } -public class Microsoft.Restier.Publishers.OData.Formatter.Serialization.RestierEntityTypeSerializer : System.Web.OData.Formatter.Serialization.ODataEntityTypeSerializer { - public RestierEntityTypeSerializer (System.Web.OData.Formatter.Serialization.ODataSerializerProvider provider) - - public virtual string CreateETag (System.Web.OData.EntityInstanceContext entityInstanceContext) - public virtual void WriteObject (object graph, System.Type type, Microsoft.OData.Core.ODataMessageWriter messageWriter, System.Web.OData.Formatter.Serialization.ODataSerializerContext writeContext) -} - public class Microsoft.Restier.Publishers.OData.Formatter.Serialization.RestierEnumSerializer : System.Web.OData.Formatter.Serialization.ODataEnumSerializer { public RestierEnumSerializer () diff --git a/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Northwind.Tests/SaveTests.cs b/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Northwind.Tests/SaveTests.cs index ef93e0da..6c30d344 100644 --- a/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Northwind.Tests/SaveTests.cs +++ b/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Northwind.Tests/SaveTests.cs @@ -60,6 +60,7 @@ public async Task TestEntityFilterReturnsTask() "Customers", typeof(Customer), null, + ChangeSetItemAction.Insert, null, null, new Dictionary() diff --git a/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Tests/Microsoft.OData.Service.Sample.Tests.csproj b/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Tests/Microsoft.OData.Service.Sample.Tests.csproj index dc30f111..ab1eb31b 100644 --- a/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Tests/Microsoft.OData.Service.Sample.Tests.csproj +++ b/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Tests/Microsoft.OData.Service.Sample.Tests.csproj @@ -34,6 +34,7 @@ + diff --git a/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Tests/TrippinE2EEtagTestCases.cs b/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Tests/TrippinE2EEtagTestCases.cs new file mode 100644 index 00000000..321a3fc3 --- /dev/null +++ b/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Tests/TrippinE2EEtagTestCases.cs @@ -0,0 +1,396 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.OData.Client; +using Microsoft.OData.Service.Sample.Trippin.Models; +using Xunit; + +namespace Microsoft.OData.Service.Sample.Tests +{ + public class TrippinE2EEtagTestCases : TrippinE2ETestBase + { + [Fact] + public void EtagAnnotationTesting() + { + this.TestGetPayloadContains("Flights", "@odata.etag"); + this.TestGetPayloadContains("Flights?$select=FlightId", "@odata.etag"); + this.TestGetPayloadContains("Flights(1)", "@odata.etag"); + } + + [Fact] + public void IfMatchCRUDTest() + { + this.TestClientContext.MergeOption = MergeOption.OverwriteChanges; + + // Post an entity + Flight flight = new Flight() + { + ConfirmationCode = "JH44444", + StartsAt = DateTimeOffset.Parse("2016-01-04T17:55:00+08:00"), + EndsAt = DateTimeOffset.Parse("2016-01-04T20:45:00+08:00"), + Duration = TimeSpan.Parse("2:50"), + FlightNumber = "AA4035", + FromId = "KJFK", + ToId = "KORD", + AirlineId = "AA" + }; + + this.TestClientContext.AddToFlights(flight); + this.TestClientContext.SaveChanges(); + int flightId = flight.FlightId; + var code = flight.ConfirmationCode; + + string etag = null; + int statusCode = -1; + EventHandler statusCodeHandler = (sender, eventArgs) => + { + etag = eventArgs.ResponseMessage.GetHeader("ETag"); + statusCode = eventArgs.ResponseMessage.StatusCode; + }; + this.TestClientContext.ReceivingResponse += statusCodeHandler; + + // Retrieve a none match etag + flight = this.TestClientContext.Flights.ByKey(new Dictionary() { { "flightId", 1 } }).GetValue(); + var nonMatchEtag = etag; + + // Request single entity, the header should return Etag + flight = this.TestClientContext.Flights.ByKey(new Dictionary() { { "flightId", flightId } }).GetValue(); + Assert.Equal(code, flight.ConfirmationCode); + Assert.NotNull(etag); + Assert.Equal(200, statusCode); + + // Test If-Match header and the header is not matched, should return 412. + EventHandler sendRequestEvent = (sender, eventArgs) => + { + eventArgs.RequestMessage.SetHeader("If-Match", nonMatchEtag); + }; + this.TestClientContext.SendingRequest2 += sendRequestEvent; + try + { + flight = this.TestClientContext.Flights.ByKey(new Dictionary() { { "flightId", flightId } }).GetValue(); + } + catch (DataServiceQueryException e) + { + } + Assert.Equal(412, statusCode); + + // Test If-Match header and the header is matched, should return the entity. + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + sendRequestEvent = (sender, eventArgs) => + { + eventArgs.RequestMessage.SetHeader("If-Match", etag ); + }; + this.TestClientContext.SendingRequest2 += sendRequestEvent; + flight = this.TestClientContext.Flights.ByKey(new Dictionary() { { "flightId", flightId } }).GetValue(); + Assert.Equal(code, flight.ConfirmationCode); + Assert.Equal(200, statusCode); + var oldEtag = etag; + + // Update the Entity without If-Match header, should return 428 + // If this is not removed, client will auto add If-Match header + var descriptor = this.TestClientContext.GetEntityDescriptor(flight); + descriptor.ETag = null; + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + code = "JH33333"; + flight.ConfirmationCode = code; + this.TestClientContext.UpdateObject(flight); + try + { + this.TestClientContext.SaveChanges(); + } + catch (DataServiceRequestException e) + { + } + this.TestClientContext.Detach(flight); + Assert.Equal(428, statusCode); + + // Query to get etag updated + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + flight = this.TestClientContext.Flights.ByKey(new Dictionary() { { "flightId", flightId } }).GetValue(); + + // Update the Entity with If-Match not match, should return 412 + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + sendRequestEvent = (sender, eventArgs) => + { + eventArgs.RequestMessage.SetHeader("If-Match", nonMatchEtag); + }; + this.TestClientContext.SendingRequest2 += sendRequestEvent; + code = "JH33333"; + flight.ConfirmationCode = code; + this.TestClientContext.UpdateObject(flight); + try + { + this.TestClientContext.SaveChanges(); + } + catch (DataServiceRequestException e) + { + } + this.TestClientContext.Detach(flight); + Assert.Equal(412, statusCode); + + // Query the flight again and etag should not be updated. + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + flight = this.TestClientContext.Flights.ByKey(new Dictionary() { { "flightId", flightId } }).GetValue(); + Assert.NotEqual(code, flight.ConfirmationCode); + Assert.Equal(200, statusCode); + Assert.Equal(oldEtag, etag); + + // Update the Entity with If-Match matches, should return 204 + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + sendRequestEvent = (sender, eventArgs) => + { + eventArgs.RequestMessage.SetHeader("If-Match", etag); + }; + this.TestClientContext.SendingRequest2 += sendRequestEvent; + code = "JH33333"; + flight.ConfirmationCode = code; + this.TestClientContext.UpdateObject(flight); + this.TestClientContext.SaveChanges(); + this.TestClientContext.Detach(flight); + Assert.Equal(204, statusCode); + + // Query the flight again and etag should be updated. + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + flight = this.TestClientContext.Flights.ByKey(new Dictionary() { { "flightId", flightId } }).GetValue(); + Assert.Equal(code, flight.ConfirmationCode); + Assert.Equal(200, statusCode); + Assert.NotEqual(oldEtag, etag); + + // Delete the Entity with If-Match does not match, should return 412 + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + sendRequestEvent = (sender, eventArgs) => + { + eventArgs.RequestMessage.SetHeader("If-Match", nonMatchEtag); + }; + this.TestClientContext.SendingRequest2 += sendRequestEvent; + this.TestClientContext.DeleteObject(flight); + try + { + this.TestClientContext.SaveChanges(); + } + catch (DataServiceRequestException e) + { + } + Assert.Equal(412, statusCode); + + // Query to get etag updated + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + flight = this.TestClientContext.Flights.ByKey(new Dictionary() { { "flightId", flightId } }).GetValue(); + + // Delete the Entity with If-Match matches, should return 204 + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + sendRequestEvent = (sender, eventArgs) => + { + eventArgs.RequestMessage.SetHeader("If-Match", etag); + }; + this.TestClientContext.SendingRequest2 += sendRequestEvent; + this.TestClientContext.DeleteObject(flight); + this.TestClientContext.SaveChanges(); + Assert.Equal(204, statusCode); + + // Query the flight again and entity does not exist. + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + try + { + flight = this.TestClientContext.Flights.ByKey(new Dictionary() { { "flightId", flightId } }).GetValue(); + } + catch (DataServiceQueryException e) + { + } + Assert.Equal(404, statusCode); + } + + [Fact] + public void IfNoneMatchCRUDTest() + { + this.TestClientContext.MergeOption = MergeOption.OverwriteChanges; + + // Post an entity + Flight flight = new Flight() + { + ConfirmationCode = "JH44444", + StartsAt = DateTimeOffset.Parse("2016-01-04T17:55:00+08:00"), + EndsAt = DateTimeOffset.Parse("2016-01-04T20:45:00+08:00"), + Duration = TimeSpan.Parse("2:50"), + FlightNumber = "AA4035", + FromId = "KJFK", + ToId = "KORD", + AirlineId = "AA" + }; + + this.TestClientContext.AddToFlights(flight); + this.TestClientContext.SaveChanges(); + int flightId = flight.FlightId; + var code = flight.ConfirmationCode; + + string etag = null; + int statusCode = -1; + EventHandler statusCodeHandler = (sender, eventArgs) => + { + etag = eventArgs.ResponseMessage.GetHeader("ETag"); + statusCode = eventArgs.ResponseMessage.StatusCode; + }; + this.TestClientContext.ReceivingResponse += statusCodeHandler; + + // Retrieve a none match etag + flight = this.TestClientContext.Flights.ByKey(new Dictionary() { { "flightId", 1 } }).GetValue(); + var nonMatchEtag = etag; + + // Request single entity, the header should return Etag + flight = this.TestClientContext.Flights.ByKey(new Dictionary() { { "flightId", flightId } }).GetValue(); + Assert.Equal(code, flight.ConfirmationCode); + Assert.NotNull(etag); + Assert.Equal(200, statusCode); + + // Test If-None-Match header and the header is matched, should return 304 (not modified). + EventHandler sendRequestEvent = (sender, eventArgs) => + { + eventArgs.RequestMessage.SetHeader("If-None-Match", etag); + }; + this.TestClientContext.SendingRequest2 += sendRequestEvent; + try + { + flight = this.TestClientContext.Flights.ByKey(new Dictionary() {{"flightId", flightId}}).GetValue(); + } + catch (DataServiceQueryException e) + { + } + Assert.Equal(304, statusCode); + + // Test If-None-Match header and the header is not matched, should return the entity. + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + sendRequestEvent = (sender, eventArgs) => + { + eventArgs.RequestMessage.SetHeader("If-None-Match", nonMatchEtag); + }; + this.TestClientContext.SendingRequest2 += sendRequestEvent; + flight = this.TestClientContext.Flights.ByKey(new Dictionary() { { "flightId", flightId } }).GetValue(); + Assert.Equal(code, flight.ConfirmationCode); + Assert.Equal(200, statusCode); + var oldEtag = etag; + + // Update the Entity without If-None-Match header, should return 428 + // If this is not removed, client will auto add If-Match header + var descriptor = this.TestClientContext.GetEntityDescriptor(flight); + descriptor.ETag = null; + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + code = "JH3333333"; + flight.ConfirmationCode = code; + this.TestClientContext.UpdateObject(flight); + + try + { + this.TestClientContext.SaveChanges(); + } + catch (DataServiceRequestException e) + { + } + this.TestClientContext.Detach(flight); + Assert.Equal(428, statusCode); + + // Query to get etag updated + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + flight = this.TestClientContext.Flights.ByKey(new Dictionary() { { "flightId", flightId } }).GetValue(); + + // Update the Entity with If-None-Match matches, should return 412 + // If this is not removed, client will auto add If-Match header + descriptor = this.TestClientContext.GetEntityDescriptor(flight); + descriptor.ETag = null; + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + sendRequestEvent = (sender, eventArgs) => + { + eventArgs.RequestMessage.SetHeader("If-None-Match", etag); + }; + this.TestClientContext.SendingRequest2 += sendRequestEvent; + code = "JH3333333"; + flight.ConfirmationCode = code; + this.TestClientContext.UpdateObject(flight); + + try + { + this.TestClientContext.SaveChanges(); + } + catch (DataServiceRequestException e) + { + } + this.TestClientContext.Detach(flight); + Assert.Equal(412, statusCode); + + // Query the flight again and etag should not be updated. + flight = this.TestClientContext.Flights.ByKey(new Dictionary() { { "flightId", flightId } }).GetValue(); + Assert.NotEqual(code, flight.ConfirmationCode); + Assert.Equal(200, statusCode); + Assert.Equal(oldEtag, etag); + + // Update the Entity with If-None-Match does not match, should return 204 + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + sendRequestEvent = (sender, eventArgs) => + { + eventArgs.RequestMessage.SetHeader("If-None-Match", nonMatchEtag); + }; + this.TestClientContext.SendingRequest2 += sendRequestEvent; + code = "JH3333333"; + flight.ConfirmationCode = code; + this.TestClientContext.UpdateObject(flight); + this.TestClientContext.SaveChanges(); + this.TestClientContext.Detach(flight); + Assert.Equal(204, statusCode); + + // Query the flight again and etag should be updated. + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + flight = this.TestClientContext.Flights.ByKey(new Dictionary() { { "flightId", flightId } }).GetValue(); + Assert.Equal(code, flight.ConfirmationCode); + Assert.Equal(200, statusCode); + Assert.NotEqual(oldEtag, etag); + + // Delete the Entity with If-None-Match matches, should return 412 + // If this is not removed, client will auto add If-Match header + descriptor = this.TestClientContext.GetEntityDescriptor(flight); + descriptor.ETag = null; + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + sendRequestEvent = (sender, eventArgs) => + { + eventArgs.RequestMessage.SetHeader("If-None-Match", etag); + }; + this.TestClientContext.SendingRequest2 += sendRequestEvent; + this.TestClientContext.DeleteObject(flight); + try + { + this.TestClientContext.SaveChanges(); + } + catch (DataServiceRequestException e) + { + } + Assert.Equal(412, statusCode); + + // Query to get etag updated + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + flight = this.TestClientContext.Flights.ByKey(new Dictionary() { { "flightId", flightId } }).GetValue(); + + // Delete the Entity with If-None-Match does not match, should return 204 + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + sendRequestEvent = (sender, eventArgs) => + { + eventArgs.RequestMessage.SetHeader("If-None-Match", nonMatchEtag); + }; + this.TestClientContext.SendingRequest2 += sendRequestEvent; + this.TestClientContext.DeleteObject(flight); + this.TestClientContext.SaveChanges(); + Assert.Equal(204, statusCode); + + // Query the flight again and entiy does not exist. + this.TestClientContext.SendingRequest2 -= sendRequestEvent; + try + { + flight = this.TestClientContext.Flights.ByKey(new Dictionary() { { "flightId", flightId } }).GetValue(); + } + catch (DataServiceQueryException e) + { + } + Assert.Equal(404, statusCode); + } + } +} diff --git a/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Tests/TrippinE2EOnFilterTestCases.cs b/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Tests/TrippinE2EOnFilterTestCases.cs index ece46ef3..44b51bfa 100644 --- a/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Tests/TrippinE2EOnFilterTestCases.cs +++ b/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Tests/TrippinE2EOnFilterTestCases.cs @@ -67,7 +67,7 @@ public void TestFilteredEntity() // Nested Expand case [InlineData("Staffs?$expand=PeerStaffs($expand=Conferences)", "OnfilterNestedExpand1")] [InlineData("Staffs?$expand=Conferences($expand=Sponsors)", "OnfilterNestedExpand2")] - public void DerivedTypeQuery(string uriStringAfterServiceRoot, string baselineFileName) + public void OnFilterQueryTest(string uriStringAfterServiceRoot, string baselineFileName) { Action validationAction = content => VerifyBaseline(baselineFileName, content); this.TestGetPayload(uriStringAfterServiceRoot, validationAction); diff --git a/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Trippin/App_Start/WebApiConfig.cs b/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Trippin/App_Start/WebApiConfig.cs index 189d87b3..1916fa09 100644 --- a/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Trippin/App_Start/WebApiConfig.cs +++ b/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Trippin/App_Start/WebApiConfig.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. See License.txt in the project root for license information. using System.Web.Http; +using System.Web.OData; using Microsoft.OData.Service.Sample.Trippin.Api; using Microsoft.Restier.Publishers.OData.Batch; using Microsoft.Restier.Publishers.OData.Routing; @@ -13,6 +14,7 @@ public static class WebApiConfig public static void Register(HttpConfiguration config) { RegisterTrippin(config, GlobalConfiguration.DefaultServer); + config.MessageHandlers.Add(new ETagMessageHandler()); } public static async void RegisterTrippin( diff --git a/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Trippin/Models/Flight.cs b/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Trippin/Models/Flight.cs index ac183bd9..220560ba 100644 --- a/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Trippin/Models/Flight.cs +++ b/test/ODataEndToEnd/Microsoft.OData.Service.Sample.Trippin/Models/Flight.cs @@ -2,20 +2,26 @@ // Licensed under the MIT License. See License.txt in the project root for license information. using System; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace Microsoft.OData.Service.Sample.Trippin.Models { public class Flight { + [ConcurrencyCheck] public int FlightId { get; set; } + [ConcurrencyCheck] public string ConfirmationCode { get; set; } + [ConcurrencyCheck] public DateTimeOffset StartsAt { get; set; } + [ConcurrencyCheck] public DateTimeOffset EndsAt { get; set; } + [ConcurrencyCheck] public TimeSpan Duration { get; set; } public string SeatNumber { get; set; }