diff --git a/extensions/Worker.Extensions.ServiceBus/src/Properties/AssemblyInfo.cs b/extensions/Worker.Extensions.ServiceBus/src/Properties/AssemblyInfo.cs index 26b1b7c2e..4889774e6 100644 --- a/extensions/Worker.Extensions.ServiceBus/src/Properties/AssemblyInfo.cs +++ b/extensions/Worker.Extensions.ServiceBus/src/Properties/AssemblyInfo.cs @@ -4,5 +4,5 @@ using System.Runtime.CompilerServices; using Microsoft.Azure.Functions.Worker.Extensions.Abstractions; -[assembly: ExtensionInformation("Microsoft.Azure.WebJobs.Extensions.ServiceBus", "5.12.0")] +[assembly: ExtensionInformation("Microsoft.Azure.WebJobs.Extensions.ServiceBus", "5.13.2")] [assembly: InternalsVisibleTo("Microsoft.Azure.Functions.Worker.Extensions.Tests, PublicKey=00240000048000009400000006020000002400005253413100040000010001005148be37ac1d9f58bd40a2e472c9d380d635b6048278f7d47480b08c928858f0f7fe17a6e4ce98da0e7a7f0b8c308aecd9e9b02d7e9680a5b5b75ac7773cec096fbbc64aebd429e77cb5f89a569a79b28e9c76426783f624b6b70327eb37341eb498a2c3918af97c4860db6cdca4732787150841e395a29cfacb959c1fd971c1")] diff --git a/extensions/Worker.Extensions.ServiceBus/src/Proto/settlement.proto b/extensions/Worker.Extensions.ServiceBus/src/Proto/settlement.proto new file mode 100644 index 000000000..969e94357 --- /dev/null +++ b/extensions/Worker.Extensions.ServiceBus/src/Proto/settlement.proto @@ -0,0 +1,47 @@ +syntax = "proto3"; + +import "google/protobuf/empty.proto"; +import "google/protobuf/wrappers.proto"; + +// this namespace will be shared between isolated worker and WebJobs extension so make it somewhat generic +option csharp_namespace = "Microsoft.Azure.ServiceBus.Grpc"; + +// The settlement service definition. +service Settlement { + // Completes a message + rpc Complete (CompleteRequest) returns (google.protobuf.Empty) {} + + // Abandons a message + rpc Abandon (AbandonRequest) returns (google.protobuf.Empty) {} + + // Deadletters a message + rpc Deadletter (DeadletterRequest) returns (google.protobuf.Empty) {} + + // Defers a message + rpc Defer (DeferRequest) returns (google.protobuf.Empty) {} +} + +// The complete message request containing the locktoken. +message CompleteRequest { + string locktoken = 1; +} + +// The abandon message request containing the locktoken and properties to modify. +message AbandonRequest { + string locktoken = 1; + bytes propertiesToModify = 2; +} + +// The deadletter message request containing the locktoken and properties to modify along with the reason/description. +message DeadletterRequest { + string locktoken = 1; + bytes propertiesToModify = 2; + google.protobuf.StringValue deadletterReason = 3; + google.protobuf.StringValue deadletterErrorDescription = 4; +} + +// The defer message request containing the locktoken and properties to modify. +message DeferRequest { + string locktoken = 1; + bytes propertiesToModify = 2; +} \ No newline at end of file diff --git a/extensions/Worker.Extensions.ServiceBus/src/ServiceBusExtensionStartup.cs b/extensions/Worker.Extensions.ServiceBus/src/ServiceBusExtensionStartup.cs new file mode 100644 index 000000000..5be4b027b --- /dev/null +++ b/extensions/Worker.Extensions.ServiceBus/src/ServiceBusExtensionStartup.cs @@ -0,0 +1,27 @@ +// Copyright (c) Jacob Viau. All rights reserved. +// Licensed under the MIT. See LICENSE file in the project root for full license information. + +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Core; +using Microsoft.Azure.Functions.Worker.Extensions.Rpc; +using Microsoft.Azure.ServiceBus.Grpc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +[assembly: WorkerExtensionStartup(typeof(ServiceBusExtensionStartup))] + +namespace Microsoft.Azure.Functions.Worker +{ + public sealed class ServiceBusExtensionStartup : WorkerExtensionStartup + { + public override void Configure(IFunctionsWorkerApplicationBuilder applicationBuilder) + { + applicationBuilder.Services.AddTransient(sp => + { + IOptions options = sp.GetRequiredService>(); + return new Settlement.SettlementClient(options.Value.CallInvoker); + }); + applicationBuilder.Services.AddWorkerRpc(); + } + } +} \ No newline at end of file diff --git a/extensions/Worker.Extensions.ServiceBus/src/ServiceBusMessageActions.cs b/extensions/Worker.Extensions.ServiceBus/src/ServiceBusMessageActions.cs new file mode 100644 index 000000000..d02de18db --- /dev/null +++ b/extensions/Worker.Extensions.ServiceBus/src/ServiceBusMessageActions.cs @@ -0,0 +1,381 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Runtime.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Azure.Messaging.ServiceBus; +using Google.Protobuf; +using Microsoft.Azure.Amqp; +using Microsoft.Azure.Amqp.Encoding; +using Microsoft.Azure.Functions.Worker.Converters; +using Microsoft.Azure.ServiceBus.Grpc; +using Type = System.Type; + +namespace Microsoft.Azure.Functions.Worker +{ + [InputConverter(typeof(ServiceBusMessageActionsConverter))] + public class ServiceBusMessageActions + { + private readonly Settlement.SettlementClient _settlement; + + /// The size, in bytes, to use as a buffer for stream operations. + private const int StreamBufferSizeInBytes = 512; + + /// The set of mappings from CLR types to AMQP types for property values. + private static readonly IReadOnlyDictionary AmqpPropertyTypeMap = new Dictionary + { + { typeof(byte), AmqpType.Byte }, + { typeof(sbyte), AmqpType.SByte }, + { typeof(char), AmqpType.Char }, + { typeof(short), AmqpType.Int16 }, + { typeof(ushort), AmqpType.UInt16 }, + { typeof(int), AmqpType.Int32 }, + { typeof(uint), AmqpType.UInt32 }, + { typeof(long), AmqpType.Int64 }, + { typeof(ulong), AmqpType.UInt64 }, + { typeof(float), AmqpType.Single }, + { typeof(double), AmqpType.Double }, + { typeof(decimal), AmqpType.Decimal }, + { typeof(bool), AmqpType.Boolean }, + { typeof(Guid), AmqpType.Guid }, + { typeof(string), AmqpType.String }, + { typeof(Uri), AmqpType.Uri }, + { typeof(DateTime), AmqpType.DateTime }, + { typeof(DateTimeOffset), AmqpType.DateTimeOffset }, + { typeof(TimeSpan), AmqpType.TimeSpan }, + }; + + internal ServiceBusMessageActions(Settlement.SettlementClient settlement) + { + _settlement = settlement; + } + + /// + /// Initializes a new instance of the class for mocking use in testing. + /// + /// + /// This constructor exists only to support mocking. When used, class state is not fully initialized, and + /// will not function correctly; virtual members are meant to be mocked. + /// + protected ServiceBusMessageActions() + { + } + + /// + public virtual async Task CompleteMessageAsync( + ServiceBusReceivedMessage message, + CancellationToken cancellationToken = default) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message)); + } + + await _settlement.CompleteAsync(new() { Locktoken = message.LockToken }, cancellationToken: cancellationToken); + } + + /// + public virtual async Task AbandonMessageAsync( + ServiceBusReceivedMessage message, + IDictionary? propertiesToModify = default, + CancellationToken cancellationToken = default) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message)); + } + + var request = new AbandonRequest() + { + Locktoken = message.LockToken, + }; + if (propertiesToModify != null) + { + request.PropertiesToModify = ConvertToByteString(propertiesToModify); + } + await _settlement.AbandonAsync(request, cancellationToken: cancellationToken); + } + + /// + public virtual async Task DeadLetterMessageAsync( + ServiceBusReceivedMessage message, + Dictionary? propertiesToModify = default, + string? deadLetterReason = default, + string? deadLetterErrorDescription = default, + CancellationToken cancellationToken = default) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message)); + } + + var request = new DeadletterRequest() + { + Locktoken = message.LockToken, + DeadletterReason = deadLetterReason, + DeadletterErrorDescription = deadLetterErrorDescription + }; + if (propertiesToModify != null) + { + request.PropertiesToModify = ConvertToByteString(propertiesToModify); + } + await _settlement.DeadletterAsync(request, cancellationToken: cancellationToken); + } + + /// + public virtual async Task DeferMessageAsync( + ServiceBusReceivedMessage message, + IDictionary? propertiesToModify = default, + CancellationToken cancellationToken = default) + { + if (message == null) + { + throw new ArgumentNullException(nameof(message)); + } + + var request = new DeferRequest() + { + Locktoken = message.LockToken, + }; + if (propertiesToModify != null) + { + request.PropertiesToModify = ConvertToByteString(propertiesToModify); + } + await _settlement.DeferAsync(request, cancellationToken: cancellationToken); + } + + internal static ByteString ConvertToByteString(IDictionary propertiesToModify) + { + var map = new AmqpMap(); + foreach (KeyValuePair kvp in propertiesToModify) + { + if (TryCreateAmqpPropertyValueFromNetProperty(kvp.Value, out var amqpValue)) + { + map[new MapKey(kvp.Key)] = amqpValue; + } + else + { + throw new NotSupportedException( + string.Format( + CultureInfo.CurrentCulture, + "The key `{0}` has a value of type `{1}` which is not supported for AMQP transport." + + "The list of supported types can be found here: https://learn.microsoft.com/dotnet/api/azure.messaging.servicebus.servicebusmessage.applicationproperties?view=azure-dotnet#remarks", + kvp.Key, + kvp.Value?.GetType().Name)); + } + } + + using ByteBuffer buffer = new ByteBuffer(256, true); + AmqpCodec.EncodeMap(map, buffer); + return ByteString.CopyFrom(buffer.Buffer, 0, buffer.Length); + } + + /// + /// Attempts to create an AMQP property value for a given event property. + /// + /// + /// The value of the event property to create an AMQP property value for. + /// The AMQP property value that was created. + /// true to allow an AMQP map to be translated to additional types supported only by a message body; otherwise, false. + /// + /// true if an AMQP property value was able to be created; otherwise, false. + /// + private static bool TryCreateAmqpPropertyValueFromNetProperty( + object? propertyValue, + out object? amqpPropertyValue, + bool allowBodyTypes = false) + { + amqpPropertyValue = null; + + if (propertyValue == null) + { + return true; + } + + switch (GetTypeIdentifier(propertyValue)) + { + case AmqpType.Byte: + case AmqpType.SByte: + case AmqpType.Int16: + case AmqpType.Int32: + case AmqpType.Int64: + case AmqpType.UInt16: + case AmqpType.UInt32: + case AmqpType.UInt64: + case AmqpType.Single: + case AmqpType.Double: + case AmqpType.Boolean: + case AmqpType.Decimal: + case AmqpType.Char: + case AmqpType.Guid: + case AmqpType.DateTime: + case AmqpType.String: + amqpPropertyValue = propertyValue; + break; + + case AmqpType.Stream: + case AmqpType.Unknown when propertyValue is Stream: + amqpPropertyValue = ReadStreamToArraySegment((Stream)propertyValue); + break; + + case AmqpType.Uri: + amqpPropertyValue = new DescribedType((AmqpSymbol)AmqpMessageConstants.Uri, ((Uri)propertyValue).AbsoluteUri); + break; + + case AmqpType.DateTimeOffset: + amqpPropertyValue = new DescribedType((AmqpSymbol)AmqpMessageConstants.DateTimeOffset, ((DateTimeOffset)propertyValue).UtcTicks); + break; + + case AmqpType.TimeSpan: + amqpPropertyValue = new DescribedType((AmqpSymbol)AmqpMessageConstants.TimeSpan, ((TimeSpan)propertyValue).Ticks); + break; + + case AmqpType.Unknown when allowBodyTypes && propertyValue is byte[] byteArray: + amqpPropertyValue = new ArraySegment(byteArray); + break; + + case AmqpType.Unknown when allowBodyTypes && propertyValue is IDictionary dict: + amqpPropertyValue = new AmqpMap(dict); + break; + + case AmqpType.Unknown when allowBodyTypes && propertyValue is IList: + amqpPropertyValue = propertyValue; + break; + + case AmqpType.Unknown: + var exception = new SerializationException(string.Format(CultureInfo.CurrentCulture, "Serialization failed due to an unsupported type, {0}.", propertyValue.GetType().FullName)); + throw exception; + } + + return (amqpPropertyValue != null); + } + + /// + /// Converts a stream to an representation. + /// + /// + /// The stream to read and capture in memory. + /// + /// The containing the stream data. + /// + private static ArraySegment ReadStreamToArraySegment(Stream stream) + { + switch (stream) + { + case { Length: < 1 }: + return default; + + case BufferListStream bufferListStream: + return bufferListStream.ReadBytes((int)stream.Length); + + case MemoryStream memStreamSource: + { + using var memStreamCopy = new MemoryStream((int)(memStreamSource.Length - memStreamSource.Position)); + memStreamSource.CopyTo(memStreamCopy, StreamBufferSizeInBytes); + if (!memStreamCopy.TryGetBuffer(out ArraySegment segment)) + { + segment = new ArraySegment(memStreamCopy.ToArray()); + } + return segment; + } + + default: + { + using var memStreamCopy = new MemoryStream(StreamBufferSizeInBytes); + stream.CopyTo(memStreamCopy, StreamBufferSizeInBytes); + if (!memStreamCopy.TryGetBuffer(out ArraySegment segment)) + { + segment = new ArraySegment(memStreamCopy.ToArray()); + } + return segment; + } + } + } + + /// + /// Represents the supported AMQP property types. + /// + /// + /// + /// WARNING: + /// These values are synchronized between Azure services and the client + /// library. You must consult with the Event Hubs/Service Bus service team before making + /// changes, including adding a new member. + /// + /// When adding a new member, remember to always do so before the Unknown + /// member. + /// + /// + private enum AmqpType + { + Null, + Byte, + SByte, + Char, + Int16, + UInt16, + Int32, + UInt32, + Int64, + UInt64, + Single, + Double, + Decimal, + Boolean, + Guid, + String, + Uri, + DateTime, + DateTimeOffset, + TimeSpan, + Stream, + Unknown + } + + /// + /// Gets the AMQP property type identifier for a given + /// value. + /// + /// + /// The value to determine the type identifier for. + /// + /// The that was identified for the given . + /// + private static AmqpType GetTypeIdentifier(object? value) => ToAmqpPropertyType(value?.GetType()); + + /// + /// Translates the given to the corresponding + /// . + /// + /// + /// The type to convert to an AMQP type. + /// + /// The AMQP property type that best matches the specified . + /// + private static AmqpType ToAmqpPropertyType(Type? type) + { + if (type == null) + { + return AmqpType.Null; + } + + if (AmqpPropertyTypeMap.TryGetValue(type, out AmqpType amqpType)) + { + return amqpType; + } + + return AmqpType.Unknown; + } + + internal static class AmqpMessageConstants + { + public const string Vendor = "com.microsoft"; + public const string TimeSpan = Vendor + ":timespan"; + public const string Uri = Vendor + ":uri"; + public const string DateTimeOffset = Vendor + ":datetime-offset"; + } + } +} \ No newline at end of file diff --git a/extensions/Worker.Extensions.ServiceBus/src/ServiceBusMessageActionsConverter.cs b/extensions/Worker.Extensions.ServiceBus/src/ServiceBusMessageActionsConverter.cs new file mode 100644 index 000000000..cfdf3beb1 --- /dev/null +++ b/extensions/Worker.Extensions.ServiceBus/src/ServiceBusMessageActionsConverter.cs @@ -0,0 +1,35 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT License. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker.Converters; +using Microsoft.Azure.ServiceBus.Grpc; + +namespace Microsoft.Azure.Functions.Worker +{ + /// + /// Converter to bind to type parameter. + /// + internal class ServiceBusMessageActionsConverter : IInputConverter + { + private readonly Settlement.SettlementClient _settlement; + + public ServiceBusMessageActionsConverter(Settlement.SettlementClient settlement) + { + _settlement = settlement; + } + + public ValueTask ConvertAsync(ConverterContext context) + { + try + { + return new ValueTask(ConversionResult.Success(new ServiceBusMessageActions(_settlement))); + } + catch (Exception exception) + { + return new ValueTask(ConversionResult.Failed(exception)); + } + } + } +} \ No newline at end of file diff --git a/extensions/Worker.Extensions.ServiceBus/src/Worker.Extensions.ServiceBus.csproj b/extensions/Worker.Extensions.ServiceBus/src/Worker.Extensions.ServiceBus.csproj index b08a98d49..02395860a 100644 --- a/extensions/Worker.Extensions.ServiceBus/src/Worker.Extensions.ServiceBus.csproj +++ b/extensions/Worker.Extensions.ServiceBus/src/Worker.Extensions.ServiceBus.csproj @@ -15,17 +15,24 @@ - + + + + + + + + diff --git a/samples/Extensions/Extensions.csproj b/samples/Extensions/Extensions.csproj index 0d66a0883..f37fda33a 100644 --- a/samples/Extensions/Extensions.csproj +++ b/samples/Extensions/Extensions.csproj @@ -13,7 +13,7 @@ - + @@ -32,4 +32,9 @@ Never + + + + + \ No newline at end of file diff --git a/samples/Extensions/ServiceBus/ServiceBusReceivedMessageFunctions.cs b/samples/Extensions/ServiceBus/ServiceBusReceivedMessageFunctions.cs index 838ee63b3..ab4473e2b 100644 --- a/samples/Extensions/ServiceBus/ServiceBusReceivedMessageFunctions.cs +++ b/samples/Extensions/ServiceBus/ServiceBusReceivedMessageFunctions.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. See License.txt in the project root for license information. using System; +using System.Threading.Tasks; using Azure.Messaging.ServiceBus; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; @@ -74,5 +75,20 @@ public void ServiceBusReceivedMessageWithStringProperties( _logger.LogInformation("Delivery Count: {count}", message.DeliveryCount); _logger.LogInformation("Delivery Count: {count}", deliveryCount); } + // + [Function(nameof(ServiceBusReceivedMessageFunction))] + public async Task ServiceBusMessageActionsFunction( + [ServiceBusTrigger("queue", Connection = "ServiceBusConnection")] + ServiceBusReceivedMessage message, + ServiceBusMessageActions messageActions) + { + _logger.LogInformation("Message ID: {id}", message.MessageId); + _logger.LogInformation("Message Body: {body}", message.Body); + _logger.LogInformation("Message Content-Type: {contentType}", message.ContentType); + + // Complete the message + await messageActions.CompleteMessageAsync(message); + } + // } } diff --git a/test/Worker.Extensions.Tests/ServiceBus/ServiceBusMessageActionsTests.cs b/test/Worker.Extensions.Tests/ServiceBus/ServiceBusMessageActionsTests.cs new file mode 100644 index 000000000..04096f40a --- /dev/null +++ b/test/Worker.Extensions.Tests/ServiceBus/ServiceBusMessageActionsTests.cs @@ -0,0 +1,133 @@ +// Copyright (c) .NET Foundation. 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.Threading; +using System.Threading.Tasks; +using Azure.Messaging.ServiceBus; +using Google.Protobuf; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Microsoft.Azure.ServiceBus.Grpc; +using Xunit; + +namespace Microsoft.Azure.Functions.Worker.Extensions.Tests +{ + public class ServiceBusMessageActionsTests + { + [Fact] + public async Task CanCompleteMessage() + { + var message = ServiceBusModelFactory.ServiceBusReceivedMessage(lockTokenGuid: Guid.NewGuid()); + var messageActions = new ServiceBusMessageActions(new MockSettlementClient(message.LockToken)); + await messageActions.CompleteMessageAsync(message); + } + + [Fact] + public async Task CanAbandonMessage() + { + var message = ServiceBusModelFactory.ServiceBusReceivedMessage(lockTokenGuid: Guid.NewGuid()); + var properties = new Dictionary() + { + { "int", 1 }, + { "string", "foo"}, + { "timespan", TimeSpan.FromSeconds(1) }, + { "datetime", DateTime.UtcNow }, + { "datetimeoffset", DateTimeOffset.UtcNow }, + { "guid", Guid.NewGuid() } + }; + var messageActions = new ServiceBusMessageActions(new MockSettlementClient(message.LockToken, properties)); + await messageActions.AbandonMessageAsync(message, properties); + } + + [Fact] + public async Task CanDeadLetterMessage() + { + var message = ServiceBusModelFactory.ServiceBusReceivedMessage(lockTokenGuid: Guid.NewGuid()); + var properties = new Dictionary() + { + { "int", 1 }, + { "string", "foo"}, + { "timespan", TimeSpan.FromSeconds(1) }, + { "datetime", DateTime.UtcNow }, + { "datetimeoffset", DateTimeOffset.UtcNow }, + { "guid", Guid.NewGuid() } + }; + var messageActions = new ServiceBusMessageActions(new MockSettlementClient(message.LockToken, properties)); + await messageActions.DeadLetterMessageAsync(message, properties); + } + + [Fact] + public async Task CanDeferMessage() + { + var message = ServiceBusModelFactory.ServiceBusReceivedMessage(lockTokenGuid: Guid.NewGuid()); + var properties = new Dictionary() + { + { "int", 1 }, + { "string", "foo"}, + { "timespan", TimeSpan.FromSeconds(1) }, + { "datetime", DateTime.UtcNow }, + { "datetimeoffset", DateTimeOffset.UtcNow }, + { "guid", Guid.NewGuid() } + }; + var messageActions = new ServiceBusMessageActions(new MockSettlementClient(message.LockToken, properties)); + await messageActions.DeferMessageAsync(message, properties); + } + + [Fact] + public async Task PassingNullMessageThrows() + { + var messageActions = new ServiceBusMessageActions(new MockSettlementClient(null)); + await Assert.ThrowsAsync(async () => await messageActions.CompleteMessageAsync(null)); + await Assert.ThrowsAsync(async () => await messageActions.AbandonMessageAsync(null)); + await Assert.ThrowsAsync(async () => await messageActions.DeadLetterMessageAsync(null)); + await Assert.ThrowsAsync(async () => await messageActions.DeferMessageAsync(null)); + } + + private class MockSettlementClient : Settlement.SettlementClient + { + private readonly string _lockToken; + private readonly ByteString _propertiesToModify; + public MockSettlementClient(string lockToken, IDictionary propertiesToModify = default) : base() + { + _lockToken = lockToken; + if (propertiesToModify != null) + { + _propertiesToModify = ServiceBusMessageActions.ConvertToByteString(propertiesToModify); + } + } + + public override AsyncUnaryCall CompleteAsync(CompleteRequest request, Metadata headers = null, DateTime? deadline = null, + CancellationToken cancellationToken = default(CancellationToken)) + { + Assert.Equal(_lockToken, request.Locktoken); + return new AsyncUnaryCall(Task.FromResult(new Empty()), Task.FromResult(new Metadata()), () => Status.DefaultSuccess, () => new Metadata(), () => { }); + } + + public override AsyncUnaryCall AbandonAsync(AbandonRequest request, Metadata headers = null, DateTime? deadline = null, + CancellationToken cancellationToken = default(CancellationToken)) + { + Assert.Equal(_lockToken, request.Locktoken); + Assert.Equal(_propertiesToModify, request.PropertiesToModify); + return new AsyncUnaryCall(Task.FromResult(new Empty()), Task.FromResult(new Metadata()), () => Status.DefaultSuccess, () => new Metadata(), () => { }); + } + + public override AsyncUnaryCall DeadletterAsync(DeadletterRequest request, Metadata headers = null, DateTime? deadline = null, + CancellationToken cancellationToken = default(CancellationToken)) + { + Assert.Equal(_lockToken, request.Locktoken); + Assert.Equal(_propertiesToModify, request.PropertiesToModify); + return new AsyncUnaryCall(Task.FromResult(new Empty()), Task.FromResult(new Metadata()), () => Status.DefaultSuccess, () => new Metadata(), () => { }); + } + + public override AsyncUnaryCall DeferAsync(DeferRequest request, Metadata headers = null, DateTime? deadline = null, + CancellationToken cancellationToken = default(CancellationToken)) + { + Assert.Equal(_lockToken, request.Locktoken); + Assert.Equal(_propertiesToModify, request.PropertiesToModify); + return new AsyncUnaryCall(Task.FromResult(new Empty()), Task.FromResult(new Metadata()), () => Status.DefaultSuccess, () => new Metadata(), () => { }); + } + } + } +} \ No newline at end of file