diff --git a/src/Microsoft.OData.Core/Batch/ODataBatchUtils.cs b/src/Microsoft.OData.Core/Batch/ODataBatchUtils.cs index b6061b568a..63e650c8d3 100644 --- a/src/Microsoft.OData.Core/Batch/ODataBatchUtils.cs +++ b/src/Microsoft.OData.Core/Batch/ODataBatchUtils.cs @@ -110,12 +110,13 @@ internal static ODataReadStream CreateBatchOperationReadStream( /// A new instance. internal static ODataWriteStream CreateBatchOperationWriteStream( Stream outputStream, - IODataStreamListener operationListener) + IODataStreamListener operationListener, + bool synchronous = true) { Debug.Assert(outputStream != null, "outputStream != null"); Debug.Assert(operationListener != null, "operationListener != null"); - return new ODataWriteStream(outputStream, operationListener); + return new ODataWriteStream(outputStream, operationListener, synchronous); } /// diff --git a/src/Microsoft.OData.Core/Batch/ODataBatchWriter.cs b/src/Microsoft.OData.Core/Batch/ODataBatchWriter.cs index 711b5fc037..aa27b05c3d 100644 --- a/src/Microsoft.OData.Core/Batch/ODataBatchWriter.cs +++ b/src/Microsoft.OData.Core/Batch/ODataBatchWriter.cs @@ -56,7 +56,7 @@ public abstract class ODataBatchWriter : IODataStreamListener, IODataOutputInStr /// /// Whether the writer is currently processing inside a changeset or atomic group. /// - private bool isInChangset; + private bool isInChangeset; /// The batch-specific URL converter that stores the content IDs found in a changeset and supports resolving cross-referencing URLs. internal readonly ODataBatchPayloadUriConverter payloadUriConverter; @@ -167,10 +167,11 @@ public void WriteStartBatch() /// Asynchronously starts a new batch; can be only called once and as first call. /// A task instance that represents the asynchronous write operation. - public Task WriteStartBatchAsync() + public async Task WriteStartBatchAsync() { this.VerifyCanWriteStartBatch(false); - return TaskUtils.GetTaskForSynchronousOperation(this.WriteStartBatchImplementation); + await this.WriteStartBatchImplementationAsync() + .ConfigureAwait(false); } /// Ends a batch; can only be called after WriteStartBatch has been called and if no other active changeset or operation exist. @@ -185,13 +186,13 @@ public void WriteEndBatch() /// Asynchronously ends a batch; can only be called after WriteStartBatch has been called and if no other active change set or operation exist. /// A task instance that represents the asynchronous write operation. - public Task WriteEndBatchAsync() + public async Task WriteEndBatchAsync() { this.VerifyCanWriteEndBatch(false); - return TaskUtils.GetTaskForSynchronousOperation(this.WriteEndBatchImplementation) - + await this.WriteEndBatchImplementationAsync() // Note that we intentionally go through the public API so that if the Flush fails the writer moves to the Error state. - .FollowOnSuccessWithTask(task => this.FlushAsync()); + .FollowOnSuccessWithTask(task => this.FlushAsync()) + .ConfigureAwait(false); } /// @@ -231,13 +232,14 @@ public Task WriteStartChangesetAsync() /// The change set Id of the batch request. Cannot be null. /// A task instance that represents the asynchronous write operation. /// Thrown if the is null. - public Task WriteStartChangesetAsync(string changesetId) + public async Task WriteStartChangesetAsync(string changesetId) { ExceptionUtils.CheckArgumentNotNull(changesetId, "changesetId"); this.VerifyCanWriteStartChangeset(false); - return TaskUtils.GetTaskForSynchronousOperation(() => this.WriteStartChangesetImplementation(changesetId)) - .FollowOnSuccessWith(t => this.FinishWriteStartChangeset()); + await this.WriteStartChangesetImplementationAsync(changesetId) + .FollowOnSuccessWith(t => this.FinishWriteStartChangeset()) + .ConfigureAwait(false); } /// Ends an active changeset; this can only be called after WriteStartChangeset and only once for each changeset. @@ -250,11 +252,12 @@ public void WriteEndChangeset() /// Asynchronously ends an active change set; this can only be called after WriteStartChangeset and only once for each change set. /// A task instance that represents the asynchronous write operation. - public Task WriteEndChangesetAsync() + public async Task WriteEndChangesetAsync() { this.VerifyCanWriteEndChangeset(false); - return TaskUtils.GetTaskForSynchronousOperation(this.WriteEndChangesetImplementation) - .FollowOnSuccessWith(t => this.FinishWriteEndChangeset()); + await this.WriteEndChangesetImplementationAsync() + .FollowOnSuccessWith(t => this.FinishWriteEndChangeset()) + .ConfigureAwait(false); } /// Creates an for writing an operation of a batch request. @@ -336,13 +339,12 @@ public Task CreateOperationRequestMessageAsyn /// The format of operation Request-URI, which could be AbsoluteUri, AbsoluteResourcePathAndHost, or RelativeResourcePath. /// The prerequisite request ids of this request. /// A task that when completed returns the newly created operation request message. - public Task CreateOperationRequestMessageAsync(string method, Uri uri, string contentId, + public async Task CreateOperationRequestMessageAsync(string method, Uri uri, string contentId, BatchPayloadUriOption payloadUriOption, IList dependsOnIds) { this.VerifyCanCreateOperationRequestMessage(false, method, uri, contentId); - - return TaskUtils.GetTaskForSynchronousOperation(() => - CreateOperationRequestMessageInternal(method, uri, contentId, payloadUriOption, dependsOnIds)); + return await this.CreateOperationRequestMessageInternalAsync( + method, uri, contentId, payloadUriOption, dependsOnIds).ConfigureAwait(false); } /// Creates a message for writing an operation of a batch response. @@ -357,11 +359,11 @@ public ODataBatchOperationResponseMessage CreateOperationResponseMessage(string /// Asynchronously creates an for writing an operation of a batch response. /// The Content-ID value to write in ChangeSet head. /// A task that when completed returns the newly created operation response message. - public Task CreateOperationResponseMessageAsync(string contentId) + public async Task CreateOperationResponseMessageAsync(string contentId) { this.VerifyCanCreateOperationResponseMessage(false); - return TaskUtils.GetTaskForSynchronousOperation( - () => this.CreateOperationResponseMessageImplementation(contentId)); + return await this.CreateOperationResponseMessageImplementationAsync(contentId) + .ConfigureAwait(false); } /// Flushes the write buffer to the underlying stream. @@ -383,12 +385,14 @@ public void Flush() /// Flushes the write buffer to the underlying stream asynchronously. /// A task instance that represents the asynchronous operation. - public Task FlushAsync() + public async Task FlushAsync() { this.VerifyCanFlush(false); // Make sure we switch to writer state Error if an exception is thrown during flushing. - return this.FlushAsynchronously().FollowOnFaultWith(t => this.SetState(BatchWriterState.Error)); + await this.FlushAsynchronously() + .FollowOnFaultWith(t => this.SetState(BatchWriterState.Error)) + .ConfigureAwait(false); } /// @@ -553,7 +557,8 @@ protected ODataBatchOperationRequestMessage BuildOperationRequestMessage(Stream ODataBatchUtils.ValidateReferenceUri(uri, requestIdsForUrlReferenceValidation, this.outputContext.MessageWriterSettings.BaseUri); - Func streamCreatorFunc = () => ODataBatchUtils.CreateBatchOperationWriteStream(outputStream, this); + Func streamCreatorFunc = () => ODataBatchUtils.CreateBatchOperationWriteStream( + outputStream, this, this.outputContext.Synchronous); ODataBatchOperationRequestMessage requestMessage = new ODataBatchOperationRequestMessage(streamCreatorFunc, method, uri, /*headers*/ null, this, contentId, this.payloadUriConverter, /*writing*/ true, this.container, dependsOnIds, groupId); @@ -573,11 +578,91 @@ protected ODataBatchOperationRequestMessage BuildOperationRequestMessage(Stream protected ODataBatchOperationResponseMessage BuildOperationResponseMessage(Stream outputStream, string contentId, string groupId) { - Func streamCreatorFunc = () => ODataBatchUtils.CreateBatchOperationWriteStream(outputStream, this); + Func streamCreatorFunc = () => ODataBatchUtils.CreateBatchOperationWriteStream( + outputStream, this, this.outputContext.Synchronous); return new ODataBatchOperationResponseMessage(streamCreatorFunc, /*headers*/ null, this, contentId, this.payloadUriConverter.BatchMessagePayloadUriConverter, /*writing*/ true, this.container, groupId); } + /// + /// Asnchronously starts a new batch. + /// + /// A task that represents the asynchronous write operation. + protected virtual Task WriteStartBatchImplementationAsync() + { + return TaskUtils.GetTaskForSynchronousOperation(this.WriteStartBatchImplementation); + } + + /// + /// Asynchronously ends a batch. + /// + /// A task that represents the asynchronous write operation. + protected virtual Task WriteEndBatchImplementationAsync() + { + return TaskUtils.GetTaskForSynchronousOperation(this.WriteEndBatchImplementation); + } + + /// + /// Asynchronously starts a new changeset. + /// + /// The atomic group id, aka changeset GUID of the batch request. + /// A task that represents the asynchronous write operation. + protected virtual Task WriteStartChangesetImplementationAsync(string groupOrChangesetId) + { + return TaskUtils.GetTaskForSynchronousOperation( + () => this.WriteStartChangesetImplementation(groupOrChangesetId)); + } + + /// + /// Asynchronously ends an active changeset. + /// + /// A task that represents the asynchronous write operation. + protected virtual Task WriteEndChangesetImplementationAsync() + { + return TaskUtils.GetTaskForSynchronousOperation(this.WriteEndChangesetImplementation); + } + + /// + /// Asynchronously creates an for writing an operation of a batch request. + /// + /// The Http method to be used for this request operation. + /// The Uri to be used for this request operation. + /// The Content-ID value to write in ChangeSet head. + /// + /// The format of operation Request-URI, which could be AbsoluteUri, AbsoluteResourcePathAndHost, or RelativeResourcePath. + /// The prerequisite request ids of this request. + /// A task that represents the asynchronous operation. + /// The value of the TResult parameter contains an + /// that can be used to write the request operation. + protected virtual Task CreateOperationRequestMessageImplementationAsync( + string method, + Uri uri, + string contentId, + BatchPayloadUriOption payloadUriOption, + IEnumerable dependsOnIds) + { + return TaskUtils.GetTaskForSynchronousOperation( + () => this.CreateOperationRequestMessageImplementation( + method, + uri, + contentId, + payloadUriOption, + dependsOnIds)); + } + + /// + /// Asynchronously creates an for writing an operation of a batch response. + /// + /// The Content-ID value to write in ChangeSet head. + /// A task that represents the asynchronous operation. + /// The value of the TResult parameter contains an + /// that can be used to write the response operation. + protected virtual Task CreateOperationResponseMessageImplementationAsync(string contentId) + { + return TaskUtils.GetTaskForSynchronousOperation( + () => this.CreateOperationResponseMessageImplementation(contentId)); + } + /// /// Catch any exception thrown by the action passed in; in the exception case move the writer into /// state ExceptionThrown and then re-throw the exception. @@ -628,7 +713,7 @@ private void ThrowODataException(string errorMessage) private ODataBatchOperationRequestMessage CreateOperationRequestMessageInternal(string method, Uri uri, string contentId, BatchPayloadUriOption payloadUriOption, IEnumerable dependsOnIds) { - if (!this.isInChangset) + if (!this.isInChangeset) { this.InterceptException(this.IncreaseBatchSize); } @@ -653,7 +738,65 @@ private ODataBatchOperationRequestMessage CreateOperationRequestMessageInternal( this.CurrentOperationRequestMessage = this.CreateOperationRequestMessageImplementation( method, uri, contentId, payloadUriOption, dependsOnIds); - if (this.isInChangset || this.outputContext.MessageWriterSettings.Version > ODataVersion.V4) + if (this.isInChangeset || this.outputContext.MessageWriterSettings.Version > ODataVersion.V4) + { + // The content Id can be generated if the value passed in is null, therefore here we get the real value of the content Id. + this.RememberContentIdHeader(this.CurrentOperationRequestMessage.ContentId); + } + + return this.CurrentOperationRequestMessage; + } + + /// + /// Internal method to create an for writing + /// an operation of a batch request. + /// + /// The Http method to be used for this request operation. + /// The Uri to be used for this request operation. + /// + /// For batch in multipart format, the Content-ID value to write in ChangeSet header, would be ignored if + /// is "GET". + /// For batch in Json format, if the value passed in is null, an GUID will be generated and used as the request id. + /// + /// + /// The format of operation Request-URI, which could be AbsoluteUri, AbsoluteResourcePathAndHost, or RelativeResourcePath. + /// The prerequisite request ids of this request. + /// A task that represents the asynchronous operation. + /// The value of the TResult parameter contains an + /// that can be used to write the request operation. + private async Task CreateOperationRequestMessageInternalAsync( + string method, + Uri uri, + string contentId, + BatchPayloadUriOption payloadUriOption, + IEnumerable dependsOnIds) + { + if (!this.isInChangeset) + { + this.InterceptException(this.IncreaseBatchSize); + } + else + { + this.InterceptException(this.IncreaseChangeSetSize); + } + + // Add a potential Content-ID header to the URL resolver so that it will be available + // to subsequent operations. + // Note that what we add here is the Content-ID header of the previous operation (if any). + // This also means that the Content-ID of the last operation in a changeset will never get + // added to the cache which is fine since we cannot reference it anywhere. + if (this.currentOperationContentId != null) + { + this.payloadUriConverter.AddContentId(this.currentOperationContentId); + } + + this.InterceptException(() => + uri = ODataBatchUtils.CreateOperationRequestUri(uri, this.outputContext.MessageWriterSettings.BaseUri, this.payloadUriConverter)); + + this.CurrentOperationRequestMessage = await this.CreateOperationRequestMessageImplementationAsync( + method, uri, contentId, payloadUriOption, dependsOnIds).ConfigureAwait(false); + + if (this.isInChangeset || this.outputContext.MessageWriterSettings.Version > ODataVersion.V4) { // The content Id can be generated if the value passed in is null, therefore here we get the real value of the content Id. this.RememberContentIdHeader(this.CurrentOperationRequestMessage.ContentId); @@ -680,7 +823,7 @@ private void FinishWriteStartChangeset() // reset the size of the current changeset and increase the size of the batch. this.ResetChangeSetSize(); this.InterceptException(this.IncreaseBatchSize); - this.isInChangset = true; + this.isInChangeset = true; } /// @@ -695,7 +838,7 @@ private void FinishWriteEndChangeset() this.currentOperationContentId = null; } - this.isInChangset = false; + this.isInChangeset = false; } /// @@ -799,7 +942,7 @@ private void VerifyCanCreateOperationRequestMessage(bool synchronousCall, string ExceptionUtils.CheckArgumentNotNull(uri, "uri"); // For the case within a changeset, verify CreateOperationRequestMessage is valid. - if (this.isInChangset) + if (this.isInChangeset) { if (HttpUtils.IsQueryMethod(method)) { @@ -933,7 +1076,7 @@ private void ValidateTransition(BatchWriterState newState) { case BatchWriterState.ChangesetStarted: // make sure that we are not starting a changeset when one is already active - if (this.isInChangset) + if (this.isInChangeset) { throw new ODataException(Strings.ODataBatchWriter_CannotStartChangeSetWithActiveChangeSet); } @@ -941,7 +1084,7 @@ private void ValidateTransition(BatchWriterState newState) break; case BatchWriterState.ChangesetCompleted: // make sure that we are not completing a changeset without an active changeset - if (!this.isInChangset) + if (!this.isInChangeset) { throw new ODataException(Strings.ODataBatchWriter_CannotCompleteChangeSetWithoutActiveChangeSet); } @@ -949,7 +1092,7 @@ private void ValidateTransition(BatchWriterState newState) break; case BatchWriterState.BatchCompleted: // make sure that we are not completing a batch while a changeset is still active - if (this.isInChangset) + if (this.isInChangeset) { throw new ODataException(Strings.ODataBatchWriter_CannotCompleteBatchWithActiveChangeSet); } diff --git a/src/Microsoft.OData.Core/GlobalSuppressions.cs b/src/Microsoft.OData.Core/GlobalSuppressions.cs index 94f4249174..0f925cff90 100644 --- a/src/Microsoft.OData.Core/GlobalSuppressions.cs +++ b/src/Microsoft.OData.Core/GlobalSuppressions.cs @@ -18,6 +18,10 @@ [assembly: SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Scope = "member", Target = "Microsoft.OData.UriParser.FunctionCallBinder.#BindAsUriFunction(Microsoft.OData.UriParser.FunctionCallToken,System.Collections.Generic.List`1)")] [assembly: SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Scope = "member", Target = "Microsoft.OData.UriParser.SelectExpandOptionParser.#BuildStarExpandTermToken(Microsoft.OData.UriParser.PathSegmentToken)")] [module: SuppressMessage("Microsoft.Naming", "CA1701:ResourceStringCompoundWordsShouldBeCasedCorrectly", MessageId = "NonEntity", Scope = "resource", Target = "Microsoft.OData.Core.resources")] +[assembly: SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Scope = "member", Target = "Microsoft.OData.JsonLight.ODataJsonLightBatchWriter.#WritePendingRequestMessageData")] +[assembly: SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Scope = "member", Target = "Microsoft.OData.JsonLight.ODataJsonLightBatchWriter.#WritePendingResponseMessageData")] +[assembly: SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Scope = "member", Target = "Microsoft.OData.JsonLight.ODataJsonLightBatchWriter.#WritePendingRequestMessageDataAsync")] +[assembly: SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Scope = "member", Target = "Microsoft.OData.JsonLight.ODataJsonLightBatchWriter.#WritePendingResponseMessageDataAsync")] // By design. [assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields", Scope = "member", Target = "Microsoft.OData.Evaluation.ODataResourceMetadataContext+ODataResourceMetadataContextWithoutModel.#serializationInfo")] diff --git a/src/Microsoft.OData.Core/JsonLight/ODataJsonLightBatchWriter.cs b/src/Microsoft.OData.Core/JsonLight/ODataJsonLightBatchWriter.cs index 37db15118d..51669f7479 100644 --- a/src/Microsoft.OData.Core/JsonLight/ODataJsonLightBatchWriter.cs +++ b/src/Microsoft.OData.Core/JsonLight/ODataJsonLightBatchWriter.cs @@ -83,6 +83,11 @@ internal sealed class ODataJsonLightBatchWriter : ODataBatchWriter /// private readonly IJsonWriter jsonWriter; + /// + /// The underlying asynchronous JSON writer. + /// + private readonly IJsonWriterAsync asynchronousJsonWriter; + /// /// The auto-generated GUID for AtomicityGroup of the Json item. Should be null for Json item /// that doesn't belong to atomic group. @@ -109,6 +114,7 @@ internal ODataJsonLightBatchWriter(ODataJsonLightOutputContext jsonLightOutputCo : base(jsonLightOutputContext) { this.jsonWriter = this.JsonLightOutputContext.JsonWriter; + this.asynchronousJsonWriter = this.JsonLightOutputContext.AsynchronousJsonWriter; } /// @@ -167,15 +173,17 @@ public override void StreamRequested() /// A task representing any action that is running as part of the status change of the operation; /// null if no such action exists. /// - public override Task StreamRequestedAsync() + public override async Task StreamRequestedAsync() { // Write any pending data and flush the batch writer to the async buffered stream - this.StartBatchOperationContent(); + await this.StartBatchOperationContentAsync() + .ConfigureAwait(false); // Asynchronously flush the async buffered stream to the underlying message stream (if there's any); // then dispose the batch writer (since we are now writing the operation content) and set the corresponding state. - return this.JsonLightOutputContext.FlushBuffersAsync() - .FollowOnSuccessWith(task => this.SetState(BatchWriterState.OperationStreamRequested)); + await this.JsonLightOutputContext.FlushBuffersAsync() + .FollowOnSuccessWith(task => this.SetState(BatchWriterState.OperationStreamRequested)) + .ConfigureAwait(false); } /// @@ -192,6 +200,21 @@ public override void StreamDisposed() this.EnsurePrecedingMessageIsClosed(); } + /// + /// This method is called to notify that the content stream of a batch operation has been disposed. + /// + public override async Task StreamDisposedAsync() + { + Debug.Assert(this.CurrentOperationMessage != null, "Expected non-null operation message!"); + + this.SetState(BatchWriterState.OperationStreamDisposed); + this.CurrentOperationRequestMessage = null; + this.CurrentOperationResponseMessage = null; + + await this.EnsurePrecedingMessageIsClosedAsync() + .ConfigureAwait(false); + } + /// /// This method notifies the listener, that an in-stream error is to be written. /// @@ -203,9 +226,22 @@ public override void OnInStreamError() { this.JsonLightOutputContext.VerifyNotDisposed(); this.SetState(BatchWriterState.Error); - this.JsonLightOutputContext.JsonWriter.Flush(); + this.jsonWriter.Flush(); - // The OData protocol spec did not defined the behavior when an exception is encountered outside of a batch operation. The batch writer + // The OData protocol spec does not define the behavior when an exception is encountered outside of a batch operation. The batch writer + // should not allow WriteError in this case. Note that WCF DS Server does serialize the error in XML format when it encounters one outside of a + // batch operation. + throw new ODataException(Strings.ODataBatchWriter_CannotWriteInStreamErrorForBatch); + } + + public override async Task OnInStreamErrorAsync() + { + this.JsonLightOutputContext.VerifyNotDisposed(); + this.SetState(BatchWriterState.Error); + await this.asynchronousJsonWriter.FlushAsync() + .ConfigureAwait(false); + + // The OData protocol spec does not define the behavior when an exception is encountered outside of a batch operation. The batch writer // should not allow WriteError in this case. Note that WCF DS Server does serialize the error in XML format when it encounters one outside of a // batch operation. throw new ODataException(Strings.ODataBatchWriter_CannotWriteInStreamErrorForBatch); @@ -268,7 +304,7 @@ protected override IEnumerable GetDependsOnRequestIds(IEnumerableThe dependsOn ids specifying current request's prerequisites. protected override void ValidateDependsOnIds(string contentId, IEnumerable dependsOnIds) { - foreach (var id in dependsOnIds) + foreach (var id in dependsOnIds) { // Content-ID cannot be part of dependsOnIds. This is to avoid self referencing. // The dependsOnId must be an existing request ID. @@ -431,6 +467,189 @@ protected override void VerifyNotDisposed() this.JsonLightOutputContext.VerifyNotDisposed(); } + /// + /// Asynchronously starts a new batch. + /// + /// A task that represents the asynchronous write operation. + protected override async Task WriteStartBatchImplementationAsync() + { + await WriteBatchEnvelopeAsync() + .ConfigureAwait(false); + this.SetState(BatchWriterState.BatchStarted); + } + + /// + /// Asynchronously ends a batch. + /// + /// A task that represents the asynchronous write operation. + protected override async Task WriteEndBatchImplementationAsync() + { + // write pending message data (headers, response line) for a previously unclosed message/request + await this.WritePendingMessageDataAsync(true) + .ConfigureAwait(false); + + this.SetState(BatchWriterState.BatchCompleted); + + // Close the messages array + await asynchronousJsonWriter.EndArrayScopeAsync() + .ConfigureAwait(false); + + // Close the top level scope + await asynchronousJsonWriter.EndObjectScopeAsync() + .ConfigureAwait(false); + } + + /// + /// Asynchronously starts a new changeset. + /// + /// The atomic group id of the changeset to start. + /// If it is null for Json batch, an GUID will be generated and used as the atomic group id. + /// A task that represents the asynchronous write operation. + protected override async Task WriteStartChangesetImplementationAsync(string groupId) + { + Debug.Assert(groupId != null, "groupId != null"); + + // write pending message data (headers, response line) for a previously unclosed message/request + await this.WritePendingMessageDataAsync(true) + .ConfigureAwait(false); + + // important to do this first since it will set up the change set boundary. + this.SetState(BatchWriterState.ChangesetStarted); + + this.atomicityGroupId = groupId; + } + + /// + /// Asynchronously ends an active changeset. + /// + /// A task that represents the asynchronous write operation. + protected override async Task WriteEndChangesetImplementationAsync() + { + // write pending message data (headers, response line) for a previously unclosed message/request + await this.WritePendingMessageDataAsync(true) + .ConfigureAwait(false); + + // change the state first so we validate the change set boundary before attempting to write it. + this.SetState(BatchWriterState.ChangesetCompleted); + this.atomicityGroupId = null; + } + + /// + /// Asynchronously creates an for writing an operation of a batch request. + /// + /// The Http method to be used for this request operation. + /// The Uri to be used for this request operation. + /// The Content-ID value to write in ChangeSet head. + /// + /// The format of operation Request-URI, which could be AbsoluteUri, AbsoluteResourcePathAndHost, or RelativeResourcePath. + /// The prerequisite request ids of this request. + /// A task that represents the asynchronous operation. + /// The value of the TResult parameter contains an + /// that can be used to write the request operation. + protected override async Task CreateOperationRequestMessageImplementationAsync( + string method, + Uri uri, + string contentId, + BatchPayloadUriOption payloadUriOption, + IEnumerable dependsOnIds) + { + // write pending message data (headers, request line) for a previously unclosed message/request + await this.WritePendingMessageDataAsync(true) + .ConfigureAwait(false); + + // For json batch request, content Id is required for single request or request within atomicityGroup. + if (contentId == null) + { + contentId = Guid.NewGuid().ToString(); + } + + AddGroupIdLookup(contentId); + + // Create the new request operation with a non-null dependsOnIds. + this.CurrentOperationRequestMessage = BuildOperationRequestMessage( + this.JsonLightOutputContext.GetOutputStream(), method, uri, contentId, + this.atomicityGroupId, + dependsOnIds ?? Enumerable.Empty()); + + this.SetState(BatchWriterState.OperationCreated); + + // write the operation's start boundary string + await this.WriteStartBoundaryForOperationAsync() + .ConfigureAwait(false); + + await this.asynchronousJsonWriter.WriteNameAsync(PropertyId) + .ConfigureAwait(false); + await this.asynchronousJsonWriter.WriteValueAsync(contentId) + .ConfigureAwait(false); + + if (this.atomicityGroupId != null) + { + await this.asynchronousJsonWriter.WriteNameAsync(PropertyAtomicityGroup) + .ConfigureAwait(false); + await this.asynchronousJsonWriter.WriteValueAsync(this.atomicityGroupId) + .ConfigureAwait(false); + } + + if (this.CurrentOperationRequestMessage.DependsOnIds != null + && this.CurrentOperationRequestMessage.DependsOnIds.Any()) + { + await this.asynchronousJsonWriter.WriteNameAsync(PropertyDependsOn) + .ConfigureAwait(false); + await this.asynchronousJsonWriter.StartArrayScopeAsync() + .ConfigureAwait(false); + + foreach (string dependsOnId in this.CurrentOperationRequestMessage.DependsOnIds) + { + ValidateDependsOnId(contentId, dependsOnId); + await this.asynchronousJsonWriter.WriteValueAsync(dependsOnId) + .ConfigureAwait(false); + } + + await this.asynchronousJsonWriter.EndArrayScopeAsync() + .ConfigureAwait(false); + } + + await this.asynchronousJsonWriter.WriteNameAsync(PropertyMethod) + .ConfigureAwait(false); + await this.asynchronousJsonWriter.WriteValueAsync(method) + .ConfigureAwait(false); + + await this.WriteRequestUriAsync(uri, payloadUriOption) + .ConfigureAwait(false); + + return this.CurrentOperationRequestMessage; + } + + /// + /// Asynchronously creates an for writing an operation of a batch response. + /// + /// The Content-ID value to write in ChangeSet head. + /// A task that represents the asynchronous operation. + /// The value of the TResult parameter contains an + /// that can be used to write the response operation. + protected override async Task CreateOperationResponseMessageImplementationAsync(string contentId) + { + await this.WritePendingMessageDataAsync(true) + .ConfigureAwait(false); + + // Url resolver: In responses we don't need to use our batch URL resolver, since there are no cross referencing URLs + // so use the URL resolver from the batch message instead. + // + // ContentId: could be null from public API common for both formats, so we don't enforce non-null value for Json format + // for sake of backward compatibility. For Json Batch response message, normally caller should use the same value + // from the request. + this.CurrentOperationResponseMessage = BuildOperationResponseMessage( + this.JsonLightOutputContext.GetOutputStream(), + contentId, this.atomicityGroupId); + this.SetState(BatchWriterState.OperationCreated); + + // Start the Json object for the response + await this.WriteStartBoundaryForOperationAsync() + .ConfigureAwait(false); + + return this.CurrentOperationResponseMessage; + } + /// /// Validates the dependsOnId. It needs to be a valid id, and cannot be inside another atomic group. /// @@ -521,7 +740,7 @@ private void WriteStartBoundaryForOperation() private void StartBatchOperationContent() { Debug.Assert(this.CurrentOperationMessage != null, "Expected non-null operation message!"); - Debug.Assert(this.JsonLightOutputContext.JsonWriter != null, "Must have a Json writer!"); + Debug.Assert(this.jsonWriter != null, "Must have a Json writer!"); // write the pending headers (if any) this.WritePendingMessageData(false); @@ -534,7 +753,7 @@ private void StartBatchOperationContent() // flush the text writer to make sure all buffers of the text writer // are flushed to the underlying async stream - this.JsonLightOutputContext.JsonWriter.Flush(); + this.jsonWriter.Flush(); } /// @@ -547,7 +766,7 @@ private void WritePendingMessageData(bool reportMessageCompleted) { if (this.CurrentOperationMessage != null) { - Debug.Assert(this.JsonLightOutputContext.JsonWriter != null, + Debug.Assert(this.jsonWriter != null, "Must have a batch writer if pending data exists."); if (this.CurrentOperationRequestMessage != null) @@ -590,14 +809,13 @@ private void WriteBatchEnvelope() this.jsonWriter.StartObjectScope(); // Start the requests / responses property - this.jsonWriter.WriteName(this.JsonLightOutputContext.WritingResponse ? PropertyResponses : PropertyRequests); + this.jsonWriter.WriteName(this.JsonLightOutputContext.WritingResponse ? PropertyResponses : PropertyRequests); this.jsonWriter.StartArrayScope(); } /// /// Writing pending data for the current request message. /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Justification = "need to use lower characters for header key")] private void WritePendingRequestMessageData() { Debug.Assert(this.CurrentOperationRequestMessage != null, "this.CurrentOperationRequestMessage != null"); @@ -621,7 +839,6 @@ private void WritePendingRequestMessageData() /// /// Writing pending data for the current response message. /// - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Globalization", "CA1308:NormalizeStringsToUppercase", Justification = "need to use lower characters for header key")] private void WritePendingResponseMessageData() { Debug.Assert(this.JsonLightOutputContext.WritingResponse, "If the response message is available we must be writing response."); @@ -674,9 +891,9 @@ private void WriteRequestUri(Uri uri, BatchPayloadUriOption payloadUriOption) break; case BatchPayloadUriOption.AbsoluteUriUsingHostHeader: - string absoluteResourcePath = absoluteUriString.Substring(absoluteUriString.IndexOf('/', absoluteUriString.IndexOf("//", StringComparison.Ordinal) + 2)); + string absoluteResourcePath = ExtractAbsoluteResourcePath(absoluteUriString); this.jsonWriter.WriteValue(absoluteResourcePath); - this.CurrentOperationRequestMessage.SetHeader("host", string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}:{1}", uri.Host, uri.Port)); + this.CurrentOperationRequestMessage.SetHeader("host", string.Format(CultureInfo.InvariantCulture, "{0}:{1}", uri.Host, uri.Port)); break; case BatchPayloadUriOption.RelativeUri: @@ -693,5 +910,246 @@ private void WriteRequestUri(Uri uri, BatchPayloadUriOption payloadUriOption) this.jsonWriter.WriteValue(UriUtils.UriToString(uri)); } } + + /// + /// Asynchronously writes the start boundary for an operation. This is Json start object. + /// + /// A task that represents the asynchronous write operation. + private Task WriteStartBoundaryForOperationAsync() + { + // Start the individual message object + return this.asynchronousJsonWriter.StartObjectScopeAsync(); + } + + /// + /// Asynchronously writes all the pending headers and prepares the writer to write a content of the operation. + /// + /// A task that represents the asynchronous write operation. + private async Task StartBatchOperationContentAsync() + { + Debug.Assert(this.CurrentOperationMessage != null, "Expected non-null operation message!"); + Debug.Assert(this.asynchronousJsonWriter != null, "Must have an asynchronous Json writer!"); + + // write the pending headers (if any) + await this.WritePendingMessageDataAsync(false) + .ConfigureAwait(false); + + await this.asynchronousJsonWriter.WriteRawValueAsync(string.Format(CultureInfo.InvariantCulture, + "{0} \"{1}\" {2}", + JsonConstants.ArrayElementSeparator, + PropertyBody, + JsonConstants.NameValueSeparator)).ConfigureAwait(false); + + // flush the text writer to make sure all buffers of the text writer + // are flushed to the underlying async stream + await this.asynchronousJsonWriter.FlushAsync() + .ConfigureAwait(false); + } + + /// + /// Asynchronously writes any pending data for the current operation message (if any). + /// + /// + /// A flag to control whether after writing the pending data we report writing the message to be completed or not. + /// + /// A task that represents the asynchronous write operation. + private async Task WritePendingMessageDataAsync(bool reportMessageCompleted) + { + if (this.CurrentOperationMessage != null) + { + Debug.Assert(this.asynchronousJsonWriter != null, + "Must have an asynchronous Json writer if pending data exists."); + + if (this.CurrentOperationRequestMessage != null) + { + await WritePendingRequestMessageDataAsync() + .ConfigureAwait(false); + } + else + { + await WritePendingResponseMessageDataAsync() + .ConfigureAwait(false); + } + + if (reportMessageCompleted) + { + this.CurrentOperationMessage.PartHeaderProcessingCompleted(); + this.CurrentOperationRequestMessage = null; + this.CurrentOperationResponseMessage = null; + + await this.EnsurePrecedingMessageIsClosedAsync() + .ConfigureAwait(false); + } + } + } + + /// + /// Asynchronously closes preceding message Json object if any. + /// + /// A task that represents the asynchronous write operation. + private Task EnsurePrecedingMessageIsClosedAsync() + { + // There shouldn't be any pending message object. + Debug.Assert(this.CurrentOperationMessage == null, "this.CurrentOperationMessage == null"); + return this.asynchronousJsonWriter.EndObjectScopeAsync(); + } + + /// + /// Asynchronously writes the json format batch envelope. + /// Always sets the isBatchEnvelopeWritten flag to true before return. + /// + /// A task that represents the asynchronous write operation. + private async Task WriteBatchEnvelopeAsync() + { + // Start the top level scope + await this.asynchronousJsonWriter.StartObjectScopeAsync() + .ConfigureAwait(false); + + // Start the requests / responses property + await this.asynchronousJsonWriter.WriteNameAsync(this.JsonLightOutputContext.WritingResponse ? PropertyResponses : PropertyRequests) + .ConfigureAwait(false); + await this.asynchronousJsonWriter.StartArrayScopeAsync() + .ConfigureAwait(false); + } + + /// + /// Asynchronously writes pending data for the current request message. + /// + /// A task that represents the asynchronous write operation. + private async Task WritePendingRequestMessageDataAsync() + { + Debug.Assert(this.CurrentOperationRequestMessage != null, "this.CurrentOperationRequestMessage != null"); + + // headers property. + await this.asynchronousJsonWriter.WriteNameAsync(PropertyHeaders) + .ConfigureAwait(false); + await this.asynchronousJsonWriter.StartObjectScopeAsync() + .ConfigureAwait(false); + IEnumerable> headers = this.CurrentOperationRequestMessage.Headers; + if (headers != null) + { + foreach (KeyValuePair headerPair in headers) + { + await this.asynchronousJsonWriter.WriteNameAsync(headerPair.Key.ToLowerInvariant()) + .ConfigureAwait(false); + await this.asynchronousJsonWriter.WriteValueAsync(headerPair.Value) + .ConfigureAwait(false); + } + } + + await this.asynchronousJsonWriter.EndObjectScopeAsync() + .ConfigureAwait(false); + } + + /// + /// Asynchronously writes pending data for the current response message. + /// + /// A task that represents the asynchronous write operation. + private async Task WritePendingResponseMessageDataAsync() + { + Debug.Assert(this.JsonLightOutputContext.WritingResponse, "If the response message is available we must be writing response."); + Debug.Assert(this.CurrentOperationResponseMessage != null, "this.CurrentOperationResponseMessage != null"); + + // id property. + await this.asynchronousJsonWriter.WriteNameAsync(PropertyId) + .ConfigureAwait(false); + await this.asynchronousJsonWriter.WriteValueAsync(this.CurrentOperationResponseMessage.ContentId) + .ConfigureAwait(false); + + // atomicityGroup property. + if (this.atomicityGroupId != null) + { + await this.asynchronousJsonWriter.WriteNameAsync(PropertyAtomicityGroup) + .ConfigureAwait(false); + await this.asynchronousJsonWriter.WriteValueAsync(this.atomicityGroupId) + .ConfigureAwait(false); + } + + // response status property. + await this.asynchronousJsonWriter.WriteNameAsync(PropertyStatus) + .ConfigureAwait(false); + await this.asynchronousJsonWriter.WriteValueAsync(this.CurrentOperationResponseMessage.StatusCode) + .ConfigureAwait(false); + + // headers property. + await this.asynchronousJsonWriter.WriteNameAsync(PropertyHeaders) + .ConfigureAwait(false); + await this.asynchronousJsonWriter.StartObjectScopeAsync() + .ConfigureAwait(false); + IEnumerable> headers = this.CurrentOperationMessage.Headers; + if (headers != null) + { + foreach (KeyValuePair headerPair in headers) + { + await this.asynchronousJsonWriter.WriteNameAsync(headerPair.Key.ToLowerInvariant()) + .ConfigureAwait(false); + await this.asynchronousJsonWriter.WriteValueAsync(headerPair.Value) + .ConfigureAwait(false); + } + } + + await this.asynchronousJsonWriter.EndObjectScopeAsync() + .ConfigureAwait(false); + } + + /// + /// Asynchronously writes the request uri. + /// + /// The uri for the request operation. + /// + /// The format of operation Request-URI, which could be AbsoluteUri, AbsoluteResourcePathAndHost, or RelativeResourcePath. + /// + /// A task that represents the asynchronous write operation. + private async Task WriteRequestUriAsync(Uri uri, BatchPayloadUriOption payloadUriOption) + { + await this.asynchronousJsonWriter.WriteNameAsync(PropertyUrl) + .ConfigureAwait(false); + + if (uri.IsAbsoluteUri) + { + Uri baseUri = this.OutputContext.MessageWriterSettings.BaseUri; + string absoluteUriString = uri.AbsoluteUri; + + switch (payloadUriOption) + { + case BatchPayloadUriOption.AbsoluteUri: + await this.asynchronousJsonWriter.WriteValueAsync(UriUtils.UriToString(uri)) + .ConfigureAwait(false); + break; + + case BatchPayloadUriOption.AbsoluteUriUsingHostHeader: + string absoluteResourcePath = ExtractAbsoluteResourcePath(absoluteUriString); + await this.asynchronousJsonWriter.WriteValueAsync(absoluteResourcePath) + .ConfigureAwait(false); + this.CurrentOperationRequestMessage.SetHeader("host", + string.Format(CultureInfo.InvariantCulture, "{0}:{1}", uri.Host, uri.Port)); + break; + + case BatchPayloadUriOption.RelativeUri: + Debug.Assert(baseUri != null, "baseUri != null"); + string baseUriString = UriUtils.UriToString(baseUri); + Debug.Assert(uri.AbsoluteUri.StartsWith(baseUriString, StringComparison.Ordinal), "absoluteUriString.StartsWith(baseUriString)"); + string relativeResourcePath = uri.AbsoluteUri.Substring(baseUriString.Length); + await this.asynchronousJsonWriter.WriteValueAsync(relativeResourcePath) + .ConfigureAwait(false); + break; + } + } + else + { + await this.asynchronousJsonWriter.WriteValueAsync(UriUtils.UriToString(uri)) + .ConfigureAwait(false); + } + } + + /// + /// Extracts the absolute resource path from the absolute Uri string. + /// + /// The absolute Uri string. + /// + private static string ExtractAbsoluteResourcePath(string absoluteUriString) + { + return absoluteUriString.Substring(absoluteUriString.IndexOf('/', absoluteUriString.IndexOf("//", StringComparison.Ordinal) + 2)); + } } -} \ No newline at end of file +} diff --git a/src/Microsoft.OData.Core/MultipartMixed/ODataMultipartMixedBatchWriter.cs b/src/Microsoft.OData.Core/MultipartMixed/ODataMultipartMixedBatchWriter.cs index 7e843900dd..4948f512b9 100644 --- a/src/Microsoft.OData.Core/MultipartMixed/ODataMultipartMixedBatchWriter.cs +++ b/src/Microsoft.OData.Core/MultipartMixed/ODataMultipartMixedBatchWriter.cs @@ -139,6 +139,12 @@ public override void StreamDisposed() this.RawOutputContext.InitializeRawValueWriter(); } + public override Task StreamDisposedAsync() + { + return TaskUtils.GetTaskForSynchronousOperation( + () => this.StreamDisposed()); + } + /// /// This method notifies the listener, that an in-stream error is to be written. /// diff --git a/test/FunctionalTests/Microsoft.OData.Core.Tests/JsonLight/ODataJsonLightBatchWriterTests.cs b/test/FunctionalTests/Microsoft.OData.Core.Tests/JsonLight/ODataJsonLightBatchWriterTests.cs new file mode 100644 index 0000000000..b901b9b517 --- /dev/null +++ b/test/FunctionalTests/Microsoft.OData.Core.Tests/JsonLight/ODataJsonLightBatchWriterTests.cs @@ -0,0 +1,942 @@ +//--------------------------------------------------------------------- +// +// Copyright (C) Microsoft Corporation. All rights reserved. See License.txt in the project root for license information. +// +//--------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.OData.Edm; +using Microsoft.OData.JsonLight; +using Xunit; + +namespace Microsoft.OData.Core.Tests.JsonLight +{ + public class ODataJsonLightBatchWriterTests + { + private const string ServiceUri = "http://tempuri.org"; + private EdmModel model; + private MemoryStream stream; + private ODataMessageWriterSettings settings; + private ODataMediaType mediaType; + private Encoding encoding; + + private EdmEnumType customerTypeEnumType; + private EdmEntityType customerEntityType; + private EdmEntityType orderEntityType; + private EdmEntitySet customerEntitySet; + private EdmEntitySet orderEntitySet; + + public ODataJsonLightBatchWriterTests() + { + InitializeEdmModel(); + this.stream = new MemoryStream(); + this.settings = new ODataMessageWriterSettings { Version = ODataVersion.V4 }; + this.settings.SetServiceDocumentUri(new Uri(ServiceUri)); + this.mediaType = new ODataMediaType("application", "json", + new[] + { + new KeyValuePair("odata.metadata", "minimal"), + new KeyValuePair("odata.streaming", "true"), + new KeyValuePair("IEEE754Compatible", "false"), + new KeyValuePair("charset", "utf-8") + }); + this.encoding = Encoding.UTF8; + } + + private void InitializeEdmModel() + { + this.model = new EdmModel(); + + this.customerTypeEnumType = new EdmEnumType("NS", "CustomerType"); + this.customerEntityType = new EdmEntityType("NS", "Customer"); + this.orderEntityType = new EdmEntityType("NS", "Order"); + + this.customerTypeEnumType.AddMember(new EdmEnumMember(this.customerTypeEnumType, "Retail", new EdmEnumMemberValue(0))); + this.customerTypeEnumType.AddMember(new EdmEnumMember(this.customerTypeEnumType, "Wholesale", new EdmEnumMemberValue(1))); + this.model.AddElement(this.customerTypeEnumType); + + var customerIdProperty = this.customerEntityType.AddStructuralProperty("Id", EdmPrimitiveTypeKind.Int32); + this.customerEntityType.AddKeys(customerIdProperty); + this.customerEntityType.AddStructuralProperty("Name", EdmPrimitiveTypeKind.String); + this.customerEntityType.AddStructuralProperty("Type", new EdmEnumTypeReference(this.customerTypeEnumType, false)); + this.model.AddElement(this.customerEntityType); + + var orderIdProperty = this.orderEntityType.AddStructuralProperty("Id", EdmPrimitiveTypeKind.Int32); + this.orderEntityType.AddKeys(orderIdProperty); + this.orderEntityType.AddStructuralProperty("CustomerId", EdmPrimitiveTypeKind.Int32); + this.orderEntityType.AddStructuralProperty("Amount", EdmPrimitiveTypeKind.Decimal); + this.model.AddElement(this.orderEntityType); + + var entityContainer = new EdmEntityContainer("NS", "Container"); + this.model.AddElement(entityContainer); + + this.customerEntitySet = entityContainer.AddEntitySet("Customers", this.customerEntityType); + this.orderEntitySet = entityContainer.AddEntitySet("Orders", this.orderEntityType); + } + + [Theory] + [InlineData(true, "{\"requests\":[]}")] + [InlineData(false, "{\"responses\":[]}")] + public async Task WriteBatchAsync(bool writingRequest, string expected) + { + var result = await SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.WriteEndBatchAsync(); + }, + writingRequest: writingRequest); + + Assert.Equal(result, expected); + } + + [Fact] + public async Task WriteBatchRequestAsync() + { + var result = await SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + + var operationRequestMessage = await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "POST", new Uri($"{ServiceUri}/Customers"), "1"); + + using (var messageWriter = new ODataMessageWriter(operationRequestMessage)) + { + var jsonLightWriter = await messageWriter.CreateODataResourceWriterAsync(this.customerEntitySet, this.customerEntityType); + + var customerResource = CreateCustomerResource(1); + await jsonLightWriter.WriteStartAsync(customerResource); + await jsonLightWriter.WriteEndAsync(); + } + + await jsonLightBatchWriter.WriteEndBatchAsync(); + }); + + Assert.Equal("{\"requests\":[{" + + "\"id\":\"1\"," + + "\"method\":\"POST\"," + + "\"url\":\"http://tempuri.org/Customers\"," + + "\"headers\":{\"odata-version\":\"4.0\",\"content-type\":\"application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8\"}, " + + "\"body\" :{\"Id\":1,\"Name\":\"Customer 1\",\"Type\":\"Retail\"}}]}", + result); + } + + [Fact] + public async Task WriteBatchRequestWithChangesetAsync() + { + var result = await SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.WriteStartChangesetAsync("69028f2c-f57b-4850-89f0-b7e5e002d4bc"); + + var operationRequestMessage = await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "POST", new Uri($"{ServiceUri}/Customers"), "1"); + + using (var messageWriter = new ODataMessageWriter(operationRequestMessage)) + { + var jsonLightWriter = await messageWriter.CreateODataResourceWriterAsync(this.customerEntitySet, this.customerEntityType); + + var customerResource = CreateCustomerResource(1); + await jsonLightWriter.WriteStartAsync(customerResource); + await jsonLightWriter.WriteEndAsync(); + } + + await jsonLightBatchWriter.WriteEndChangesetAsync(); + await jsonLightBatchWriter.WriteEndBatchAsync(); + }); + + Assert.Equal("{\"requests\":[{" + + "\"id\":\"1\"," + + "\"atomicityGroup\":\"69028f2c-f57b-4850-89f0-b7e5e002d4bc\"," + + "\"method\":\"POST\"," + + "\"url\":\"http://tempuri.org/Customers\"," + + "\"headers\":{\"odata-version\":\"4.0\",\"content-type\":\"application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8\"}, " + + "\"body\" :{\"Id\":1,\"Name\":\"Customer 1\",\"Type\":\"Retail\"}}]}", + result); + } + + [Fact] + public async Task WriteBatchRequestWithDependsOnIdsAsync() + { + var result = await SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + + var operationRequestMessage1 = await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "POST", new Uri($"{ServiceUri}/Customers"), "1"); + + using (var messageWriter1 = new ODataMessageWriter(operationRequestMessage1)) + { + var jsonLightWriter = await messageWriter1.CreateODataResourceWriterAsync(this.customerEntitySet, this.customerEntityType); + + var customerResource = CreateCustomerResource(1); + await jsonLightWriter.WriteStartAsync(customerResource); + await jsonLightWriter.WriteEndAsync(); + } + + // Operation request depends on the previous (Content ID: 1) + var dependsOnIds = new List { "1" }; + var operationRequestMessage2 = await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "POST", new Uri($"{ServiceUri}/Orders"), "2", BatchPayloadUriOption.AbsoluteUri, dependsOnIds); + + using (var messageWriter2 = new ODataMessageWriter(operationRequestMessage2)) + { + var jsonLightWriter = await messageWriter2.CreateODataResourceWriterAsync(this.orderEntitySet, this.orderEntityType); + + var orderResource = CreateOrderResource(1); + await jsonLightWriter.WriteStartAsync(orderResource); + await jsonLightWriter.WriteEndAsync(); + } + + await jsonLightBatchWriter.WriteEndBatchAsync(); + }); + + Assert.Equal("{\"requests\":[" + + "{\"id\":\"1\"," + + "\"method\":\"POST\"," + + "\"url\":\"http://tempuri.org/Customers\"," + + "\"headers\":{\"odata-version\":\"4.0\",\"content-type\":\"application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8\"}, " + + "\"body\" :{\"Id\":1,\"Name\":\"Customer 1\",\"Type\":\"Retail\"}}," + + "{\"id\":\"2\"," + + "\"dependsOn\":[\"1\"]," + + "\"method\":\"POST\"," + + "\"url\":\"http://tempuri.org/Orders\"," + + "\"headers\":{\"odata-version\":\"4.0\",\"content-type\":\"application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8\"}, " + + "\"body\" :{\"Id\":1,\"CustomerId\":1,\"Amount\":13}}]}", + result); + } + + [Fact] + public async Task WriteBatchRequestWithChangesetAndDependsOnIdsAsync() + { + var result = await SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.WriteStartChangesetAsync("69028f2c-f57b-4850-89f0-b7e5e002d4bc"); + + var operationRequestMessage1 = await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "POST", new Uri($"{ServiceUri}/Customers"), "1"); + + using (var messageWriter1 = new ODataMessageWriter(operationRequestMessage1)) + { + var jsonLightWriter = await messageWriter1.CreateODataResourceWriterAsync(this.customerEntitySet, this.customerEntityType); + + var customerResource = CreateCustomerResource(1); + await jsonLightWriter.WriteStartAsync(customerResource); + await jsonLightWriter.WriteEndAsync(); + } + + // Operation request depends on the previous (Content ID: 1) + var dependsOnIds = new List { "1" }; + var operationRequestMessage2 = await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "POST", new Uri($"{ServiceUri}/Orders"), "2", BatchPayloadUriOption.AbsoluteUri, dependsOnIds); + + using (var messageWriter2 = new ODataMessageWriter(operationRequestMessage2)) + { + var jsonLightWriter = await messageWriter2.CreateODataResourceWriterAsync(this.orderEntitySet, this.orderEntityType); + + var orderResource = CreateOrderResource(1); + await jsonLightWriter.WriteStartAsync(orderResource); + await jsonLightWriter.WriteEndAsync(); + } + + await jsonLightBatchWriter.WriteEndChangesetAsync(); + await jsonLightBatchWriter.WriteEndBatchAsync(); + }); + + Assert.Equal( + "{\"requests\":[" + + "{\"id\":\"1\"," + + "\"atomicityGroup\":\"69028f2c-f57b-4850-89f0-b7e5e002d4bc\"," + + "\"method\":\"POST\"," + + "\"url\":\"http://tempuri.org/Customers\"," + + "\"headers\":{\"odata-version\":\"4.0\",\"content-type\":\"application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8\"}, " + + "\"body\" :{\"Id\":1,\"Name\":\"Customer 1\",\"Type\":\"Retail\"}}," + + "{\"id\":\"2\"," + + "\"atomicityGroup\":\"69028f2c-f57b-4850-89f0-b7e5e002d4bc\"," + + "\"dependsOn\":[\"1\"]," + + "\"method\":\"POST\"," + + "\"url\":\"http://tempuri.org/Orders\"," + + "\"headers\":{\"odata-version\":\"4.0\",\"content-type\":\"application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8\"}, " + + "\"body\" :{\"Id\":1,\"CustomerId\":1,\"Amount\":13}}]}", + result); + } + + [Fact] + public async Task WriteBatchRequestWithGroupIdForChangesetNotSpecifiedAsync() + { + var result = await SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.WriteStartChangesetAsync(); + + var operationRequestMessage = await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "POST", new Uri($"{ServiceUri}/Customers"), "1"); + + using (var messageWriter = new ODataMessageWriter(operationRequestMessage)) + { + var jsonLightWriter = await messageWriter.CreateODataResourceWriterAsync(this.customerEntitySet, this.customerEntityType); + + var customerResource = CreateCustomerResource(1); + await jsonLightWriter.WriteStartAsync(customerResource); + await jsonLightWriter.WriteEndAsync(); + } + + await jsonLightBatchWriter.WriteEndChangesetAsync(); + await jsonLightBatchWriter.WriteEndBatchAsync(); + }); + + Assert.StartsWith("{\"requests\":[{" + + "\"id\":\"1\"," + + "\"atomicityGroup\":\"", // atomicityGroup is a random Guid + result); + Assert.EndsWith("\"," + + "\"method\":\"POST\"," + + "\"url\":\"http://tempuri.org/Customers\"," + + "\"headers\":{\"odata-version\":\"4.0\",\"content-type\":\"application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8\"}, " + + "\"body\" :{\"Id\":1,\"Name\":\"Customer 1\",\"Type\":\"Retail\"}}]}", + result); + } + + [Fact] + public async Task WriteBatchRequestWithContentIdNullAsync() + { + var result = await SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + + var operationRequestMessage = await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "POST", new Uri($"{ServiceUri}/Customers"), /*contentId*/ null); + + using (var messageWriter = new ODataMessageWriter(operationRequestMessage)) + { + var jsonLightWriter = await messageWriter.CreateODataResourceWriterAsync(this.customerEntitySet, this.customerEntityType); + + var customerResource = CreateCustomerResource(1); + await jsonLightWriter.WriteStartAsync(customerResource); + await jsonLightWriter.WriteEndAsync(); + } + + await jsonLightBatchWriter.WriteEndBatchAsync(); + }); + + Assert.StartsWith("{\"requests\":[{\"id\":\"", // id is a random Guid + result); + Assert.EndsWith("\"," + + "\"method\":\"POST\"," + + "\"url\":\"http://tempuri.org/Customers\"," + + "\"headers\":{\"odata-version\":\"4.0\",\"content-type\":\"application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8\"}, " + + "\"body\" :{\"Id\":1,\"Name\":\"Customer 1\",\"Type\":\"Retail\"}}]}", + result); + } + + [Fact] + public async Task WriteBatchResponseAsync() + { + var result = await SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + + var operationResponseMessage = await jsonLightBatchWriter.CreateOperationResponseMessageAsync("1"); + + using (var messageWriter = new ODataMessageWriter(operationResponseMessage, this.settings, this.model)) + { + var jsonLightWriter = await messageWriter.CreateODataResourceWriterAsync(this.customerEntitySet, this.customerEntityType); + + var customerResource = CreateCustomerResource(1); + await jsonLightWriter.WriteStartAsync(customerResource); + await jsonLightWriter.WriteEndAsync(); + } + + await jsonLightBatchWriter.WriteEndBatchAsync(); + }, + /*writingRequest*/ false); + + Assert.Equal("{\"responses\":[{" + + "\"id\":\"1\"," + + "\"status\":0," + + "\"headers\":{\"odata-version\":\"4.0\",\"content-type\":\"application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8\"}, " + + "\"body\" :{\"@odata.context\":\"http://tempuri.org/$metadata#Customers/$entity\",\"Id\":1,\"Name\":\"Customer 1\",\"Type\":\"Retail\"}}]}", + result); + } + + [Fact] + public async Task WriteBatchResponseWithChangesetAsync() + { + var result = await SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.WriteStartChangesetAsync("69028f2c-f57b-4850-89f0-b7e5e002d4bc"); + + var operationResponseMessage = await jsonLightBatchWriter.CreateOperationResponseMessageAsync("1"); + + using (var messageWriter = new ODataMessageWriter(operationResponseMessage, this.settings, this.model)) + { + var jsonLightWriter = await messageWriter.CreateODataResourceWriterAsync(this.customerEntitySet, this.customerEntityType); + + var customerResource = CreateCustomerResource(1); + await jsonLightWriter.WriteStartAsync(customerResource); + await jsonLightWriter.WriteEndAsync(); + } + + await jsonLightBatchWriter.WriteEndChangesetAsync(); + await jsonLightBatchWriter.WriteEndBatchAsync(); + }, + /*writingRequest*/ false); + + Assert.Equal("{\"responses\":[{" + + "\"id\":\"1\"," + + "\"atomicityGroup\":\"69028f2c-f57b-4850-89f0-b7e5e002d4bc\"," + + "\"status\":0," + + "\"headers\":{\"odata-version\":\"4.0\",\"content-type\":\"application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8\"}, " + + "\"body\" :{\"@odata.context\":\"http://tempuri.org/$metadata#Customers/$entity\",\"Id\":1,\"Name\":\"Customer 1\",\"Type\":\"Retail\"}}]}", + result); + } + + [Fact] + public async Task WriteBatchRequestWithAbsoluteUriUsingHostHeaderAsync() + { + var result = await SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + + var operationRequestMessage = await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "POST", new Uri($"{ServiceUri}/odata/Customers"), "1", BatchPayloadUriOption.AbsoluteUriUsingHostHeader); + + using (var messageWriter = new ODataMessageWriter(operationRequestMessage)) + { + var jsonLightWriter = await messageWriter.CreateODataResourceWriterAsync(this.customerEntitySet, this.customerEntityType); + + var customerResource = CreateCustomerResource(1); + await jsonLightWriter.WriteStartAsync(customerResource); + await jsonLightWriter.WriteEndAsync(); + } + + await jsonLightBatchWriter.WriteEndBatchAsync(); + }); + + Assert.Equal("{\"requests\":[{" + + "\"id\":\"1\"," + + "\"method\":\"POST\"," + + "\"url\":\"/odata/Customers\"," + + "\"headers\":{\"host\":\"tempuri.org:80\",\"odata-version\":\"4.0\",\"content-type\":\"application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8\"}, " + + "\"body\" :{\"Id\":1,\"Name\":\"Customer 1\",\"Type\":\"Retail\"}}]}", + result); + } + + [Fact] + public async Task WriteBatchRequestWithRelativeUriAsync() + { + this.settings.BaseUri = new Uri(ServiceUri); + + var result = await SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + + var operationRequestMessage = await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "POST", new Uri("/odata/Customers", UriKind.Relative), "1", BatchPayloadUriOption.RelativeUri); + + using (var messageWriter = new ODataMessageWriter(operationRequestMessage)) + { + var jsonLightWriter = await messageWriter.CreateODataResourceWriterAsync(this.customerEntitySet, this.customerEntityType); + + var customerResource = CreateCustomerResource(1); + await jsonLightWriter.WriteStartAsync(customerResource); + await jsonLightWriter.WriteEndAsync(); + } + + await jsonLightBatchWriter.WriteEndBatchAsync(); + }); + + Assert.Equal( + "{\"requests\":[{" + + "\"id\":\"1\"," + + "\"method\":\"POST\"," + + "\"url\":\"odata/Customers\"," + + "\"headers\":{\"odata-version\":\"4.0\",\"content-type\":\"application/json;odata.metadata=minimal;odata.streaming=true;IEEE754Compatible=false;charset=utf-8\"}, " + + "\"body\" :{\"Id\":1,\"Name\":\"Customer 1\",\"Type\":\"Retail\"}}]}", + result); + } + + [Fact] + public async Task WriteBatchRequestAsync_ReportMessageCompleted() + { + var result = await SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + var operationRequestMessage = await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "POST", new Uri($"{ServiceUri}/Customers"), "1"); + // No writer created for the request message + await jsonLightBatchWriter.WriteEndBatchAsync(); + }); + + Assert.Equal( + "{\"requests\":[{\"id\":\"1\",\"method\":\"POST\",\"url\":\"http://tempuri.org/Customers\",\"headers\":{}}]}", + result); + } + + #region Exception Cases + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForChangesetStartedWithinChangeset() + { + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.WriteStartChangesetAsync(); + // Try to start writing a changeset when another is active + await jsonLightBatchWriter.WriteStartChangesetAsync(); + })); + + Assert.IsType(exception.InnerException); + Assert.Equal(Strings.ODataBatchWriter_CannotStartChangeSetWithActiveChangeSet, exception.InnerException.Message); + } + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForEndChangesetNotPrecededByStartChangeset() + { + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + // Try to end changeset when there's none active + await jsonLightBatchWriter.WriteEndChangesetAsync(); + })); + + Assert.IsType(exception.InnerException); + Assert.Equal(Strings.ODataBatchWriter_CannotCompleteChangeSetWithoutActiveChangeSet, exception.InnerException.Message); + } + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForEndBatchBeforeEndChangeset() + { + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.WriteStartChangesetAsync(); + // Try to stop writing batch before changeset end + await jsonLightBatchWriter.WriteEndBatchAsync(); + })); + + Assert.IsType(exception.InnerException); + Assert.Equal(Strings.ODataBatchWriter_CannotCompleteBatchWithActiveChangeSet, exception.InnerException.Message); + } + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForNoStartBatch() + { + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + // Try to start writing changeset before batch start + await jsonLightBatchWriter.WriteStartChangesetAsync(); + })); + + Assert.IsType(exception.InnerException); + Assert.Equal(Strings.ODataBatchWriter_InvalidTransitionFromStart, exception.InnerException.Message); + } + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForMultipleStartBatch() + { + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + // Try to start writing batch again + await jsonLightBatchWriter.WriteStartBatchAsync(); + })); + + Assert.Equal(Strings.ODataBatchWriter_InvalidTransitionFromBatchStarted, exception.Message); + } + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForStateTransitionsAfterEndBatch() + { + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.WriteEndBatchAsync(); + // Try to start writing batch after batch end + await jsonLightBatchWriter.WriteStartBatchAsync(); + })); + + Assert.Equal(Strings.ODataBatchWriter_InvalidTransitionFromBatchCompleted, exception.Message); + } + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForResponseOperationMessage() + { + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.CreateOperationResponseMessageAsync("1"); + }, + /*writingRequest*/ true)); + + Assert.Equal(Strings.ODataBatchWriter_CannotCreateResponseOperationWhenWritingRequest, exception.Message); + } + + [Fact] + public async Task WriteBatchResponseAsync_ThrowsExceptionForRequestOperationMessage() + { + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "POST", new Uri($"{ServiceUri}/Customers"), "1"); + }, + /*writingRequest*/ false)); + + Assert.Equal(Strings.ODataBatchWriter_CannotCreateRequestOperationWhenWritingResponse, exception.Message); + } + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForInvalidTransitionFromEndChangeset() + { + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.WriteStartChangesetAsync(); + await jsonLightBatchWriter.WriteEndChangesetAsync(); + // // Try to start writing batch after changeset end + await jsonLightBatchWriter.WriteStartBatchAsync(); + })); + + Assert.Equal(Strings.ODataBatchWriter_InvalidTransitionFromChangeSetCompleted, exception.Message); + } + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForInvalidTransitionFromOperationStreamDisposed() + { + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + + var operationRequestMessage = await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "POST", new Uri($"{ServiceUri}/Customers"), "1"); + + using (var messageWriter = new ODataMessageWriter(operationRequestMessage)) + { + var jsonLightWriter = await messageWriter.CreateODataResourceWriterAsync(this.customerEntitySet, this.customerEntityType); + + var customerResource = CreateCustomerResource(1); + await jsonLightWriter.WriteStartAsync(customerResource); + await jsonLightWriter.WriteEndAsync(); + } + + // Try to start writing batch after operation stream disposed + await jsonLightBatchWriter.WriteStartBatchAsync(); + })); + + Assert.Equal(Strings.ODataBatchWriter_InvalidTransitionFromOperationContentStreamDisposed, exception.Message); + } + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForInvalidTransitionFromOperationStreamRequested() + { + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + + var operationRequestMessage = await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "POST", new Uri($"{ServiceUri}/Customers"), "1"); + + using (var messageWriter = new ODataMessageWriter(operationRequestMessage)) + { + await messageWriter.CreateODataResourceWriterAsync(this.customerEntitySet, this.customerEntityType); + + // Try to end writing batch after operation stream requested + await jsonLightBatchWriter.WriteEndBatchAsync(); + } + })); + + Assert.Equal(Strings.ODataBatchWriter_InvalidTransitionFromOperationContentStreamRequested, exception.Message); + } + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForInvalidTransitionFromOperationCreated() + { + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "POST", new Uri($"{ServiceUri}/Customers"), "1"); + // Try to start writing batch after operation created + await jsonLightBatchWriter.WriteStartBatchAsync(); + })); + + Assert.Equal(Strings.ODataBatchWriter_InvalidTransitionFromOperationCreated, exception.Message); + } + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForInvalidTransitionFromChangesetStarted() + { + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.WriteStartChangesetAsync(); + // Try to start writing batch after operation created + await jsonLightBatchWriter.WriteStartBatchAsync(); + })); + + Assert.Equal(Strings.ODataBatchWriter_InvalidTransitionFromChangeSetStarted, exception.Message); + } + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForSynchronousOutputContext() + { + var jsonLightOutputContext = CreateJsonLightOutputContext(/*writingRequest*/ true, /*asynchronous*/ false); + var jsonLightBatchWriter = new ODataJsonLightBatchWriter(jsonLightOutputContext); + // Try to asynchronously start writing batch with an output context intended for synchronous writing + var exception = await Assert.ThrowsAsync( + () => jsonLightBatchWriter.WriteStartBatchAsync()); + + Assert.Equal(Strings.ODataBatchWriter_AsyncCallOnSyncWriter, exception.Message); + } + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForGetMethodInChangeset() + { + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.WriteStartChangesetAsync(); + // Try to create operation request message for a GET + await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "GET", new Uri($"{ServiceUri}/Customers(1)"), "1"); + })); + + Assert.Equal(Strings.ODataBatch_InvalidHttpMethodForChangeSetRequest("GET"), exception.Message); + } + + [Fact] + public async Task WriteBatchRequestWithChangesetAsync_ThrowsExceptionForContentIdIsNull() + { + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.WriteStartChangesetAsync(); + // Try to create operation request message with null content id + await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "PUT", new Uri($"{ServiceUri}/Customers(1)"), /*contentId*/ null); + })); + + Assert.Equal(Strings.ODataBatchOperationHeaderDictionary_KeyNotFound(ODataConstants.ContentIdHeader), exception.Message); + } + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForMaxPartsPerBatchLimitExceeded() + { + this.settings.MessageQuotas.MaxPartsPerBatch = 1; + + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.WriteStartChangesetAsync(); + await jsonLightBatchWriter.WriteEndChangesetAsync(); + await jsonLightBatchWriter.WriteStartChangesetAsync(); + })); + + Assert.Equal(Strings.ODataBatchWriter_MaxBatchSizeExceeded(1), exception.Message); + } + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForMaxOperationsPerChangesetLimitExceeded() + { + this.settings.MessageQuotas.MaxOperationsPerChangeset = 1; + + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.WriteStartChangesetAsync(); + await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "PUT", new Uri($"{ServiceUri}/Customers(1)"), "1"); + await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "PUT", new Uri($"{ServiceUri}/Customers(1)"), "2"); + })); + + Assert.Equal(Strings.ODataBatchWriter_MaxChangeSetSizeExceeded(1), exception.Message); + } + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForDuplicateContentId() + { + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "PUT", new Uri($"{ServiceUri}/Customers(1)"), "1"); + // Try to create operation request message with similar content id + await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "PUT", new Uri($"{ServiceUri}/Customers(1)"), "1"); + })); + + Assert.Equal(Strings.ODataBatchWriter_DuplicateContentIDsNotAllowed("1"), exception.Message); + } + + [Fact] + public async Task WriteBatchRequestAsync_ThrowsExceptionForContentIdNotInTheSameAtomicityGroup() + { + var exception = await Assert.ThrowsAsync( + () => SetupJsonLightBatchWriterAndRunTestAsync( + async (jsonLightBatchWriter) => + { + await jsonLightBatchWriter.WriteStartBatchAsync(); + await jsonLightBatchWriter.WriteStartChangesetAsync("fd04fc24"); + + await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "POST", new Uri($"{ServiceUri}/Customers"), "1"); + + await jsonLightBatchWriter.WriteEndChangesetAsync(); + await jsonLightBatchWriter.WriteStartChangesetAsync("b62a2456"); + + // Operation request depends on content id from different atomicity group + var dependsOnIds = new List { "1" }; + await jsonLightBatchWriter.CreateOperationRequestMessageAsync( + "POST", new Uri($"{ServiceUri}/Orders"), "2", BatchPayloadUriOption.AbsoluteUri, dependsOnIds); + })); + + Assert.Equal(Strings.ODataBatchReader_DependsOnRequestIdIsPartOfAtomicityGroupNotAllowed("2", "fd04fc24"), exception.Message); + } + + #endregion Exception Cases + + /// + /// Sets up an ODataJsonLightBatchWriter, + /// then runs the given test code asynchronously, + /// then flushes and reads the stream back as a string for customized verification. + /// + private async Task SetupJsonLightBatchWriterAndRunTestAsync( + Func func, + bool writingRequest = true) + { + var jsonLightOutputContext = CreateJsonLightOutputContext(writingRequest, /*asynchronous*/ true); + var jsonLightBatchWriter = new ODataJsonLightBatchWriter(jsonLightOutputContext); + + await func(jsonLightBatchWriter); + + this.stream.Position = 0; + return await new StreamReader(this.stream).ReadToEndAsync(); + } + + private ODataJsonLightOutputContext CreateJsonLightOutputContext(bool writingRequest = true, bool asynchronous = false) + { + var messageInfo = new ODataMessageInfo + { + MessageStream = this.stream, + MediaType = this.mediaType, + Encoding = this.encoding, + IsResponse = !writingRequest, + IsAsync = asynchronous, + Model = this.model + }; + + return new ODataJsonLightOutputContext(messageInfo, this.settings); + } + + #region Helper Methods + + private static ODataResource CreateCustomerResource(int customerId) + { + return new ODataResource + { + TypeName = "NS.Customer", + Properties = new List + { + new ODataProperty + { + Name = "Id", + Value = customerId, + SerializationInfo = new ODataPropertySerializationInfo { PropertyKind = ODataPropertyKind.Key } + }, + new ODataProperty { Name = "Name", Value = $"Customer {customerId}" }, + new ODataProperty { Name = "Type", Value = new ODataEnumValue("Retail") } + }, + SerializationInfo = new ODataResourceSerializationInfo + { + NavigationSourceName = "Customers", + ExpectedTypeName = "NS.Customer", + NavigationSourceEntityTypeName = "NS.Customer", + NavigationSourceKind = EdmNavigationSourceKind.EntitySet + } + }; + } + + private static ODataResource CreateOrderResource(int orderId) + { + return new ODataResource + { + TypeName = "NS.Order", + Properties = new List + { + new ODataProperty + { + Name = "Id", + Value = orderId, + SerializationInfo = new ODataPropertySerializationInfo { PropertyKind = ODataPropertyKind.Key } + }, + new ODataProperty { Name = "CustomerId", Value = 1 }, + new ODataProperty { Name = "Amount", Value = 13M } + }, + SerializationInfo = new ODataResourceSerializationInfo + { + NavigationSourceName = "Orders", + ExpectedTypeName = "NS.Order", + NavigationSourceEntityTypeName = "NS.Order", + NavigationSourceKind = EdmNavigationSourceKind.EntitySet + } + }; + } + + #endregion Helper Methods + } +}