From 03b8a9f26248929af76f7007c03c59152d0e4f3d Mon Sep 17 00:00:00 2001 From: Wanpeng Li Date: Fri, 8 Nov 2019 11:21:49 +0800 Subject: [PATCH 01/21] update management sdk version (#85) --- build/dependencies.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/dependencies.props b/build/dependencies.props index ebaf13db..c0f63e2d 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -5,7 +5,7 @@ 0.3.0 - 1.0.0 + 1.2.0 3.0.4 15.8.0 4.9.0 From d27b71bb6ff6b5734048854dde7d4498b8432f94 Mon Sep 17 00:00:00 2001 From: Chenyang Liu Date: Tue, 18 Feb 2020 17:36:11 +0800 Subject: [PATCH 02/21] Add utils, context and constants for trigger (#92) * Add utils context and constants * Seperate proj * Minor update * Use messagepack 2.0 * Revert "Use messagepack 2.0" This reverts commit 584fec8efbc9e7cce321fa10a0a81800d488ac79. * Add messagepack test * Add proj * Minor update * Remove useless method * Remove extra line * Add more tests * Add a TODO on version in protocol --- azure-functions-signalrservice-extension.sln | 34 +- build/dependencies.props | 4 + .../Constants.cs | 25 ++ .../IServerlessProtocol.cs | 27 ++ .../Internal/MemoryBufferWriter.cs | 344 ++++++++++++++++++ .../Internal/MessagePackHelper.cs | 190 ++++++++++ .../Internal/ReadOnlySequenceStream.cs | 102 ++++++ .../JsonServerlessProtocol.cs | 60 +++ .../MessagePackServerlessProtocol.cs | 52 +++ ....Azure.SignalR.Serverless.Protocols.csproj | 13 + .../ServerlessMessage.cs | 35 ++ src/SignalRServiceExtension/Constants.cs | 23 ++ ...e.WebJobs.Extensions.SignalRService.csproj | 4 + .../Context/InvocationContext.cs | 61 ++++ .../Utils/JsonMessageParser.cs | 20 + .../Utils/MessagePackMessageParser.cs | 20 + .../TriggerBindings/Utils/MessageParser.cs | 32 ++ .../BinaryMessageParser.cs | 85 +++++ ....SignalR.Serverless.Protocols.Tests.csproj | 18 + .../ServerlessProtocolTests.cs | 100 +++++ .../TextMessageParser.cs | 32 ++ 21 files changed, 1279 insertions(+), 2 deletions(-) create mode 100644 src/Microsoft.Azure.SignalR.Serverless.Protocols/Constants.cs create mode 100644 src/Microsoft.Azure.SignalR.Serverless.Protocols/IServerlessProtocol.cs create mode 100644 src/Microsoft.Azure.SignalR.Serverless.Protocols/Internal/MemoryBufferWriter.cs create mode 100644 src/Microsoft.Azure.SignalR.Serverless.Protocols/Internal/MessagePackHelper.cs create mode 100644 src/Microsoft.Azure.SignalR.Serverless.Protocols/Internal/ReadOnlySequenceStream.cs create mode 100644 src/Microsoft.Azure.SignalR.Serverless.Protocols/JsonServerlessProtocol.cs create mode 100644 src/Microsoft.Azure.SignalR.Serverless.Protocols/MessagePackServerlessProtocol.cs create mode 100644 src/Microsoft.Azure.SignalR.Serverless.Protocols/Microsoft.Azure.SignalR.Serverless.Protocols.csproj create mode 100644 src/Microsoft.Azure.SignalR.Serverless.Protocols/ServerlessMessage.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/Context/InvocationContext.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/Utils/JsonMessageParser.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/Utils/MessagePackMessageParser.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/Utils/MessageParser.cs create mode 100644 test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/BinaryMessageParser.cs create mode 100644 test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/Microsoft.Azure.SignalR.Serverless.Protocols.Tests.csproj create mode 100644 test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/ServerlessProtocolTests.cs create mode 100644 test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/TextMessageParser.cs diff --git a/azure-functions-signalrservice-extension.sln b/azure-functions-signalrservice-extension.sln index ded0f4da..bba77681 100644 --- a/azure-functions-signalrservice-extension.sln +++ b/azure-functions-signalrservice-extension.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26124.0 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29806.167 MinimumVisualStudioVersion = 15.0.26124.0 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{7005F387-A2ED-42B0-8CE1-41639A6D1E51}" EndProject @@ -22,6 +22,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution version.props = version.props EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Azure.SignalR.Serverless.Protocols", "src\Microsoft.Azure.SignalR.Serverless.Protocols\Microsoft.Azure.SignalR.Serverless.Protocols.csproj", "{B6468EC0-E62B-4037-BB77-461DB3AB6F20}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Azure.SignalR.Serverless.Protocols.Tests", "test\Microsoft.Azure.SignalR.Serverless.Protocols.Tests\Microsoft.Azure.SignalR.Serverless.Protocols.Tests.csproj", "{E796842E-4BE7-48F2-8C77-89B42AE065DB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -56,6 +60,30 @@ Global {CFFE1AEB-0D5A-458E-AA45-8F312B1F37F3}.Release|x64.Build.0 = Release|Any CPU {CFFE1AEB-0D5A-458E-AA45-8F312B1F37F3}.Release|x86.ActiveCfg = Release|Any CPU {CFFE1AEB-0D5A-458E-AA45-8F312B1F37F3}.Release|x86.Build.0 = Release|Any CPU + {B6468EC0-E62B-4037-BB77-461DB3AB6F20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B6468EC0-E62B-4037-BB77-461DB3AB6F20}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B6468EC0-E62B-4037-BB77-461DB3AB6F20}.Debug|x64.ActiveCfg = Debug|Any CPU + {B6468EC0-E62B-4037-BB77-461DB3AB6F20}.Debug|x64.Build.0 = Debug|Any CPU + {B6468EC0-E62B-4037-BB77-461DB3AB6F20}.Debug|x86.ActiveCfg = Debug|Any CPU + {B6468EC0-E62B-4037-BB77-461DB3AB6F20}.Debug|x86.Build.0 = Debug|Any CPU + {B6468EC0-E62B-4037-BB77-461DB3AB6F20}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B6468EC0-E62B-4037-BB77-461DB3AB6F20}.Release|Any CPU.Build.0 = Release|Any CPU + {B6468EC0-E62B-4037-BB77-461DB3AB6F20}.Release|x64.ActiveCfg = Release|Any CPU + {B6468EC0-E62B-4037-BB77-461DB3AB6F20}.Release|x64.Build.0 = Release|Any CPU + {B6468EC0-E62B-4037-BB77-461DB3AB6F20}.Release|x86.ActiveCfg = Release|Any CPU + {B6468EC0-E62B-4037-BB77-461DB3AB6F20}.Release|x86.Build.0 = Release|Any CPU + {E796842E-4BE7-48F2-8C77-89B42AE065DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E796842E-4BE7-48F2-8C77-89B42AE065DB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E796842E-4BE7-48F2-8C77-89B42AE065DB}.Debug|x64.ActiveCfg = Debug|Any CPU + {E796842E-4BE7-48F2-8C77-89B42AE065DB}.Debug|x64.Build.0 = Debug|Any CPU + {E796842E-4BE7-48F2-8C77-89B42AE065DB}.Debug|x86.ActiveCfg = Debug|Any CPU + {E796842E-4BE7-48F2-8C77-89B42AE065DB}.Debug|x86.Build.0 = Debug|Any CPU + {E796842E-4BE7-48F2-8C77-89B42AE065DB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E796842E-4BE7-48F2-8C77-89B42AE065DB}.Release|Any CPU.Build.0 = Release|Any CPU + {E796842E-4BE7-48F2-8C77-89B42AE065DB}.Release|x64.ActiveCfg = Release|Any CPU + {E796842E-4BE7-48F2-8C77-89B42AE065DB}.Release|x64.Build.0 = Release|Any CPU + {E796842E-4BE7-48F2-8C77-89B42AE065DB}.Release|x86.ActiveCfg = Release|Any CPU + {E796842E-4BE7-48F2-8C77-89B42AE065DB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -63,6 +91,8 @@ Global GlobalSection(NestedProjects) = preSolution {27EBF417-718B-40A2-808B-EF6538AEEDC7} = {7005F387-A2ED-42B0-8CE1-41639A6D1E51} {CFFE1AEB-0D5A-458E-AA45-8F312B1F37F3} = {D6082274-DF4A-455D-9EF3-090C74BC96A1} + {B6468EC0-E62B-4037-BB77-461DB3AB6F20} = {7005F387-A2ED-42B0-8CE1-41639A6D1E51} + {E796842E-4BE7-48F2-8C77-89B42AE065DB} = {D6082274-DF4A-455D-9EF3-090C74BC96A1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {227AD9AE-1447-4D8C-A014-50ABEC8E005C} diff --git a/build/dependencies.props b/build/dependencies.props index c0f63e2d..dfbfd172 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -11,6 +11,10 @@ 4.9.0 2.4.0 2.4.0 + 1.9.11 + 11.0.2 + 4.5.3 + diff --git a/src/Microsoft.Azure.SignalR.Serverless.Protocols/Constants.cs b/src/Microsoft.Azure.SignalR.Serverless.Protocols/Constants.cs new file mode 100644 index 00000000..48ae5515 --- /dev/null +++ b/src/Microsoft.Azure.SignalR.Serverless.Protocols/Constants.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Azure.SignalR.Serverless.Protocols +{ + internal static class ServerlessProtocolConstants + { + /// + /// Represents the invocation message type. + /// + public const int InvocationMessageType = 1; + + // Reserve number in HubProtocolConstants + + /// + /// Represents the open connection message type. + /// + public const int OpenConnectionMessageType = 10; + + /// + /// Represents the close connection message type. + /// + public const int CloseConnectionMessageType = 11; + } +} diff --git a/src/Microsoft.Azure.SignalR.Serverless.Protocols/IServerlessProtocol.cs b/src/Microsoft.Azure.SignalR.Serverless.Protocols/IServerlessProtocol.cs new file mode 100644 index 00000000..93fc3918 --- /dev/null +++ b/src/Microsoft.Azure.SignalR.Serverless.Protocols/IServerlessProtocol.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Azure.SignalR.Serverless.Protocols +{ + public interface IServerlessProtocol + { + // TODO: Have a discussion about how to handle version change. + /// + /// Gets the version of the protocol. + /// + int Version { get; } + + /// + /// Creates a new from the specified serialized representation. + /// + /// The serialized representation of the message. + /// When this method returns true, contains the parsed message. + /// A value that is true if the was successfully parsed; otherwise, false. + bool TryParseMessage(ref ReadOnlySequence input, out ServerlessMessage message); + } +} diff --git a/src/Microsoft.Azure.SignalR.Serverless.Protocols/Internal/MemoryBufferWriter.cs b/src/Microsoft.Azure.SignalR.Serverless.Protocols/Internal/MemoryBufferWriter.cs new file mode 100644 index 00000000..e7dcf114 --- /dev/null +++ b/src/Microsoft.Azure.SignalR.Serverless.Protocols/Internal/MemoryBufferWriter.cs @@ -0,0 +1,344 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Internal +{ + /// + /// Copied from https://github.com/dotnet/aspnetcore/blob/master/src/SignalR/common/Shared/MemoryBufferWriter.cs + /// + internal sealed class MemoryBufferWriter : Stream, IBufferWriter + { + [ThreadStatic] + private static MemoryBufferWriter _cachedInstance; + +#if DEBUG + private bool _inUse; +#endif + + private readonly int _minimumSegmentSize; + private int _bytesWritten; + + private List _completedSegments; + private byte[] _currentSegment; + private int _position; + + public MemoryBufferWriter(int minimumSegmentSize = 4096) + { + _minimumSegmentSize = minimumSegmentSize; + } + + public override long Length => _bytesWritten; + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public static MemoryBufferWriter Get() + { + var writer = _cachedInstance; + if (writer == null) + { + writer = new MemoryBufferWriter(); + } + else + { + // Taken off the thread static + _cachedInstance = null; + } +#if DEBUG + if (writer._inUse) + { + throw new InvalidOperationException("The reader wasn't returned!"); + } + + writer._inUse = true; +#endif + + return writer; + } + + public static void Return(MemoryBufferWriter writer) + { + _cachedInstance = writer; +#if DEBUG + writer._inUse = false; +#endif + writer.Reset(); + } + + public void Reset() + { + if (_completedSegments != null) + { + for (var i = 0; i < _completedSegments.Count; i++) + { + _completedSegments[i].Return(); + } + + _completedSegments.Clear(); + } + + if (_currentSegment != null) + { + ArrayPool.Shared.Return(_currentSegment); + _currentSegment = null; + } + + _bytesWritten = 0; + _position = 0; + } + + public void Advance(int count) + { + _bytesWritten += count; + _position += count; + } + + public Memory GetMemory(int sizeHint = 0) + { + EnsureCapacity(sizeHint); + + return _currentSegment.AsMemory(_position, _currentSegment.Length - _position); + } + + public Span GetSpan(int sizeHint = 0) + { + EnsureCapacity(sizeHint); + + return _currentSegment.AsSpan(_position, _currentSegment.Length - _position); + } + + public void CopyTo(IBufferWriter destination) + { + if (_completedSegments != null) + { + // Copy completed segments + var count = _completedSegments.Count; + for (var i = 0; i < count; i++) + { + destination.Write(_completedSegments[i].Span); + } + } + + destination.Write(_currentSegment.AsSpan(0, _position)); + } + + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + if (_completedSegments == null) + { + // There is only one segment so write without awaiting. + return destination.WriteAsync(_currentSegment, 0, _position); + } + + return CopyToSlowAsync(destination); + } + + private void EnsureCapacity(int sizeHint) + { + // This does the Right Thing. It only subtracts _position from the current segment length if it's non-null. + // If _currentSegment is null, it returns 0. + var remainingSize = _currentSegment?.Length - _position ?? 0; + + // If the sizeHint is 0, any capacity will do + // Otherwise, the buffer must have enough space for the entire size hint, or we need to add a segment. + if ((sizeHint == 0 && remainingSize > 0) || (sizeHint > 0 && remainingSize >= sizeHint)) + { + // We have capacity in the current segment + return; + } + + AddSegment(sizeHint); + } + + private void AddSegment(int sizeHint = 0) + { + if (_currentSegment != null) + { + // We're adding a segment to the list + if (_completedSegments == null) + { + _completedSegments = new List(); + } + + // Position might be less than the segment length if there wasn't enough space to satisfy the sizeHint when + // GetMemory was called. In that case we'll take the current segment and call it "completed", but need to + // ignore any empty space in it. + _completedSegments.Add(new CompletedBuffer(_currentSegment, _position)); + } + + // Get a new buffer using the minimum segment size, unless the size hint is larger than a single segment. + _currentSegment = ArrayPool.Shared.Rent(Math.Max(_minimumSegmentSize, sizeHint)); + _position = 0; + } + + private async Task CopyToSlowAsync(Stream destination) + { + if (_completedSegments != null) + { + // Copy full segments + var count = _completedSegments.Count; + for (var i = 0; i < count; i++) + { + var segment = _completedSegments[i]; + await destination.WriteAsync(segment.Buffer, 0, segment.Length); + } + } + + await destination.WriteAsync(_currentSegment, 0, _position); + } + + public byte[] ToArray() + { + if (_currentSegment == null) + { + return Array.Empty(); + } + + var result = new byte[_bytesWritten]; + + var totalWritten = 0; + + if (_completedSegments != null) + { + // Copy full segments + var count = _completedSegments.Count; + for (var i = 0; i < count; i++) + { + var segment = _completedSegments[i]; + segment.Span.CopyTo(result.AsSpan(totalWritten)); + totalWritten += segment.Span.Length; + } + } + + // Copy current incomplete segment + _currentSegment.AsSpan(0, _position).CopyTo(result.AsSpan(totalWritten)); + + return result; + } + + public void CopyTo(Span span) + { + Debug.Assert(span.Length >= _bytesWritten); + + if (_currentSegment == null) + { + return; + } + + var totalWritten = 0; + + if (_completedSegments != null) + { + // Copy full segments + var count = _completedSegments.Count; + for (var i = 0; i < count; i++) + { + var segment = _completedSegments[i]; + segment.Span.CopyTo(span.Slice(totalWritten)); + totalWritten += segment.Span.Length; + } + } + + // Copy current incomplete segment + _currentSegment.AsSpan(0, _position).CopyTo(span.Slice(totalWritten)); + + Debug.Assert(_bytesWritten == totalWritten + _position); + } + + public override void Flush() { } + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void WriteByte(byte value) + { + if (_currentSegment != null && (uint)_position < (uint)_currentSegment.Length) + { + _currentSegment[_position] = value; + } + else + { + AddSegment(); + _currentSegment[0] = value; + } + + _position++; + _bytesWritten++; + } + + public override void Write(byte[] buffer, int offset, int count) + { + var position = _position; + if (_currentSegment != null && position < _currentSegment.Length - count) + { + Buffer.BlockCopy(buffer, offset, _currentSegment, position, count); + + _position = position + count; + _bytesWritten += count; + } + else + { + BuffersExtensions.Write(this, buffer.AsSpan(offset, count)); + } + } + +#if NETCOREAPP2_1 + public override void Write(ReadOnlySpan span) + { + if (_currentSegment != null && span.TryCopyTo(_currentSegment.AsSpan(_position))) + { + _position += span.Length; + _bytesWritten += span.Length; + } + else + { + BuffersExtensions.Write(this, span); + } + } +#endif + + protected override void Dispose(bool disposing) + { + if (disposing) + { + Reset(); + } + } + + /// + /// Holds a byte[] from the pool and a size value. Basically a Memory but guaranteed to be backed by an ArrayPool byte[], so that we know we can return it. + /// + private readonly struct CompletedBuffer + { + public byte[] Buffer { get; } + public int Length { get; } + + public ReadOnlySpan Span => Buffer.AsSpan(0, Length); + + public CompletedBuffer(byte[] buffer, int length) + { + Buffer = buffer; + Length = length; + } + + public void Return() + { + ArrayPool.Shared.Return(Buffer); + } + } + } +} diff --git a/src/Microsoft.Azure.SignalR.Serverless.Protocols/Internal/MessagePackHelper.cs b/src/Microsoft.Azure.SignalR.Serverless.Protocols/Internal/MessagePackHelper.cs new file mode 100644 index 00000000..b37ce921 --- /dev/null +++ b/src/Microsoft.Azure.SignalR.Serverless.Protocols/Internal/MessagePackHelper.cs @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using MessagePack; + +namespace Microsoft.Azure.SignalR.Serverless.Protocols +{ + internal class MessagePackHelper + { + public static void SkipHeaders(byte[] input, ref int offset) + { + var headerCount = ReadMapLength(input, ref offset, "headers"); + if (headerCount > 0) + { + for (var i = 0; i < headerCount; i++) + { + ReadString(input, ref offset, $"headers[{i}].Key"); + ReadString(input, ref offset, $"headers[{i}].Value"); + } + } + } + + public static string ReadInvocationId(byte[] input, ref int offset) + { + return ReadString(input, ref offset, "invocationId"); + } + + public static string ReadTarget(byte[] input, ref int offset) + { + return ReadString(input, ref offset, "target"); + } + + public static object[] ReadArguments(byte[] input, ref int offset) + { + var argumentCount = ReadArrayLength(input, ref offset, "arguments"); + var array = new object[argumentCount]; + for (int i = 0; i < argumentCount; i++) + { + array[i] = ReadObject(input, ref offset); + } + return array; + } + + public static int ReadInt32(byte[] input, ref int offset, string field) + { + Exception msgPackException = null; + try + { + var readInt = MessagePackBinary.ReadInt32(input, offset, out var readSize); + offset += readSize; + return readInt; + } + catch (Exception e) + { + msgPackException = e; + } + + throw new InvalidDataException($"Reading '{field}' as Int32 failed.", msgPackException); + } + + public static string ReadString(byte[] input, ref int offset, string field) + { + Exception msgPackException = null; + try + { + var readString = MessagePackBinary.ReadString(input, offset, out var readSize); + offset += readSize; + return readString; + } + catch (Exception e) + { + msgPackException = e; + } + + throw new InvalidDataException($"Reading '{field}' as String failed.", msgPackException); + } + + public static bool ReadBoolean(byte[] input, ref int offset, string field) + { + Exception msgPackException = null; + try + { + var readBool = MessagePackBinary.ReadBoolean(input, offset, out var readSize); + offset += readSize; + return readBool; + } + catch (Exception e) + { + msgPackException = e; + } + + throw new InvalidDataException($"Reading '{field}' as Boolean failed.", msgPackException); + } + + public static long ReadMapLength(byte[] input, ref int offset, string field) + { + Exception msgPackException = null; + try + { + var readMap = MessagePackBinary.ReadMapHeader(input, offset, out var readSize); + offset += readSize; + return readMap; + } + catch (Exception e) + { + msgPackException = e; + } + + throw new InvalidDataException($"Reading map length for '{field}' failed.", msgPackException); + } + + public static long ReadArrayLength(byte[] input, ref int offset, string field) + { + Exception msgPackException = null; + try + { + var readArray = MessagePackBinary.ReadArrayHeader(input, offset, out var readSize); + offset += readSize; + return readArray; + } + catch (Exception e) + { + msgPackException = e; + } + + throw new InvalidDataException($"Reading array length for '{field}' failed.", msgPackException); + } + + public static object ReadObject(byte[] input, ref int offset) + { + var type = MessagePackBinary.GetMessagePackType(input, offset); + int size; + switch (type) + { + case MessagePackType.Integer: + var intValue = MessagePackBinary.ReadInt64(input, offset, out size); + offset += size; + return intValue; + case MessagePackType.Nil: + MessagePackBinary.ReadNil(input, offset, out size); + offset += size; + return null; + case MessagePackType.Boolean: + var boolValue = MessagePackBinary.ReadBoolean(input, offset, out size); + offset += size; + return boolValue; + case MessagePackType.Float: + var doubleValue = MessagePackBinary.ReadDouble(input, offset, out size); + offset += size; + return doubleValue; + case MessagePackType.String: + var textValue = MessagePackBinary.ReadString(input, offset, out size); + offset += size; + return textValue; + case MessagePackType.Binary: + var binaryValue = MessagePackBinary.ReadBytes(input, offset, out size); + offset += size; + return binaryValue; + case MessagePackType.Array: + var argumentCount = ReadArrayLength(input, ref offset, "arguments"); + var array = new object[argumentCount]; + for (int i = 0; i < argumentCount; i++) + { + array[i] = ReadObject(input, ref offset); + } + return array; + case MessagePackType.Map: + var propertyCount = MessagePackBinary.ReadMapHeader(input, offset, out size); + offset += size; + var map = new Dictionary(); + for (int i = 0; i < propertyCount; i++) + { + textValue = MessagePackBinary.ReadString(input, offset, out size); + offset += size; + var value = ReadObject(input, ref offset); + map[textValue] = value; + } + return map; + case MessagePackType.Extension: + case MessagePackType.Unknown: + default: + return null; + } + } + } +} diff --git a/src/Microsoft.Azure.SignalR.Serverless.Protocols/Internal/ReadOnlySequenceStream.cs b/src/Microsoft.Azure.SignalR.Serverless.Protocols/Internal/ReadOnlySequenceStream.cs new file mode 100644 index 00000000..ed41a6d0 --- /dev/null +++ b/src/Microsoft.Azure.SignalR.Serverless.Protocols/Internal/ReadOnlySequenceStream.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace Microsoft.Azure.SignalR.Serverless.Protocols +{ + internal class ReadOnlySequenceStream : Stream + { + private readonly ReadOnlySequence _sequence; + private SequencePosition _position; + + public ReadOnlySequenceStream(ReadOnlySequence sequence) + { + _sequence = sequence; + _position = _sequence.Start; + } + + public override void Flush() + { + throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + var remain = _sequence.Slice(_position); + var result = remain.Slice(0, Math.Min(count, remain.Length)); + _position = result.End; + result.CopyTo(buffer.AsSpan(offset, count)); + return (int)result.Length; + } + + public override long Seek(long offset, SeekOrigin origin) + { + switch (origin) + { + case SeekOrigin.Begin: + _position = _sequence.GetPosition(offset); + break; + case SeekOrigin.End: + if (offset >= 0) + { + _position = _sequence.GetPosition(offset, _sequence.End); + } + if (offset < 0) + { + _position = _sequence.GetPosition(offset + _sequence.Length); + } + break; + case SeekOrigin.Current: + if (offset >= 0) + { + _position = _sequence.GetPosition(offset, _position); + } + else + { + _position = _sequence.GetPosition(offset + Position); + } + break; + default: + throw new ArgumentOutOfRangeException(); + } + + return Position; + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + public override bool CanRead => true; + + public override bool CanSeek => true; + + public override bool CanWrite => false; + + public override long Length => _sequence.Length; + + public override long Position + { + get => _sequence.Slice(0, _position).Length; + set + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(); + } + _position = _sequence.GetPosition(value); + } + } + } +} diff --git a/src/Microsoft.Azure.SignalR.Serverless.Protocols/JsonServerlessProtocol.cs b/src/Microsoft.Azure.SignalR.Serverless.Protocols/JsonServerlessProtocol.cs new file mode 100644 index 00000000..be8bc059 --- /dev/null +++ b/src/Microsoft.Azure.SignalR.Serverless.Protocols/JsonServerlessProtocol.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Buffers; +using System.IO; +using System.Text; + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.SignalR.Serverless.Protocols +{ + public class JsonServerlessProtocol : IServerlessProtocol + { + private const string TypePropertyName = "type"; + + public int Version => 1; + + public bool TryParseMessage(ref ReadOnlySequence input, out ServerlessMessage message) + { + var textReader = new JsonTextReader(new StreamReader(new ReadOnlySequenceStream(input))); + var jObject = JObject.Load(textReader); + if (jObject.TryGetValue(TypePropertyName, out var token)) + { + var type = token.Value(); + switch (type) + { + case ServerlessProtocolConstants.InvocationMessageType: + message = SafeParseMessage(jObject); + break; + case ServerlessProtocolConstants.OpenConnectionMessageType: + message = SafeParseMessage(jObject); + break; + case ServerlessProtocolConstants.CloseConnectionMessageType: + message = SafeParseMessage(jObject); + break; + default: + message = null; + break; + } + return message != null; + } + message = null; + return false; + } + + private ServerlessMessage SafeParseMessage(JObject jObject) where T : ServerlessMessage + { + try + { + return jObject.ToObject(); + } + catch + { + return null; + } + } + } +} diff --git a/src/Microsoft.Azure.SignalR.Serverless.Protocols/MessagePackServerlessProtocol.cs b/src/Microsoft.Azure.SignalR.Serverless.Protocols/MessagePackServerlessProtocol.cs new file mode 100644 index 00000000..aaca50b8 --- /dev/null +++ b/src/Microsoft.Azure.SignalR.Serverless.Protocols/MessagePackServerlessProtocol.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.IO; + +using MessagePack; + +namespace Microsoft.Azure.SignalR.Serverless.Protocols +{ + public class MessagePackServerlessProtocol : IServerlessProtocol + { + public int Version => 1; + + public bool TryParseMessage(ref ReadOnlySequence input, out ServerlessMessage message) + { + var array = input.ToArray(); + var startOffset = 0; + _ = MessagePackBinary.ReadArrayHeader(array, startOffset, out var readSize); + startOffset += readSize; + var messageType = MessagePackHelper.ReadInt32(array, ref startOffset, "messageType"); + switch (messageType) + { + case ServerlessProtocolConstants.InvocationMessageType: + message = ConvertInvocationMessage(array, ref startOffset); + break; + default: + // TODO:OpenConnectionMessage and CloseConnectionMessage only will be sent in JSON format. It can be added later. + message = null; + break; + } + + return message != null; + } + + private static InvocationMessage ConvertInvocationMessage(byte[] input, ref int offset) + { + var invocationMessage = new InvocationMessage() + { + Type = ServerlessProtocolConstants.InvocationMessageType, + }; + + MessagePackHelper.SkipHeaders(input, ref offset); + invocationMessage.InvocationId = MessagePackHelper.ReadInvocationId(input, ref offset); + invocationMessage.Target = MessagePackHelper.ReadTarget(input, ref offset); + invocationMessage.Arguments = MessagePackHelper.ReadArguments(input, ref offset); + return invocationMessage; + } + } +} diff --git a/src/Microsoft.Azure.SignalR.Serverless.Protocols/Microsoft.Azure.SignalR.Serverless.Protocols.csproj b/src/Microsoft.Azure.SignalR.Serverless.Protocols/Microsoft.Azure.SignalR.Serverless.Protocols.csproj new file mode 100644 index 00000000..da22b89b --- /dev/null +++ b/src/Microsoft.Azure.SignalR.Serverless.Protocols/Microsoft.Azure.SignalR.Serverless.Protocols.csproj @@ -0,0 +1,13 @@ + + + + netstandard2.0 + + + + + + + + + diff --git a/src/Microsoft.Azure.SignalR.Serverless.Protocols/ServerlessMessage.cs b/src/Microsoft.Azure.SignalR.Serverless.Protocols/ServerlessMessage.cs new file mode 100644 index 00000000..3ee0147e --- /dev/null +++ b/src/Microsoft.Azure.SignalR.Serverless.Protocols/ServerlessMessage.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Newtonsoft.Json; + +namespace Microsoft.Azure.SignalR.Serverless.Protocols +{ + public abstract class ServerlessMessage + { + [JsonProperty(PropertyName = "type")] + public int Type { get; set; } + } + + public class InvocationMessage : ServerlessMessage + { + [JsonProperty(PropertyName = "invocationId")] + public string InvocationId { get; set; } + + [JsonProperty(PropertyName = "target")] + public string Target { get; set; } + + [JsonProperty(PropertyName = "arguments")] + public object[] Arguments { get; set; } + } + + public class OpenConnectionMessage : ServerlessMessage + { + } + + public class CloseConnectionMessage : ServerlessMessage + { + [JsonProperty(PropertyName = "error")] + public string Error { get; set; } + } +} diff --git a/src/SignalRServiceExtension/Constants.cs b/src/SignalRServiceExtension/Constants.cs index 2e79b820..aeefd637 100644 --- a/src/SignalRServiceExtension/Constants.cs +++ b/src/SignalRServiceExtension/Constants.cs @@ -7,5 +7,28 @@ internal static class Constants { public const string AzureSignalRConnectionStringName = "AzureSignalRConnectionString"; public const string ServiceTransportTypeName = "AzureSignalRServiceTransportType"; + public const string AsrsHeaderPrefix = "X-ASRS-"; + public const string AsrsConnectionIdHeader = AsrsHeaderPrefix + "Connection-Id"; + public const string AsrsUserClaims = AsrsHeaderPrefix + "User-Claims"; + public const string AsrsUserId = AsrsHeaderPrefix + "User-Id"; + public const string AsrsHubNameHeader = AsrsHeaderPrefix + "Hub"; + public const string AsrsCategory = AsrsHeaderPrefix + "Category"; + public const string AsrsEvent = AsrsHeaderPrefix + "Event"; + public const string AsrsClientQueryString = AsrsHeaderPrefix + "Client-Query"; + public const string AsrsSignature = AsrsHeaderPrefix + "Signature"; + public const string JsonContentType = "application/json"; + public const string MessagePackContentType = "application/x-msgpack"; + + public static class Category + { + public const string Connections = "connections"; + public const string Messages = "messages"; + } + + public static class Events + { + public const string Connect = "connect"; + public const string Disconnect = "disconnect"; + } } } diff --git a/src/SignalRServiceExtension/Microsoft.Azure.WebJobs.Extensions.SignalRService.csproj b/src/SignalRServiceExtension/Microsoft.Azure.WebJobs.Extensions.SignalRService.csproj index e7f5668e..1aa516c8 100644 --- a/src/SignalRServiceExtension/Microsoft.Azure.WebJobs.Extensions.SignalRService.csproj +++ b/src/SignalRServiceExtension/Microsoft.Azure.WebJobs.Extensions.SignalRService.csproj @@ -10,4 +10,8 @@ + + + + diff --git a/src/SignalRServiceExtension/TriggerBindings/Context/InvocationContext.cs b/src/SignalRServiceExtension/TriggerBindings/Context/InvocationContext.cs new file mode 100644 index 00000000..a1543408 --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/Context/InvocationContext.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + public class InvocationContext + { + /// + /// The arguments of invocation message. + /// + public object[] Arguments { get; set; } + + /// + /// The error message of close connection event. + /// Only close connection message can have this property, and it can be empty if connections close with no error. + /// + public string Error { get; set; } + + /// + /// The category of the message. + /// + public string Category { get; set; } + + /// + /// The event of the message. + /// + public string Event { get; set; } + + /// + /// The hub which message belongs to. + /// + public string Hub { get; set; } + + /// + /// The connection-id of the client which send the message. + /// + public string ConnectionId { get; set; } + + /// + /// The user identity of the client which send the message. + /// + public string UserId { get; set; } + + /// + /// The headers of request. + /// + public IDictionary Headers { get; set; } + + /// + /// The query of the request when client connect to the service. + /// + public IDictionary Query { get; set; } + + /// + /// The claims of the client. + /// + public IDictionary Claims { get; set; } + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/Utils/JsonMessageParser.cs b/src/SignalRServiceExtension/TriggerBindings/Utils/JsonMessageParser.cs new file mode 100644 index 00000000..8db0b859 --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/Utils/JsonMessageParser.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Buffers; + +using Microsoft.AspNetCore.SignalR.Protocol; +using Microsoft.Azure.SignalR.Serverless.Protocols; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class JsonMessageParser : MessageParser + { + private static readonly IServerlessProtocol ServerlessProtocol = new JsonServerlessProtocol(); + + public override bool TryParseMessage(ref ReadOnlySequence buffer, out ServerlessMessage message) => + ServerlessProtocol.TryParseMessage(ref buffer, out message); + + public override IHubProtocol Protocol { get; } = new JsonHubProtocol(); + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/Utils/MessagePackMessageParser.cs b/src/SignalRServiceExtension/TriggerBindings/Utils/MessagePackMessageParser.cs new file mode 100644 index 00000000..57253be5 --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/Utils/MessagePackMessageParser.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Buffers; + +using Microsoft.AspNetCore.SignalR.Protocol; +using Microsoft.Azure.SignalR.Serverless.Protocols; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class MessagePackMessageParser : MessageParser + { + private static readonly IServerlessProtocol ServerlessProtocol = new MessagePackServerlessProtocol(); + + public override bool TryParseMessage(ref ReadOnlySequence buffer, out ServerlessMessage message) => + ServerlessProtocol.TryParseMessage(ref buffer, out message); + + public override IHubProtocol Protocol { get; } = new MessagePackHubProtocol(); + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/Utils/MessageParser.cs b/src/SignalRServiceExtension/TriggerBindings/Utils/MessageParser.cs new file mode 100644 index 00000000..5db64bce --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/Utils/MessageParser.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Buffers; +using Microsoft.AspNetCore.SignalR.Protocol; +using Microsoft.Azure.SignalR.Serverless.Protocols; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal abstract class MessageParser + { + public static readonly MessageParser Json = new JsonMessageParser(); + public static readonly MessageParser MessagePack = new MessagePackMessageParser(); + + public static MessageParser GetParser(string protocol) + { + switch (protocol) + { + case Constants.JsonContentType: + return Json; + case Constants.MessagePackContentType: + return MessagePack; + default: + return null; + } + } + + public abstract bool TryParseMessage(ref ReadOnlySequence buffer, out ServerlessMessage message); + + public abstract IHubProtocol Protocol { get; } + } +} diff --git a/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/BinaryMessageParser.cs b/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/BinaryMessageParser.cs new file mode 100644 index 00000000..85d1cc8b --- /dev/null +++ b/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/BinaryMessageParser.cs @@ -0,0 +1,85 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Azure.SignalR.Serverless.Protocols.Tests +{ + internal static class BinaryMessageParser + { + internal const int MaxLengthPrefixSize = 5; + + public static bool TryParseMessage(ref ReadOnlySequence buffer, out ReadOnlySequence payload) + { + if (buffer.IsEmpty) + { + payload = default; + return false; + } + + // The payload starts with a length prefix encoded as a VarInt. VarInts use the most significant bit + // as a marker whether the byte is the last byte of the VarInt or if it spans to the next byte. Bytes + // appear in the reverse order - i.e. the first byte contains the least significant bits of the value + // Examples: + // VarInt: 0x35 - %00110101 - the most significant bit is 0 so the value is %x0110101 i.e. 0x35 (53) + // VarInt: 0x80 0x25 - %10000000 %00101001 - the most significant bit of the first byte is 1 so the + // remaining bits (%x0000000) are the lowest bits of the value. The most significant bit of the second + // byte is 0 meaning this is last byte of the VarInt. The actual value bits (%x0101001) need to be + // prepended to the bits we already read so the values is %01010010000000 i.e. 0x1480 (5248) + // We support paylads up to 2GB so the biggest number we support is 7fffffff which when encoded as + // VarInt is 0xFF 0xFF 0xFF 0xFF 0x07 - hence the maximum length prefix is 5 bytes. + + var length = 0U; + var numBytes = 0; + + var lengthPrefixBuffer = buffer.Slice(0, Math.Min(MaxLengthPrefixSize, buffer.Length)); + var span = GetSpan(lengthPrefixBuffer); + + byte byteRead; + do + { + byteRead = span[numBytes]; + length = length | (((uint)(byteRead & 0x7f)) << (numBytes * 7)); + numBytes++; + } + while (numBytes < lengthPrefixBuffer.Length && ((byteRead & 0x80) != 0)); + + // size bytes are missing + if ((byteRead & 0x80) != 0 && (numBytes < MaxLengthPrefixSize)) + { + payload = default; + return false; + } + + if ((byteRead & 0x80) != 0 || (numBytes == MaxLengthPrefixSize && byteRead > 7)) + { + throw new FormatException("Messages over 2GB in size are not supported."); + } + + // We don't have enough data + if (buffer.Length < length + numBytes) + { + payload = default; + return false; + } + + // Get the payload + payload = buffer.Slice(numBytes, (int)length); + + // Skip the payload + buffer = buffer.Slice(numBytes + (int)length); + return true; + } + + private static ReadOnlySpan GetSpan(in ReadOnlySequence lengthPrefixBuffer) + { + if (lengthPrefixBuffer.IsSingleSegment) + { + return lengthPrefixBuffer.First.Span; + } + + // Should be rare + return lengthPrefixBuffer.ToArray(); + } + } +} diff --git a/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/Microsoft.Azure.SignalR.Serverless.Protocols.Tests.csproj b/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/Microsoft.Azure.SignalR.Serverless.Protocols.Tests.csproj new file mode 100644 index 00000000..6cf07b23 --- /dev/null +++ b/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/Microsoft.Azure.SignalR.Serverless.Protocols.Tests.csproj @@ -0,0 +1,18 @@ + + + + netcoreapp2.1 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/ServerlessProtocolTests.cs b/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/ServerlessProtocolTests.cs new file mode 100644 index 00000000..85411500 --- /dev/null +++ b/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/ServerlessProtocolTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Text; +using Microsoft.AspNetCore.SignalR.Protocol; +using Newtonsoft.Json; +using Xunit; + +namespace Microsoft.Azure.SignalR.Serverless.Protocols.Tests +{ + public class ServerlessProtocolTests + { + public static IEnumerable GetParameters() + { + var protocols = new string[] {"json", "messagepack"}; + foreach (var protocol in protocols) + { + yield return new object[] { protocol, null, Guid.NewGuid().ToString(), new object[0] }; + yield return new object[] { protocol, Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), new object[0] }; + yield return new object[] + { + protocol, + Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), + new object[] {Guid.NewGuid().ToString(), Guid.NewGuid().ToString()} + }; + yield return new object[] + { + protocol, + Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), + new object[] {new object[] {Guid.NewGuid().ToString()}, Guid.NewGuid().ToString()} + }; + yield return new object[] + { + protocol, + Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), new object[] { new Dictionary + { + [Guid.NewGuid().ToString()] = Guid.NewGuid().ToString(), + [Guid.NewGuid().ToString()] = Guid.NewGuid().ToString(), + }} + }; + yield return new object[] + { + protocol, + Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), + new object[] {new object[] { null, Guid.NewGuid().ToString() }} + }; + } + + } + + [Theory] + [MemberData(nameof(GetParameters))] + public void InvocationMessageParseTest(string protocolName, string invocationId, string target, object[] arguments) + { + var message = new AspNetCore.SignalR.Protocol.InvocationMessage(invocationId, target, arguments); + IHubProtocol protocol = protocolName == "json" ? (IHubProtocol)new JsonHubProtocol() : new MessagePackHubProtocol(); + var bytes = new ReadOnlySequence(protocol.GetMessageBytes(message)); + ReadOnlySequence payload; + if (protocolName == "json") + { + TextMessageParser.TryParseMessage(ref bytes, out payload); + } + else + { + BinaryMessageParser.TryParseMessage(ref bytes, out payload); + } + var serverlessProtocol = protocolName == "json" ? (IServerlessProtocol)new JsonServerlessProtocol() : new MessagePackServerlessProtocol(); + Assert.True(serverlessProtocol.TryParseMessage(ref payload, out var parsedMessage)); + var invocationMessage = (InvocationMessage) parsedMessage; + Assert.Equal(1, invocationMessage.Type); + Assert.Equal(invocationId, invocationMessage.InvocationId); + Assert.Equal(target, invocationMessage.Target); + var expected = JsonConvert.SerializeObject(arguments); + var actual = JsonConvert.SerializeObject(invocationMessage.Arguments); + Assert.Equal(expected, actual); + } + + [Fact] + public void OpenConnectionMessageParseTest() + { + var openConnectionPayload = new ReadOnlySequence(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new OpenConnectionMessage { Type = 10 }))); + var serverlessProtocol = new JsonServerlessProtocol(); + Assert.True(serverlessProtocol.TryParseMessage(ref openConnectionPayload, out var message)); + Assert.Equal(typeof(OpenConnectionMessage), message.GetType()); + } + + [Theory] + [InlineData("")] + [InlineData("error")] + [InlineData(null)] + public void CloseConnectionMessageParseTest(string error) + { + var openConnectionPayload = new ReadOnlySequence(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new CloseConnectionMessage() { Type = 11, Error = error}))); + var serverlessProtocol = new JsonServerlessProtocol(); + Assert.True(serverlessProtocol.TryParseMessage(ref openConnectionPayload, out var message)); + Assert.Equal(error, ((CloseConnectionMessage)message).Error); + Assert.Equal(typeof(CloseConnectionMessage), message.GetType()); + } + } +} diff --git a/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/TextMessageParser.cs b/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/TextMessageParser.cs new file mode 100644 index 00000000..2b61868f --- /dev/null +++ b/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/TextMessageParser.cs @@ -0,0 +1,32 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Azure.SignalR.Serverless.Protocols.Tests +{ + /// + /// The same as https://github.com/aspnet/SignalR/blob/release/2.2/src/Common/TextMessageParser.cs + /// + internal static class TextMessageParser + { + public static readonly byte RecordSeparator = 0x1e; + + public static bool TryParseMessage(ref ReadOnlySequence buffer, out ReadOnlySequence payload) + { + var position = buffer.PositionOf(RecordSeparator); + if (position == null) + { + payload = default; + return false; + } + + payload = buffer.Slice(0, position.Value); + + // Skip record separator + buffer = buffer.Slice(buffer.GetPosition(1, position.Value)); + + return true; + } + } +} From e85a3c7fe3eed347e39af54ac5910cd39b2331d5 Mon Sep 17 00:00:00 2001 From: Chenyang Liu Date: Thu, 20 Feb 2020 13:30:14 +0800 Subject: [PATCH 03/21] Add TriggerBinding for SignalR Trigger (#93) * Add trigger binding * Some updates according to comments * Renaming --- .../ISignalRTriggerDispatcher.cs | 17 ++ .../TriggerBindings/NullListener.cs | 35 ++++ .../SignalRTriggerAttribute.cs | 39 ++++ .../TriggerBindings/SignalRTriggerBinding.cs | 170 ++++++++++++++++++ .../SignalRTriggerBindingProvider.cs | 40 +++++ .../TriggerBindings/SignalRTriggerEvent.cs | 20 +++ .../GlobalSuppressions.cs | 6 + .../Trigger/SignalRTriggerTests.cs | 56 ++++++ .../Utils/TestTriggerDispatcher.cs | 31 ++++ 9 files changed, 414 insertions(+) create mode 100644 src/SignalRServiceExtension/TriggerBindings/ISignalRTriggerDispatcher.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/NullListener.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBindingProvider.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/SignalRTriggerEvent.cs create mode 100644 test/SignalRServiceExtension.Tests/GlobalSuppressions.cs create mode 100644 test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerTests.cs create mode 100644 test/SignalRServiceExtension.Tests/Utils/TestTriggerDispatcher.cs diff --git a/src/SignalRServiceExtension/TriggerBindings/ISignalRTriggerDispatcher.cs b/src/SignalRServiceExtension/TriggerBindings/ISignalRTriggerDispatcher.cs new file mode 100644 index 00000000..15af0350 --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/ISignalRTriggerDispatcher.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Executors; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal interface ISignalRTriggerDispatcher + { + void Map((string hubName, string category, string @event) key, ITriggeredFunctionExecutor executor); + + Task ExecuteAsync(HttpRequestMessage req, CancellationToken token = default); + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/NullListener.cs b/src/SignalRServiceExtension/TriggerBindings/NullListener.cs new file mode 100644 index 00000000..f5e4fe97 --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/NullListener.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Listeners; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class NullListener: IListener + { + public NullListener() + { + } + + public void Dispose() + { + } + + public Task StartAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public void Cancel() + { + } + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs new file mode 100644 index 00000000..d34c53bf --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; + +using Microsoft.Azure.WebJobs.Description; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + [AttributeUsage(AttributeTargets.ReturnValue | AttributeTargets.Parameter)] + [Binding] + public class SignalRTriggerAttribute : Attribute + { + /// + /// The hub of request belongs to. + /// + [AutoResolve] + public string HubName { get; set; } + + /// + /// The event of the request. + /// + [AutoResolve] + public string Event { get; set; } + + /// + /// Two optional value: connections and messages + /// + [AutoResolve] + public string Category { get; set; } + + /// + /// Used for messages category. All the name defined in will map to + /// Arguments in InvocationMessage by order. And the name can be used in parameters of method + /// directly. + /// + public string[] ParameterNames { get; set; } + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs new file mode 100644 index 00000000..d1cfdd36 --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Azure.WebJobs.Host.Listeners; +using Microsoft.Azure.WebJobs.Host.Protocols; +using Microsoft.Azure.WebJobs.Host.Triggers; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class SignalRTriggerBinding : ITriggerBinding + { + private const string ReturnParameterKey = "$return"; + + private readonly ParameterInfo _parameterInfo; + private readonly SignalRTriggerAttribute _attribute; + private readonly ISignalRTriggerDispatcher _dispatcher; + + public SignalRTriggerBinding(ParameterInfo parameterInfo, SignalRTriggerAttribute attribute, ISignalRTriggerDispatcher dispatcher) + { + _parameterInfo = parameterInfo ?? throw new ArgumentNullException(nameof(parameterInfo)); + _attribute = attribute ?? throw new ArgumentNullException(nameof(attribute)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + public Task BindAsync(object value, ValueBindingContext context) + { + var bindingData = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (value is SignalRTriggerEvent triggerEvent) + { + var bindingContext = triggerEvent.Context; + // TODO: Add dynamic binding data in bindingData + + return Task.FromResult(new TriggerData(new SignalRTriggerValueProvider(_parameterInfo, bindingContext), bindingData) + { + ReturnValueProvider = triggerEvent.TaskCompletionSource == null ? null : new TriggerReturnValueProvider(triggerEvent.TaskCompletionSource), + }); + } + + return Task.FromResult(null); + } + + public Task CreateListenerAsync(ListenerFactoryContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // It's not a real listener, and it doesn't need a start or close. + _dispatcher.Map((_attribute.HubName, _attribute.Category, _attribute.Event), context.Executor); + return Task.FromResult(new NullListener()); + } + + public ParameterDescriptor ToParameterDescriptor() + { + return new ParameterDescriptor + { + Name = _parameterInfo.Name, + }; + } + + /// + /// Type of object in BindAsync + /// + public Type TriggerValueType => typeof(SignalRTriggerEvent); + + // TODO: Use dynamic contract to deal with parameterName + public IReadOnlyDictionary BindingDataContract => CreateBindingContract(); + + /// + /// Defined what other bindings can use and return value. + /// + private IReadOnlyDictionary CreateBindingContract() + { + var contract = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + //TODO: Add names in parameterNames to contract for binding + { ReturnParameterKey, typeof(object).MakeByRefType() }, + }; + + return contract; + } + + // TODO: Add more supported type + /// + /// A provider that responsible for providing value in various type to be bond to function method parameter. + /// + private class SignalRTriggerValueProvider : IValueBinder + { + private readonly InvocationContext _value; + private readonly ParameterInfo _parameter; + + public SignalRTriggerValueProvider(ParameterInfo parameter, InvocationContext value) + { + _parameter = parameter ?? throw new ArgumentNullException(nameof(parameter)); + _value = value ?? throw new ArgumentNullException(nameof(value)); + } + + public Task GetValueAsync() + { + if (_parameter.ParameterType == typeof(InvocationContext)) + { + return Task.FromResult(_value); + } + else if (_parameter.ParameterType == typeof(object)) + { + return Task.FromResult(JObject.FromObject(_value)); + } + + return Task.FromResult(null); + } + + public string ToInvokeString() + { + return _value.ToString(); + } + + public Type Type => _parameter.GetType(); + + // No use here + public Task SetValueAsync(object value, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } + + /// + /// A provider to handle return value. + /// + private class TriggerReturnValueProvider : IValueBinder + { + private readonly TaskCompletionSource _tcs; + + public TriggerReturnValueProvider(TaskCompletionSource tcs) + { + _tcs = tcs; + } + + public Task GetValueAsync() + { + // Useless for return value provider + return null; + } + + public string ToInvokeString() + { + // Useless for return value provider + return string.Empty; + } + + public Type Type => typeof(object).MakeByRefType(); + + public Task SetValueAsync(object value, CancellationToken cancellationToken) + { + _tcs.TrySetResult(value); + return Task.CompletedTask; + } + } + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBindingProvider.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBindingProvider.cs new file mode 100644 index 00000000..d3ee4f84 --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBindingProvider.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Azure.WebJobs.Host.Triggers; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class SignalRTriggerBindingProvider : ITriggerBindingProvider + { + private readonly ISignalRTriggerDispatcher dispatcher; + + public SignalRTriggerBindingProvider(ISignalRTriggerDispatcher dispatcher) + { + this.dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + } + + public Task TryCreateAsync(TriggerBindingProviderContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var parameterInfo = context.Parameter; + var attribute = parameterInfo.GetCustomAttribute(false); + if (attribute == null) + { + return Task.FromResult(null); + } + + return Task.FromResult(new SignalRTriggerBinding(parameterInfo, attribute, dispatcher)); + } + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerEvent.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerEvent.cs new file mode 100644 index 00000000..54ea964f --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerEvent.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Threading.Tasks; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class SignalRTriggerEvent + { + /// + /// SignalR Context that gets from HTTP request and pass the Function parameters + /// + public InvocationContext Context { get; set; } + + /// + /// A TaskCompletionSource will set the return value when the function invocation is finished. + /// + public TaskCompletionSource TaskCompletionSource { get; set; } + } +} diff --git a/test/SignalRServiceExtension.Tests/GlobalSuppressions.cs b/test/SignalRServiceExtension.Tests/GlobalSuppressions.cs new file mode 100644 index 00000000..ac6a176f --- /dev/null +++ b/test/SignalRServiceExtension.Tests/GlobalSuppressions.cs @@ -0,0 +1,6 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "xUnit1013:Public method should be marked as test", Justification = "", Scope = "member", Target = "~M:SignalRServiceExtension.Tests.SignalRTriggerTests.TestFunction(Microsoft.Azure.WebJobs.Extensions.SignalRService.InvocationContext)")] diff --git a/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerTests.cs b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerTests.cs new file mode 100644 index 00000000..89fd9003 --- /dev/null +++ b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerTests.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Azure.WebJobs.Extensions.SignalRService; +using Microsoft.Azure.WebJobs.Host.Executors; +using Microsoft.Azure.WebJobs.Host.Listeners; +using Microsoft.Azure.WebJobs.Host.Protocols; +using Moq; +using SignalRServiceExtension.Tests.Utils; +using Xunit; + +namespace SignalRServiceExtension.Tests +{ + public class SignalRTriggerTests + { + [Fact] + public async Task BindAsyncTest() + { + var parameterInfo = this.GetType().GetMethod(nameof(TestFunction)).GetParameters()[0]; + var dispatcher = new TestTriggerDispatcher(); + var binding = new SignalRTriggerBinding(parameterInfo, new SignalRTriggerAttribute(), dispatcher); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var context = new InvocationContext(); + var triggerContext = new SignalRTriggerEvent {Context = context, TaskCompletionSource = tcs}; + var result = await binding.BindAsync(triggerContext, null); + Assert.Equal(context, await result.ValueProvider.GetValueAsync()); + } + + [Fact] + public async Task CreateListenerTest() + { + var executor = new Mock().Object; + var listenerFactoryContext = + new ListenerFactoryContext(new FunctionDescriptor(), executor, CancellationToken.None); + var parameterInfo = this.GetType().GetMethod(nameof(TestFunction)).GetParameters()[0]; + var dispatcher = new TestTriggerDispatcher(); + var hub = Guid.NewGuid().ToString(); + var category = Guid.NewGuid().ToString(); + var method = Guid.NewGuid().ToString(); + var binding = new SignalRTriggerBinding(parameterInfo, new SignalRTriggerAttribute{HubName = hub, Category = category, Event = method}, dispatcher); + await binding.CreateListenerAsync(listenerFactoryContext); + Assert.Equal(executor, dispatcher.Executors[(hub, category, method)]); + } + + public void TestFunction(InvocationContext context) + { + } + } +} diff --git a/test/SignalRServiceExtension.Tests/Utils/TestTriggerDispatcher.cs b/test/SignalRServiceExtension.Tests/Utils/TestTriggerDispatcher.cs new file mode 100644 index 00000000..4b2e272f --- /dev/null +++ b/test/SignalRServiceExtension.Tests/Utils/TestTriggerDispatcher.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Azure.WebJobs.Extensions.SignalRService; +using Microsoft.Azure.WebJobs.Host.Executors; + +namespace SignalRServiceExtension.Tests.Utils +{ + class TestTriggerDispatcher : ISignalRTriggerDispatcher + { + public Dictionary<(string, string, string), ITriggeredFunctionExecutor> Executors { get; } = + new Dictionary<(string, string, string), ITriggeredFunctionExecutor>(); + + public void Map((string hubName, string category, string @event) key, ITriggeredFunctionExecutor executor) + { + Executors.Add(key, executor); + } + + public Task ExecuteAsync(HttpRequestMessage req, CancellationToken token = default) + { + throw new NotImplementedException(); + } + } +} From 6007d486222672eb027c5176b72c10436b084e91 Mon Sep 17 00:00:00 2001 From: Wanpeng Li Date: Mon, 24 Feb 2020 17:20:03 +0800 Subject: [PATCH 04/21] custom auth from DI (#90) --- build/dependencies.props | 2 +- .../chat-with-custom-auth/content/index.html | 268 ++++++++++++++++++ .../csharp/FunctionApp/.gitignore | 264 +++++++++++++++++ .../csharp/FunctionApp/FunctionApp.csproj | 26 ++ .../csharp/FunctionApp/FunctionApp.sln | 31 ++ .../SignalRBindingSampleFunctions.cs | 167 +++++++++++ .../csharp/FunctionApp/Startup.cs | 66 +++++ .../csharp/FunctionApp/host.json | 14 + .../FunctionApp/local.settings.sample.json | 15 + .../csharp/FunctionApp/FunctionApp.csproj | 3 +- .../Auth/DefaultSecurityTokenValidator.cs | 81 ++++++ .../Auth/ISecurityTokenValidator.cs | 20 ++ .../Auth/ISignalRConnectionInfoConfigurer.cs | 19 ++ .../Auth/SecurityTokenResult.cs | 54 ++++ .../Auth/SecurityTokenStatus.cs | 12 + .../Auth/SignalRConnectionDetail.cs | 25 ++ .../Bindings/SignalRAsyncCollector.cs | 3 +- .../SecurityTokenValidationInputBinding.cs | 51 ++++ .../SignalRConnectionInputBinding.cs | 74 +++++ .../SignalRConnectionInputBindingProvider.cs | 40 +++ .../SignalRValueProvider.cs | 31 ++ .../Client/AzureSignalRClient.cs | 66 +++-- .../Client/IAzureSignalRSender.cs | 1 - .../Config/SignalRConfigProvider.cs | 20 +- .../SignalRFunctionsHostBuilderExtensions.cs | 68 +++++ ...e.WebJobs.Extensions.SignalRService.csproj | 2 +- .../SecurityTokenValidationAttribute.cs | 14 + .../SignalRConnectionInfoAttribute.cs | 2 +- .../DefaultSecurityTokenValidatorTests.cs | 63 ++++ 29 files changed, 1470 insertions(+), 32 deletions(-) create mode 100644 samples/chat-with-custom-auth/content/index.html create mode 100644 samples/chat-with-custom-auth/csharp/FunctionApp/.gitignore create mode 100644 samples/chat-with-custom-auth/csharp/FunctionApp/FunctionApp.csproj create mode 100644 samples/chat-with-custom-auth/csharp/FunctionApp/FunctionApp.sln create mode 100644 samples/chat-with-custom-auth/csharp/FunctionApp/SignalRBindingSampleFunctions.cs create mode 100644 samples/chat-with-custom-auth/csharp/FunctionApp/Startup.cs create mode 100644 samples/chat-with-custom-auth/csharp/FunctionApp/host.json create mode 100644 samples/chat-with-custom-auth/csharp/FunctionApp/local.settings.sample.json create mode 100644 src/SignalRServiceExtension/Auth/DefaultSecurityTokenValidator.cs create mode 100644 src/SignalRServiceExtension/Auth/ISecurityTokenValidator.cs create mode 100644 src/SignalRServiceExtension/Auth/ISignalRConnectionInfoConfigurer.cs create mode 100644 src/SignalRServiceExtension/Auth/SecurityTokenResult.cs create mode 100644 src/SignalRServiceExtension/Auth/SecurityTokenStatus.cs create mode 100644 src/SignalRServiceExtension/Auth/SignalRConnectionDetail.cs create mode 100644 src/SignalRServiceExtension/Bindings/SignalRInputBindings/SecurityTokenValidationInputBinding.cs create mode 100644 src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding.cs create mode 100644 src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBindingProvider.cs create mode 100644 src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRValueProvider.cs create mode 100644 src/SignalRServiceExtension/Config/SignalRFunctionsHostBuilderExtensions.cs create mode 100644 src/SignalRServiceExtension/SecurityTokenValidationAttribute.cs create mode 100644 test/SignalRServiceExtension.Tests/DefaultSecurityTokenValidatorTests.cs diff --git a/build/dependencies.props b/build/dependencies.props index dfbfd172..5a8d7b97 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -6,7 +6,7 @@ 0.3.0 1.2.0 - 3.0.4 + 1.0.0 15.8.0 4.9.0 2.4.0 diff --git a/samples/chat-with-custom-auth/content/index.html b/samples/chat-with-custom-auth/content/index.html new file mode 100644 index 00000000..d2a23ed5 --- /dev/null +++ b/samples/chat-with-custom-auth/content/index.html @@ -0,0 +1,268 @@ + + + + Serverless Chat + + + + + + +

 

+
+

Serverless chat

+
+
+
+
+ + +
+
+ +
+
+
+
+
+
Loading...
+
+
+
+ +
+
+
+
+
+
+ + + {{ message.Sender || message.sender }} + + + Connection: {{ message.ConnectionId || message.connectionId }} + + AddGroup + + + RemoveGroup + + + AddConnectionToGroup + + + RemoveConnectionFromGroup + + private message + +
+
+ {{ message.Text || message.text }} +
+
+
+
+
+
+
+ + + + + + + + + + \ No newline at end of file diff --git a/samples/chat-with-custom-auth/csharp/FunctionApp/.gitignore b/samples/chat-with-custom-auth/csharp/FunctionApp/.gitignore new file mode 100644 index 00000000..ff5b00c5 --- /dev/null +++ b/samples/chat-with-custom-auth/csharp/FunctionApp/.gitignore @@ -0,0 +1,264 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# Azure Functions localsettings file +local.settings.json + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +#*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc \ No newline at end of file diff --git a/samples/chat-with-custom-auth/csharp/FunctionApp/FunctionApp.csproj b/samples/chat-with-custom-auth/csharp/FunctionApp/FunctionApp.csproj new file mode 100644 index 00000000..b267b833 --- /dev/null +++ b/samples/chat-with-custom-auth/csharp/FunctionApp/FunctionApp.csproj @@ -0,0 +1,26 @@ + + + netstandard2.0 + v2 + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + PreserveNewest + Never + + + \ No newline at end of file diff --git a/samples/chat-with-custom-auth/csharp/FunctionApp/FunctionApp.sln b/samples/chat-with-custom-auth/csharp/FunctionApp/FunctionApp.sln new file mode 100644 index 00000000..6f688905 --- /dev/null +++ b/samples/chat-with-custom-auth/csharp/FunctionApp/FunctionApp.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.27703.2047 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FunctionApp", "FunctionApp.csproj", "{185119A1-81E7-4A9C-BFD7-C3C976BDA463}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Azure.WebJobs.Extensions.SignalRService", "..\..\..\..\src\SignalRServiceExtension\Microsoft.Azure.WebJobs.Extensions.SignalRService.csproj", "{43AD6D39-E440-4812-A86F-22EA23E62456}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {185119A1-81E7-4A9C-BFD7-C3C976BDA463}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {185119A1-81E7-4A9C-BFD7-C3C976BDA463}.Debug|Any CPU.Build.0 = Debug|Any CPU + {185119A1-81E7-4A9C-BFD7-C3C976BDA463}.Release|Any CPU.ActiveCfg = Release|Any CPU + {185119A1-81E7-4A9C-BFD7-C3C976BDA463}.Release|Any CPU.Build.0 = Release|Any CPU + {43AD6D39-E440-4812-A86F-22EA23E62456}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {43AD6D39-E440-4812-A86F-22EA23E62456}.Debug|Any CPU.Build.0 = Debug|Any CPU + {43AD6D39-E440-4812-A86F-22EA23E62456}.Release|Any CPU.ActiveCfg = Release|Any CPU + {43AD6D39-E440-4812-A86F-22EA23E62456}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {DBE75EA3-2A43-47B5-8806-859D6045A793} + EndGlobalSection +EndGlobal diff --git a/samples/chat-with-custom-auth/csharp/FunctionApp/SignalRBindingSampleFunctions.cs b/samples/chat-with-custom-auth/csharp/FunctionApp/SignalRBindingSampleFunctions.cs new file mode 100644 index 00000000..20d3f0e4 --- /dev/null +++ b/samples/chat-with-custom-auth/csharp/FunctionApp/SignalRBindingSampleFunctions.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Azure.WebJobs.Extensions.Http; +using Newtonsoft.Json; +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService.Samples +{ + public static class SignalRBindingSampleFunctions + { + [FunctionName("negotiate")] + public static Task GetSignalRInfo( + [HttpTrigger(AuthorizationLevel.Anonymous)] HttpRequestMessage req, + [SecurityTokenValidation] SecurityTokenResult tokenResult, + [SignalRConnectionInfo(HubName = Constants.HubName)] SignalRConnectionInfo connectionInfo) + { + return tokenResult.Status == SecurityTokenStatus.Valid + ? Task.FromResult(req.CreateResponse(HttpStatusCode.OK, connectionInfo)) + : Task.FromResult(req.CreateErrorResponse(HttpStatusCode.Unauthorized, $"Validation result: {tokenResult.Status.ToString()}; Message: {tokenResult.Exception?.Message}")); + } + + [FunctionName("messages")] + public static async Task SendMessage( + [HttpTrigger(AuthorizationLevel.Anonymous, "post")]HttpRequestMessage req, + [SecurityTokenValidation] SecurityTokenResult tokenResult, + [SignalR(HubName = Constants.HubName)]IAsyncCollector signalRMessages) + { + if (!PassTokenValidation(req, tokenResult, out var unauthorizedActionResult, out var isAdmin)) + { + return unauthorizedActionResult; + } + + var message = new JsonSerializer().Deserialize(new JsonTextReader(new StreamReader(await req.Content.ReadAsStreamAsync()))); + + // prevent broadcast on non-administrator caller + if (!isAdmin && message.Recipient == null && message.GroupName == null) + { + return req.CreateErrorResponse(HttpStatusCode.Forbidden, "Non administrator cannot broadcast messages"); + } + + return await BuildResponseAsync(req, signalRMessages.AddAsync( + new SignalRMessage + { + UserId = message.Recipient, + GroupName = message.GroupName, + Target = "newMessage", + Arguments = new[] { message } + })); + } + + [FunctionName("addToGroup")] + public static async Task AddToGroup( + [HttpTrigger(AuthorizationLevel.Anonymous, "post")]HttpRequestMessage req, + [SecurityTokenValidation] SecurityTokenResult tokenResult, + [SignalR(HubName = Constants.HubName)]IAsyncCollector signalRGroupActions) + { + if (!PassTokenValidation(req, tokenResult, out var unauthorizedActionResult, out _)) + { + return unauthorizedActionResult; + } + + var message = new JsonSerializer().Deserialize(new JsonTextReader(new StreamReader(await req.Content.ReadAsStreamAsync()))); + + var decodedfConnectionId = GetBase64DecodedString(message.ConnectionId); + + return await BuildResponseAsync(req, signalRGroupActions.AddAsync( + new SignalRGroupAction + { + ConnectionId = decodedfConnectionId, + UserId = message.Recipient, + GroupName = message.GroupName, + Action = GroupAction.Add + })); + } + + [FunctionName("removeFromGroup")] + public static async Task RemoveFromGroup( + [HttpTrigger(AuthorizationLevel.Anonymous, "post")]HttpRequestMessage req, + [SecurityTokenValidation] SecurityTokenResult tokenResult, + [SignalR(HubName = Constants.HubName)]IAsyncCollector signalRGroupActions) + { + if (!PassTokenValidation(req, tokenResult, out var unauthorizedActionResult, out _)) + { + return unauthorizedActionResult; + } + var message = new JsonSerializer().Deserialize(new JsonTextReader(new StreamReader(await req.Content.ReadAsStreamAsync()))); + + return await BuildResponseAsync(req, signalRGroupActions.AddAsync( + new SignalRGroupAction + { + ConnectionId = message.ConnectionId, + UserId = message.Recipient, + GroupName = message.GroupName, + Action = GroupAction.Remove + })); + } + + private static string GetBase64DecodedString(string source) + { + if (string.IsNullOrEmpty(source)) + { + return source; + } + + return Encoding.UTF8.GetString(Convert.FromBase64String(source)); + } + + private static bool PassTokenValidation(HttpRequestMessage req, SecurityTokenResult securityTokenResult, out HttpResponseMessage unauthorizedActionResult, out bool isAdmin) + { + isAdmin = false; + + if (securityTokenResult.Status != SecurityTokenStatus.Valid) + { + // failed to pass auth check + unauthorizedActionResult = + req.CreateErrorResponse(HttpStatusCode.Unauthorized, securityTokenResult.Exception.Message); + return false; + } + + unauthorizedActionResult = null; + foreach (var claim in securityTokenResult.Principal.Claims) + { + if (claim.Type == "admin") + { + isAdmin = Boolean.Parse(claim.Value); + } + } + + return true; + } + + private static async Task BuildResponseAsync(HttpRequestMessage req, Task task) + { + try + { + await task; + } + catch (Exception ex) + { + return req.CreateErrorResponse(HttpStatusCode.InternalServerError, ex.Message); + } + + return req.CreateResponse(HttpStatusCode.Accepted); + } + + public static class Constants + { + public const string HubName = "simplechat"; + } + + public class ChatMessage + { + public string Sender { get; set; } + public string Text { get; set; } + public string GroupName { get; set; } + public string Recipient { get; set; } + public string ConnectionId { get; set; } + public bool IsPrivate { get; set; } + } + } +} diff --git a/samples/chat-with-custom-auth/csharp/FunctionApp/Startup.cs b/samples/chat-with-custom-auth/csharp/FunctionApp/Startup.cs new file mode 100644 index 00000000..b86253aa --- /dev/null +++ b/samples/chat-with-custom-auth/csharp/FunctionApp/Startup.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.IO; +using System.Security.Claims; +using FunctionApp; +using Microsoft.Azure.Functions.Extensions.DependencyInjection; +using Microsoft.Azure.WebJobs.Extensions.SignalRService; +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.Tokens; + +[assembly: FunctionsStartup(typeof(Startup))] +namespace FunctionApp +{ + /// + /// Runs when the Azure Functions host starts. Microsoft.NET.Sdk.Functions package version 1.0.28 or later + /// + public class Startup : FunctionsStartup + { + public override void Configure(IFunctionsHostBuilder builder) + { + // Get the configuration files for the OAuth token issuer + //var issuerToken = Environment.GetEnvironmentVariable("IssuerToken"); + + // only for sample + var config = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables() + .Build(); + var issuerSigningKey = config["IssuerSigningKey"]; // base64 encoded for "myfunctionauthtest"; + + // Register the access token provider as a singleton, customer can register one's own + builder.AddDefaultAuth(parameters => + { + parameters.IssuerSigningKey = new SymmetricSecurityKey(Convert.FromBase64String(issuerSigningKey)); + // for sample only + parameters.RequireSignedTokens = true; + parameters.ValidateAudience = false; + parameters.ValidateIssuer = false; + parameters.ValidateIssuerSigningKey = true; + parameters.ValidateLifetime = false; + }, (accessTokenResult, httpRequest, signalRConnectionDetail) => + { + // resolve the identity + var identity = accessTokenResult.Principal.Identity.Name; + + // update connection info detail + signalRConnectionDetail.UserId = identity; + + // add custom claim + var customClaimValues = httpRequest.Headers["x-ms-signalr-custom-claim"]; + if (customClaimValues.Count == 1) + { + var customClaim = new Claim("x-ms-signalr-custom-claim", customClaimValues); + signalRConnectionDetail.Claims?.Add(customClaim); + } + + // binding will generate ASRS negotiate response inside with this new signalRConnectionDetail, + // now you can keep your negotiate function clean + return signalRConnectionDetail; + }); + } + } +} \ No newline at end of file diff --git a/samples/chat-with-custom-auth/csharp/FunctionApp/host.json b/samples/chat-with-custom-auth/csharp/FunctionApp/host.json new file mode 100644 index 00000000..c8da4706 --- /dev/null +++ b/samples/chat-with-custom-auth/csharp/FunctionApp/host.json @@ -0,0 +1,14 @@ +{ + "version": "2.0", + "extensions": { + "http": { + "routePrefix": "simplechat" + } + }, + "logging": { + "fileLoggingMode": "always", + "logLevel": { + "default": "Trace" + } + } +} \ No newline at end of file diff --git a/samples/chat-with-custom-auth/csharp/FunctionApp/local.settings.sample.json b/samples/chat-with-custom-auth/csharp/FunctionApp/local.settings.sample.json new file mode 100644 index 00000000..9f74b2aa --- /dev/null +++ b/samples/chat-with-custom-auth/csharp/FunctionApp/local.settings.sample.json @@ -0,0 +1,15 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "", + "AzureWebJobsDashboard": "", + "FUNCTIONS_WORKER_RUNTIME": "dotnet", + "AzureSignalRConnectionString": "", + "AzureSignalRServiceTransportType": "Transient", + "IssuerSigningKey": "" + }, + "Host": { + "LocalHttpPort": 7071, + "CORS": "*" + } +} \ No newline at end of file diff --git a/samples/simple-chat/csharp/FunctionApp/FunctionApp.csproj b/samples/simple-chat/csharp/FunctionApp/FunctionApp.csproj index 319e6d19..14ed2796 100644 --- a/samples/simple-chat/csharp/FunctionApp/FunctionApp.csproj +++ b/samples/simple-chat/csharp/FunctionApp/FunctionApp.csproj @@ -5,7 +5,8 @@ - + + diff --git a/src/SignalRServiceExtension/Auth/DefaultSecurityTokenValidator.cs b/src/SignalRServiceExtension/Auth/DefaultSecurityTokenValidator.cs new file mode 100644 index 00000000..1f3f293f --- /dev/null +++ b/src/SignalRServiceExtension/Auth/DefaultSecurityTokenValidator.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.IdentityModel.Tokens.Jwt; +using Microsoft.AspNetCore.Http; +using Microsoft.IdentityModel.Tokens; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class DefaultSecurityTokenValidator : ISecurityTokenValidator + { + private const string AuthHeaderName = "Authorization"; + private const string BearerPrefix = "Bearer "; + private readonly TokenValidationParameters tokenValidationParameters = new TokenValidationParameters(); + private readonly JwtSecurityTokenHandler handler = new JwtSecurityTokenHandler(); + + public DefaultSecurityTokenValidator(Action configureTokenValidationParameters) + { + if (configureTokenValidationParameters == null) + { + throw new ArgumentNullException(nameof(configureTokenValidationParameters)); + } + configureTokenValidationParameters(tokenValidationParameters); + } + + public SecurityTokenResult ValidateToken(HttpRequest request) + { + try + { + // Gets the token from the Authorization header + if (request != null && + request.Headers.ContainsKey(AuthHeaderName) && + request.Headers[AuthHeaderName].ToString().StartsWith(BearerPrefix)) + { + var token = request.Headers[AuthHeaderName].ToString().Substring(BearerPrefix.Length); + // Validates the token + var principal = handler.ValidateToken(token, tokenValidationParameters, out _); + return SecurityTokenResult.Success(principal); + } + + // token is null or whitespace + return SecurityTokenResult.Empty(); + } + catch (Exception ex) when ( + // 'exp' claim is less than DateTime.UtcNow + ex is SecurityTokenExpiredException || + + // 1. token's length is greater than TokenHandler.MaximumTokenSizeInBytes + // 2. token does not have 3 or 5 parts + // 3. token cannot be read + ex is ArgumentException || + + // 1. TokenValidationParameters.ValidAudience is null or whitespace and TokenValidationParameters.ValidAudiences is null. Audience is not validated if TokenValidationParameters.ValidateAudience is set to false. + // 2. 'aud' claim did not match either TokenValidationParameters.ValidAudience or one of TokenValidationParameters.ValidAudiences. + ex is SecurityTokenInvalidAudienceException || + + // 'nbf' claim is greater than 'exp' claim + ex is SecurityTokenInvalidLifetimeException || + + // Signature is not properly formatted. + ex is SecurityTokenInvalidSignatureException || + + // 1. 'exp' claim is missing and TokenValidationParameters.RequireExpirationTime is true. + // 2. TokenValidationParameters.TokenReplayCache is not null and expirationTime.HasValue is false. When a TokenReplayCache is set, tokens require an expiration time + ex is SecurityTokenNoExpirationException || + + // 'nbf' claim is greater than DateTime.UtcNow. + ex is SecurityTokenNotYetValidException || + + // token could not be added to the TokenValidationParameters.TokenReplayCache + ex is SecurityTokenReplayAddFailedException || + + // token is found in the cache + ex is SecurityTokenReplayDetectedException) + { + return SecurityTokenResult.Error(ex); + } + } + } +} \ No newline at end of file diff --git a/src/SignalRServiceExtension/Auth/ISecurityTokenValidator.cs b/src/SignalRServiceExtension/Auth/ISecurityTokenValidator.cs new file mode 100644 index 00000000..9eeb3df7 --- /dev/null +++ b/src/SignalRServiceExtension/Auth/ISecurityTokenValidator.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + /// + /// An abstraction for validating security token. + /// + public interface ISecurityTokenValidator + { + /// + /// Validates security token from http request. + /// + /// Http request that was sent to azure function + /// + SecurityTokenResult ValidateToken(HttpRequest request); + } +} \ No newline at end of file diff --git a/src/SignalRServiceExtension/Auth/ISignalRConnectionInfoConfigurer.cs b/src/SignalRServiceExtension/Auth/ISignalRConnectionInfoConfigurer.cs new file mode 100644 index 00000000..3c54af53 --- /dev/null +++ b/src/SignalRServiceExtension/Auth/ISignalRConnectionInfoConfigurer.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + /// + /// A configuration abstraction for configuring SignalR connection information + /// + public interface ISignalRConnectionInfoConfigurer + { + /// + /// Configuring SignalR access token from a given Azure function access token result, http request, SignalR connection detail, and return a new SignalR connection detail for generating access token to access SignalR service. + /// + Func Configure { get; set; } + } +} \ No newline at end of file diff --git a/src/SignalRServiceExtension/Auth/SecurityTokenResult.cs b/src/SignalRServiceExtension/Auth/SecurityTokenResult.cs new file mode 100644 index 00000000..2038f1c0 --- /dev/null +++ b/src/SignalRServiceExtension/Auth/SecurityTokenResult.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Security.Claims; +using Newtonsoft.Json; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + /// + /// Defines the result of a security token validation. + /// + public sealed class SecurityTokenResult + { + /// + /// Gets the status of validated principal. + /// + [JsonProperty("status")] + public SecurityTokenStatus Status { get; } + + /// + /// Gets the which contains multiple claims-based identities after token validation. + /// + public ClaimsPrincipal Principal { get; } + + /// + /// Gets any exception thrown on validating an invalid token. + /// + [JsonProperty("exception")] + public Exception Exception { get; } + + private SecurityTokenResult(SecurityTokenStatus status, ClaimsPrincipal principal = null, Exception exception = null) + { + Status = status; + Principal = principal; + Exception = exception; + } + + /// + /// Static initializer for creating validation result of a valid token. + /// + public static SecurityTokenResult Success(ClaimsPrincipal principal) => new SecurityTokenResult(SecurityTokenStatus.Valid, principal: principal); + + /// + /// Static initializer for creating validation result of an invalid token. + /// + public static SecurityTokenResult Error(Exception ex) => new SecurityTokenResult(SecurityTokenStatus.Error, exception: ex); + + /// + /// Static initializer for creating validation result of an empty token. + /// + public static SecurityTokenResult Empty() => new SecurityTokenResult(SecurityTokenStatus.Empty); + } +} \ No newline at end of file diff --git a/src/SignalRServiceExtension/Auth/SecurityTokenStatus.cs b/src/SignalRServiceExtension/Auth/SecurityTokenStatus.cs new file mode 100644 index 00000000..f2295342 --- /dev/null +++ b/src/SignalRServiceExtension/Auth/SecurityTokenStatus.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + public enum SecurityTokenStatus + { + Valid, + Error, + Empty + } +} \ No newline at end of file diff --git a/src/SignalRServiceExtension/Auth/SignalRConnectionDetail.cs b/src/SignalRServiceExtension/Auth/SignalRConnectionDetail.cs new file mode 100644 index 00000000..87e61065 --- /dev/null +++ b/src/SignalRServiceExtension/Auth/SignalRConnectionDetail.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Security.Claims; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + /// + /// Contains details to SignalR connection information that is used in generating SignalR access token. + /// + public class SignalRConnectionDetail + { + /// + /// User identity for a SignalR connection + /// + public string UserId { get; set; } + + /// + /// Custom claims that added to SignalR access token. + /// + public IList Claims { get; set; } + } +} \ No newline at end of file diff --git a/src/SignalRServiceExtension/Bindings/SignalRAsyncCollector.cs b/src/SignalRServiceExtension/Bindings/SignalRAsyncCollector.cs index 9f9687eb..af3a2c4f 100644 --- a/src/SignalRServiceExtension/Bindings/SignalRAsyncCollector.cs +++ b/src/SignalRServiceExtension/Bindings/SignalRAsyncCollector.cs @@ -4,6 +4,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; namespace Microsoft.Azure.WebJobs.Extensions.SignalRService { @@ -59,7 +60,7 @@ internal SignalRAsyncCollector(IAzureSignalRSender client) if (!string.IsNullOrEmpty(groupAction.ConnectionId)) { - switch(groupAction.Action) + switch (groupAction.Action) { case GroupAction.Add: await client.AddConnectionToGroup(groupAction.ConnectionId, groupAction.GroupName).ConfigureAwait(false); diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SecurityTokenValidationInputBinding.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SecurityTokenValidationInputBinding.cs new file mode 100644 index 00000000..d9620604 --- /dev/null +++ b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SecurityTokenValidationInputBinding.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Azure.WebJobs.Host.Protocols; +using System; +using System.Threading.Tasks; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class SecurityTokenValidationInputBinding : IBinding + { + private const string HttpRequestName = "$request"; + private readonly ISecurityTokenValidator securityTokenValidator; + + public bool FromAttribute { get; } + + public SecurityTokenValidationInputBinding(ISecurityTokenValidator securityTokenValidator) + { + this.securityTokenValidator = securityTokenValidator; + } + + public Task BindAsync(object value, ValueBindingContext context) + { + var request = ((BindingContext)value).BindingData[HttpRequestName] as HttpRequest; + + if (request == null) + { + throw new NotSupportedException($"Argument {nameof(HttpRequest)} is null. {nameof(SecurityTokenValidationAttribute)} must work with HttpTrigger."); + } + + if (securityTokenValidator == null) + { + return Task.FromResult((IValueProvider)new SignalRValueProvider(null)); + } + + return Task.FromResult((IValueProvider)new SignalRValueProvider(securityTokenValidator.ValidateToken(request))); + } + + public Task BindAsync(BindingContext context) + { + return BindAsync(context, null); + } + + public ParameterDescriptor ToParameterDescriptor() + { + return new ParameterDescriptor(); + } + } +} \ No newline at end of file diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding.cs new file mode 100644 index 00000000..1936fada --- /dev/null +++ b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Azure.WebJobs.Host.Protocols; +using System.Threading.Tasks; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class SignalRConnectionInputBinding : IBinding + { + private const string HttpRequestName = "$request"; + private readonly SignalRConnectionInfoAttribute attribute; + private readonly ISecurityTokenValidator securityTokenValidator; + private readonly AzureSignalRClient azureSignalRClient; + private readonly ISignalRConnectionInfoConfigurer signalRConnectionInfoConfigurer; + + public bool FromAttribute => true; + + public SignalRConnectionInputBinding(SignalRConnectionInfoAttribute attribute, AzureSignalRClient azureSignalRClient, ISecurityTokenValidator securityTokenValidator, ISignalRConnectionInfoConfigurer signalRConnectionInfoConfigurer) + { + this.securityTokenValidator = securityTokenValidator; + this.azureSignalRClient = azureSignalRClient; + this.attribute = attribute; + this.signalRConnectionInfoConfigurer = signalRConnectionInfoConfigurer; + } + + public Task BindAsync(object value, ValueBindingContext context) + { + var bindingData = ((BindingContext)value).BindingData; + + if (!bindingData.ContainsKey(HttpRequestName) || securityTokenValidator == null) + { + var info = azureSignalRClient.GetClientConnectionInfo(attribute.UserId, attribute.IdToken, attribute.ClaimTypeList); + return Task.FromResult((IValueProvider)new SignalRValueProvider(info)); + } + + var request = bindingData[HttpRequestName] as HttpRequest; + + var tokenResult = securityTokenValidator.ValidateToken(request); + + if (tokenResult.Status != SecurityTokenStatus.Valid) + { + return Task.FromResult((IValueProvider)new SignalRValueProvider(null)); + } + + if (signalRConnectionInfoConfigurer == null) + { + var info = azureSignalRClient.GetClientConnectionInfo(attribute.UserId, attribute.IdToken, attribute.ClaimTypeList); + return Task.FromResult((IValueProvider)new SignalRValueProvider(info)); + } + + var signalRConnectionDetail = new SignalRConnectionDetail + { + UserId = attribute.UserId, + Claims = azureSignalRClient.GetCustomClaims(attribute.IdToken, attribute.ClaimTypeList), + }; + signalRConnectionInfoConfigurer.Configure(tokenResult, request, signalRConnectionDetail); + var customizedInfo = azureSignalRClient.GetClientConnectionInfo(signalRConnectionDetail.UserId, signalRConnectionDetail.Claims); + return Task.FromResult((IValueProvider)new SignalRValueProvider(customizedInfo)); + } + + public Task BindAsync(BindingContext context) + { + return BindAsync(context, null); + } + + public ParameterDescriptor ToParameterDescriptor() + { + return new ParameterDescriptor(); + } + } +} \ No newline at end of file diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBindingProvider.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBindingProvider.cs new file mode 100644 index 00000000..d644a6d2 --- /dev/null +++ b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBindingProvider.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Bindings; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class SignalRConnectionInputBindingProvider : IBindingProvider + { + private readonly ISecurityTokenValidator securityTokenValidator; + private readonly SignalRConfigProvider signalRConfigProvider; + private readonly ISignalRConnectionInfoConfigurer signalRConnectionInfoConfigurer; + + public SignalRConnectionInputBindingProvider(SignalRConfigProvider signalRConfigProvider, ISecurityTokenValidator securityTokenValidator, ISignalRConnectionInfoConfigurer signalRConnectionInfoConfigurer) + { + this.securityTokenValidator = securityTokenValidator; + this.signalRConfigProvider = signalRConfigProvider; + this.signalRConnectionInfoConfigurer = signalRConnectionInfoConfigurer; + } + + public Task TryCreateAsync(BindingProviderContext context) + { + var parameterInfo = context.Parameter; + foreach (var attr in parameterInfo.GetCustomAttributes()) + { + switch (attr) + { + case SignalRConnectionInfoAttribute connectionInfoAttribute: + var resolvedConnectionString = signalRConfigProvider.nameResolver.Resolve(connectionInfoAttribute.ConnectionStringSetting); + return Task.FromResult((IBinding)new SignalRConnectionInputBinding(connectionInfoAttribute, signalRConfigProvider.GetAzureSignalRClient(resolvedConnectionString, connectionInfoAttribute.HubName), securityTokenValidator, signalRConnectionInfoConfigurer)); + case SecurityTokenValidationAttribute validationAttribute: + return Task.FromResult((IBinding) new SecurityTokenValidationInputBinding(securityTokenValidator)); + } + } + return Task.FromResult(null); + } + } +} \ No newline at end of file diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRValueProvider.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRValueProvider.cs new file mode 100644 index 00000000..8e317641 --- /dev/null +++ b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRValueProvider.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Bindings; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class SignalRValueProvider : IValueProvider + { + private object value; + + public SignalRValueProvider(object value) + { + this.value = value; + } + + public Task GetValueAsync() + { + return Task.FromResult(value); + } + + public string ToInvokeString() + { + return value?.ToString(); + } + + public Type Type { get; } + } +} diff --git a/src/SignalRServiceExtension/Client/AzureSignalRClient.cs b/src/SignalRServiceExtension/Client/AzureSignalRClient.cs index fdd0a587..9f558d54 100644 --- a/src/SignalRServiceExtension/Client/AzureSignalRClient.cs +++ b/src/SignalRServiceExtension/Client/AzureSignalRClient.cs @@ -7,7 +7,6 @@ using System.Linq; using System.Security.Claims; using System.Threading.Tasks; -using Microsoft.Azure.SignalR.Management; namespace Microsoft.Azure.WebJobs.Extensions.SignalRService { @@ -26,46 +25,69 @@ internal class AzureSignalRClient : IAzureSignalRSender "nbf" // Not Before claim. Added by default. It is not validated by service. }; private readonly IServiceManagerStore serviceManagerStore; - private readonly string hubName; private readonly string connectionString; + public string HubName { get; } + internal AzureSignalRClient(IServiceManagerStore serviceManagerStore, string connectionString, string hubName) { this.serviceManagerStore = serviceManagerStore; - this.hubName = hubName; + this.HubName = hubName; this.connectionString = connectionString; } public SignalRConnectionInfo GetClientConnectionInfo(string userId, string idToken, string[] claimTypeList) { - IEnumerable customerClaims = null; - if (idToken != null && claimTypeList != null && claimTypeList.Length > 0) + var customerClaims = GetCustomClaims(idToken, claimTypeList); + var serviceManager = serviceManagerStore.GetOrAddByConnectionString(connectionString).ServiceManager; + + return new SignalRConnectionInfo { - var jwtToken = new JwtSecurityTokenHandler().ReadJwtToken(idToken); - customerClaims = from claim in jwtToken.Claims - where claimTypeList.Contains(claim.Type) - select claim; - } + Url = serviceManager.GetClientEndpoint(HubName), + AccessToken = serviceManager.GenerateClientAccessToken( + HubName, userId, BuildJwtClaims(customerClaims, AzureSignalRUserPrefix).ToList()) + }; + } + public SignalRConnectionInfo GetClientConnectionInfo(string userId, IList claims) + { var serviceManager = serviceManagerStore.GetOrAddByConnectionString(connectionString).ServiceManager; - return new SignalRConnectionInfo { - Url = serviceManager.GetClientEndpoint(hubName), + Url = serviceManager.GetClientEndpoint(HubName), AccessToken = serviceManager.GenerateClientAccessToken( - hubName, userId, BuildJwtClaims(customerClaims, AzureSignalRUserPrefix).ToList()) + HubName, userId, BuildJwtClaims(claims, AzureSignalRUserPrefix).ToList()) }; } + public IList GetCustomClaims(string idToken, string[] claimTypeList) + { + var customClaims = new List(); + + if (idToken != null && claimTypeList != null && claimTypeList.Length > 0) + { + var jwtToken = new JwtSecurityTokenHandler().ReadJwtToken(idToken); + foreach (var claim in jwtToken.Claims) + { + if (claimTypeList.Contains(claim.Type)) + { + customClaims.Add(claim); + } + } + } + + return customClaims; + } + public async Task SendToAll(SignalRData data) { - var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(hubName); + var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(HubName); await serviceHubContext.Clients.All.SendCoreAsync(data.Target, data.Arguments); } public async Task SendToConnection(string connectionId, SignalRData data) { - var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(hubName); + var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(HubName); await serviceHubContext.Clients.Client(connectionId).SendCoreAsync(data.Target, data.Arguments); } @@ -75,7 +97,7 @@ public async Task SendToUser(string userId, SignalRData data) { throw new ArgumentException($"{nameof(userId)} cannot be null or empty"); } - var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(hubName); + var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(HubName); await serviceHubContext.Clients.User(userId).SendCoreAsync(data.Target, data.Arguments); } @@ -85,7 +107,7 @@ public async Task SendToGroup(string groupName, SignalRData data) { throw new ArgumentException($"{nameof(groupName)} cannot be null or empty"); } - var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(hubName); + var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(HubName); await serviceHubContext.Clients.Group(groupName).SendCoreAsync(data.Target, data.Arguments); } @@ -99,7 +121,7 @@ public async Task AddUserToGroup(string userId, string groupName) { throw new ArgumentException($"{nameof(groupName)} cannot be null or empty"); } - var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(hubName); + var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(HubName); await serviceHubContext.UserGroups.AddToGroupAsync(userId, groupName); } @@ -113,7 +135,7 @@ public async Task RemoveUserFromGroup(string userId, string groupName) { throw new ArgumentException($"{nameof(groupName)} cannot be null or empty"); } - var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(hubName); + var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(HubName); await serviceHubContext.UserGroups.RemoveFromGroupAsync(userId, groupName); } @@ -123,7 +145,7 @@ public async Task RemoveUserFromAllGroups(string userId) { throw new ArgumentException($"{nameof(userId)} cannot be null or empty"); } - var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(hubName); + var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(HubName); await serviceHubContext.UserGroups.RemoveFromAllGroupsAsync(userId); } @@ -137,7 +159,7 @@ public async Task AddConnectionToGroup(string connectionId, string groupName) { throw new ArgumentException($"{nameof(groupName)} cannot be null or empty"); } - var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(hubName); + var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(HubName); await serviceHubContext.Groups.AddToGroupAsync(connectionId, groupName); } @@ -151,7 +173,7 @@ public async Task RemoveConnectionFromGroup(string connectionId, string groupNam { throw new ArgumentException($"{nameof(groupName)} cannot be null or empty"); } - var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(hubName); + var serviceHubContext = await serviceManagerStore.GetOrAddByConnectionString(connectionString).GetAsync(HubName); await serviceHubContext.Groups.RemoveFromGroupAsync(connectionId, groupName); } diff --git a/src/SignalRServiceExtension/Client/IAzureSignalRSender.cs b/src/SignalRServiceExtension/Client/IAzureSignalRSender.cs index 69003d6e..86a0b25f 100644 --- a/src/SignalRServiceExtension/Client/IAzureSignalRSender.cs +++ b/src/SignalRServiceExtension/Client/IAzureSignalRSender.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using System.Collections.Generic; using System.Threading.Tasks; namespace Microsoft.Azure.WebJobs.Extensions.SignalRService diff --git a/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs b/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs index bf0143fd..00396cca 100644 --- a/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs +++ b/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs @@ -20,22 +20,29 @@ internal class SignalRConfigProvider : IExtensionConfigProvider { public IConfiguration Configuration { get; } - private readonly SignalROptions options; - private readonly INameResolver nameResolver; + internal readonly INameResolver nameResolver; + private readonly ILogger logger; + private readonly SignalROptions options; private readonly ILoggerFactory loggerFactory; + private readonly ISecurityTokenValidator securityTokenValidator; + private readonly ISignalRConnectionInfoConfigurer signalRConnectionInfoConfigurer; public SignalRConfigProvider( IOptions options, INameResolver nameResolver, ILoggerFactory loggerFactory, - IConfiguration configuration) + IConfiguration configuration, + ISecurityTokenValidator securityTokenValidator = null, + ISignalRConnectionInfoConfigurer signalRConnectionInfoConfigurer = null) { this.options = options.Value; this.loggerFactory = loggerFactory; this.logger = loggerFactory.CreateLogger("SignalR"); this.nameResolver = nameResolver; Configuration = configuration; + this.securityTokenValidator = securityTokenValidator; + this.signalRConnectionInfoConfigurer = signalRConnectionInfoConfigurer; } public void Initialize(ExtensionConfigContext context) @@ -67,9 +74,14 @@ public void Initialize(ExtensionConfigContext context) .AddConverter(input => input.ToObject()) .AddConverter(input => input.ToObject()); + var signalRConnectionInputBindingProvider = new SignalRConnectionInputBindingProvider(this, securityTokenValidator, signalRConnectionInfoConfigurer); + var signalRConnectionInfoAttributeRule = context.AddBindingRule(); signalRConnectionInfoAttributeRule.AddValidator(ValidateSignalRConnectionInfoAttributeBinding); - signalRConnectionInfoAttributeRule.BindToInput(GetClientConnectionInfo); + signalRConnectionInfoAttributeRule.Bind(signalRConnectionInputBindingProvider); + + var securityTokenValidationAttributeRule = context.AddBindingRule(); + securityTokenValidationAttributeRule.Bind(signalRConnectionInputBindingProvider); var signalRAttributeRule = context.AddBindingRule(); signalRAttributeRule.AddValidator(ValidateSignalRAttributeBinding); diff --git a/src/SignalRServiceExtension/Config/SignalRFunctionsHostBuilderExtensions.cs b/src/SignalRServiceExtension/Config/SignalRFunctionsHostBuilderExtensions.cs new file mode 100644 index 00000000..e05b5df2 --- /dev/null +++ b/src/SignalRServiceExtension/Config/SignalRFunctionsHostBuilderExtensions.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.Functions.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using System; +using System.Linq; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + using SignalRConnectionInfoConfigureFunc = Func; + + /// + /// Extensions to add security token validator and SignalR connection configuration + /// + public static class SignalRFunctionsHostBuilderExtensions + { + /// + /// Adds security token validation parameters' configuration and SignalR connection's configuration. + /// + /// Azure function host builder + /// Token validation parameters to validate security token + /// SignalR connection configuration to be used in generating Azure SignalR service's access token + /// Azure function host builder + public static IFunctionsHostBuilder AddDefaultAuth(this IFunctionsHostBuilder builder, Action configureTokenValidationParameters, SignalRConnectionInfoConfigureFunc configurer = null) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (configureTokenValidationParameters == null) + { + throw new ArgumentNullException(nameof(configureTokenValidationParameters)); + } + + var internalSignalRConnectionInfoConfigurer = new InternalSignalRConnectionInfoConfigurer(configurer); + + if (builder.Services.Any(d => d.ServiceType == typeof(ISecurityTokenValidator))) + { + throw new NotSupportedException($"{nameof(ISecurityTokenValidator)} already injected."); + } + + builder.Services + .AddSingleton(s => + new DefaultSecurityTokenValidator(configureTokenValidationParameters)); + + builder.Services. + TryAddSingleton(s => + internalSignalRConnectionInfoConfigurer); + + return builder; + } + } + + internal class InternalSignalRConnectionInfoConfigurer : ISignalRConnectionInfoConfigurer + { + public SignalRConnectionInfoConfigureFunc Configure { get; set; } + + public InternalSignalRConnectionInfoConfigurer(SignalRConnectionInfoConfigureFunc Configure) + { + this.Configure = Configure; + } + } +} \ No newline at end of file diff --git a/src/SignalRServiceExtension/Microsoft.Azure.WebJobs.Extensions.SignalRService.csproj b/src/SignalRServiceExtension/Microsoft.Azure.WebJobs.Extensions.SignalRService.csproj index 1aa516c8..5271628f 100644 --- a/src/SignalRServiceExtension/Microsoft.Azure.WebJobs.Extensions.SignalRService.csproj +++ b/src/SignalRServiceExtension/Microsoft.Azure.WebJobs.Extensions.SignalRService.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/SignalRServiceExtension/SecurityTokenValidationAttribute.cs b/src/SignalRServiceExtension/SecurityTokenValidationAttribute.cs new file mode 100644 index 00000000..5da58040 --- /dev/null +++ b/src/SignalRServiceExtension/SecurityTokenValidationAttribute.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Azure.WebJobs.Description; +using System; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + [AttributeUsage(AttributeTargets.ReturnValue | AttributeTargets.Parameter)] + [Binding] + public class SecurityTokenValidationAttribute : Attribute + { + } +} diff --git a/src/SignalRServiceExtension/SignalRConnectionInfoAttribute.cs b/src/SignalRServiceExtension/SignalRConnectionInfoAttribute.cs index fd9c111a..55244eb5 100644 --- a/src/SignalRServiceExtension/SignalRConnectionInfoAttribute.cs +++ b/src/SignalRServiceExtension/SignalRConnectionInfoAttribute.cs @@ -14,7 +14,7 @@ public class SignalRConnectionInfoAttribute : Attribute { [AppSetting(Default = Constants.AzureSignalRConnectionStringName)] public string ConnectionStringSetting { get; set; } - + [AutoResolve] public string HubName { get; set; } diff --git a/test/SignalRServiceExtension.Tests/DefaultSecurityTokenValidatorTests.cs b/test/SignalRServiceExtension.Tests/DefaultSecurityTokenValidatorTests.cs new file mode 100644 index 00000000..bd86473a --- /dev/null +++ b/test/SignalRServiceExtension.Tests/DefaultSecurityTokenValidatorTests.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Internal; +using Microsoft.Azure.WebJobs.Extensions.SignalRService; +using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.Tokens; +using Xunit; + +namespace SignalRServiceExtension.Tests +{ + public class DefaultSecurityTokenValidatorTests + { + public static IEnumerable TestData = new List + { + new object [] + { + "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiYWFhIiwiZXhwIjoxNjk5ODE5MDI1fQ.joh9CXSfRpgZhoraozdQ0Z1DxmUhlXF4ENt_1Ttz7x8", + SecurityTokenStatus.Valid + }, + new object[] + { + "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiYWFhIiwiZXhwIjoyNTMwODk4OTIyMjV9.1dbS2bgRrTvxHhph9lh0TLw34a46ts5jwaJH0OeS8-s", + SecurityTokenStatus.Error + }, + new object[] + { + "", + SecurityTokenStatus.Empty + } + + }; + + [Theory] + [MemberData(nameof(TestData))] + public void ValidateSecurityTokenFacts(string tokenString, SecurityTokenStatus expectedStatus) + { + var ctx = new DefaultHttpContext(); + var req = new DefaultHttpRequest(ctx); + req.Headers.Add("Authorization", new StringValues(tokenString)); + + var issuerToken = "bXlmdW5jdGlvbmF1dGh0ZXN0"; // base64 encoded for "myfunctionauthtest"; + Action configureTokenValidationParameters = parameters => + { + parameters.IssuerSigningKey = new SymmetricSecurityKey(Convert.FromBase64String(issuerToken)); + parameters.RequireSignedTokens = true; + parameters.ValidateAudience = false; + parameters.ValidateIssuer = false; + parameters.ValidateIssuerSigningKey = true; + parameters.ValidateLifetime = true; + }; + + var securityTokenValidator = new DefaultSecurityTokenValidator(configureTokenValidationParameters); + var securityTokenResult = securityTokenValidator.ValidateToken(req); + + Assert.Equal(expectedStatus, securityTokenResult.Status); + } + } +} From 2cb8da24903d793f230ef92aa215755f0f00c035 Mon Sep 17 00:00:00 2001 From: Chenyang Liu Date: Mon, 2 Mar 2020 13:29:18 +0800 Subject: [PATCH 05/21] Add Dispatcher and Executor for SignalR Trigger (#94) * stash * stash2 * Add dispatcher * Some fix according to comments * update according to comments * Some refactor and add more tests * Add more tests --- azure-functions-signalrservice-extension.sln | 15 ++ .../Config/SignalRConfigProvider.cs | 27 +++- .../SignalRTriggerAuthorizeFailedException.cs | 12 ++ .../Exceptions/SignalRTriggerException.cs | 18 +++ .../Executor/ExecutionContext.cs | 14 ++ .../Executor/SignalRConnectMethodExecutor.cs | 37 +++++ .../SignalRDisconnectMethodExecutor.cs | 37 +++++ .../SignalRInvocationMethodExecutor.cs | 82 +++++++++++ .../Executor/SignalRMethodExecutor.cs | 65 +++++++++ .../ISignalRTriggerDispatcher.cs | 2 +- .../Resolver/IRequestResolver.cs | 25 ++++ .../Resolver/SignalRRequestResolver.cs | 75 ++++++++++ .../SignalRTriggerAttribute.cs | 7 + .../TriggerBindings/SignalRTriggerBinding.cs | 4 +- .../SignalRTriggerDispatcher.cs | 106 ++++++++++++++ .../Utils/SignalRTriggerUtils.cs | 79 +++++++++++ .../Utils/TupleStringIgnoreCasesComparer.cs | 29 ++++ ....SignalR.Serverless.Protocols.Tests.csproj | 4 +- .../ServerlessProtocolTests.cs | 1 + .../BinaryMessageParser.cs | 4 +- ...ensions.SignalRService.Tests.Common.csproj | 11 ++ .../TextMessageParser.cs | 4 +- .../SignalRServiceExtension.Tests.csproj | 1 + .../Trigger/SignalRMethodExecutorTests.cs | 129 ++++++++++++++++++ .../Trigger/SignalRTriggerDispatcherTests.cs | 123 +++++++++++++++++ .../Trigger/SignalRTriggerTests.cs | 5 +- .../Utils/TestHelpers.cs | 49 +++++++ .../Utils/TestTriggerDispatcher.cs | 8 +- 28 files changed, 957 insertions(+), 16 deletions(-) create mode 100644 src/SignalRServiceExtension/Exceptions/SignalRTriggerAuthorizeFailedException.cs create mode 100644 src/SignalRServiceExtension/Exceptions/SignalRTriggerException.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/Executor/ExecutionContext.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/Executor/SignalRConnectMethodExecutor.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/Executor/SignalRDisconnectMethodExecutor.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/Executor/SignalRInvocationMethodExecutor.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/Executor/SignalRMethodExecutor.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/Resolver/IRequestResolver.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/Resolver/SignalRRequestResolver.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/SignalRTriggerDispatcher.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/Utils/SignalRTriggerUtils.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/Utils/TupleStringIgnoreCasesComparer.cs rename test/{Microsoft.Azure.SignalR.Serverless.Protocols.Tests => Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common}/BinaryMessageParser.cs (96%) create mode 100644 test/Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common/Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common.csproj rename test/{Microsoft.Azure.SignalR.Serverless.Protocols.Tests => Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common}/TextMessageParser.cs (87%) create mode 100644 test/SignalRServiceExtension.Tests/Trigger/SignalRMethodExecutorTests.cs create mode 100644 test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerDispatcherTests.cs diff --git a/azure-functions-signalrservice-extension.sln b/azure-functions-signalrservice-extension.sln index bba77681..e2b8dfcd 100644 --- a/azure-functions-signalrservice-extension.sln +++ b/azure-functions-signalrservice-extension.sln @@ -26,6 +26,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Azure.SignalR.Ser EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Azure.SignalR.Serverless.Protocols.Tests", "test\Microsoft.Azure.SignalR.Serverless.Protocols.Tests\Microsoft.Azure.SignalR.Serverless.Protocols.Tests.csproj", "{E796842E-4BE7-48F2-8C77-89B42AE065DB}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common", "test\Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common\Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common.csproj", "{BACA8231-3939-4340-B405-CA681DB4C89B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -84,6 +86,18 @@ Global {E796842E-4BE7-48F2-8C77-89B42AE065DB}.Release|x64.Build.0 = Release|Any CPU {E796842E-4BE7-48F2-8C77-89B42AE065DB}.Release|x86.ActiveCfg = Release|Any CPU {E796842E-4BE7-48F2-8C77-89B42AE065DB}.Release|x86.Build.0 = Release|Any CPU + {BACA8231-3939-4340-B405-CA681DB4C89B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BACA8231-3939-4340-B405-CA681DB4C89B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BACA8231-3939-4340-B405-CA681DB4C89B}.Debug|x64.ActiveCfg = Debug|Any CPU + {BACA8231-3939-4340-B405-CA681DB4C89B}.Debug|x64.Build.0 = Debug|Any CPU + {BACA8231-3939-4340-B405-CA681DB4C89B}.Debug|x86.ActiveCfg = Debug|Any CPU + {BACA8231-3939-4340-B405-CA681DB4C89B}.Debug|x86.Build.0 = Debug|Any CPU + {BACA8231-3939-4340-B405-CA681DB4C89B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BACA8231-3939-4340-B405-CA681DB4C89B}.Release|Any CPU.Build.0 = Release|Any CPU + {BACA8231-3939-4340-B405-CA681DB4C89B}.Release|x64.ActiveCfg = Release|Any CPU + {BACA8231-3939-4340-B405-CA681DB4C89B}.Release|x64.Build.0 = Release|Any CPU + {BACA8231-3939-4340-B405-CA681DB4C89B}.Release|x86.ActiveCfg = Release|Any CPU + {BACA8231-3939-4340-B405-CA681DB4C89B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -93,6 +107,7 @@ Global {CFFE1AEB-0D5A-458E-AA45-8F312B1F37F3} = {D6082274-DF4A-455D-9EF3-090C74BC96A1} {B6468EC0-E62B-4037-BB77-461DB3AB6F20} = {7005F387-A2ED-42B0-8CE1-41639A6D1E51} {E796842E-4BE7-48F2-8C77-89B42AE065DB} = {D6082274-DF4A-455D-9EF3-090C74BC96A1} + {BACA8231-3939-4340-B405-CA681DB4C89B} = {D6082274-DF4A-455D-9EF3-090C74BC96A1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {227AD9AE-1447-4D8C-A014-50ABEC8E005C} diff --git a/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs b/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs index 00396cca..a63186a9 100644 --- a/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs +++ b/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs @@ -4,10 +4,14 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Azure.SignalR.Management; using Microsoft.Azure.WebJobs.Description; using Microsoft.Azure.WebJobs.Host.Bindings; using Microsoft.Azure.WebJobs.Host.Config; +using Microsoft.Azure.WebJobs.Logging; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -15,8 +19,8 @@ namespace Microsoft.Azure.WebJobs.Extensions.SignalRService { - [Extension("SignalR")] - internal class SignalRConfigProvider : IExtensionConfigProvider + [Extension("SignalR", "signalr")] + internal class SignalRConfigProvider : IExtensionConfigProvider, IAsyncConverter { public IConfiguration Configuration { get; } @@ -27,6 +31,7 @@ internal class SignalRConfigProvider : IExtensionConfigProvider private readonly ILoggerFactory loggerFactory; private readonly ISecurityTokenValidator securityTokenValidator; private readonly ISignalRConnectionInfoConfigurer signalRConnectionInfoConfigurer; + private readonly ISignalRTriggerDispatcher _dispatcher; public SignalRConfigProvider( IOptions options, @@ -38,13 +43,16 @@ public SignalRConfigProvider( { this.options = options.Value; this.loggerFactory = loggerFactory; - this.logger = loggerFactory.CreateLogger("SignalR"); + this.logger = loggerFactory.CreateLogger(LogCategories.CreateTriggerCategory("SignalR")); this.nameResolver = nameResolver; Configuration = configuration; this.securityTokenValidator = securityTokenValidator; this.signalRConnectionInfoConfigurer = signalRConnectionInfoConfigurer; + this._dispatcher = new SignalRTriggerDispatcher(); } + // GetWebhookHandler() need the Obsolete + [Obsolete("preview")] public void Initialize(ExtensionConfigContext context) { if (context == null) @@ -69,11 +77,19 @@ public void Initialize(ExtensionConfigContext context) StaticServiceHubContextStore.ServiceManagerStore = new ServiceManagerStore(options.AzureSignalRServiceTransportType, Configuration, loggerFactory); + var url = context.GetWebhookHandler(); + logger.LogInformation($"Registered SignalR trigger Endpoint = {url?.GetLeftPart(UriPartial.Path)}"); + context.AddConverter(JObject.FromObject) .AddConverter(JObject.FromObject) .AddConverter(input => input.ToObject()) .AddConverter(input => input.ToObject()); + // Trigger binding rule + context.AddBindingRule() + .BindToTrigger(new SignalRTriggerBindingProvider(_dispatcher)); + + // Non-trigger binding rule var signalRConnectionInputBindingProvider = new SignalRConnectionInputBindingProvider(this, securityTokenValidator, signalRConnectionInfoConfigurer); var signalRConnectionInfoAttributeRule = context.AddBindingRule(); @@ -90,6 +106,11 @@ public void Initialize(ExtensionConfigContext context) logger.LogInformation("SignalRService binding initialized"); } + public Task ConvertAsync(HttpRequestMessage input, CancellationToken cancellationToken) + { + return _dispatcher.ExecuteAsync(input, cancellationToken); + } + public AzureSignalRClient GetAzureSignalRClient(string attributeConnectionString, string attributeHubName) { var connectionString = FirstOrDefault(attributeConnectionString, options.ConnectionString); diff --git a/src/SignalRServiceExtension/Exceptions/SignalRTriggerAuthorizeFailedException.cs b/src/SignalRServiceExtension/Exceptions/SignalRTriggerAuthorizeFailedException.cs new file mode 100644 index 00000000..2a0f6d64 --- /dev/null +++ b/src/SignalRServiceExtension/Exceptions/SignalRTriggerAuthorizeFailedException.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class SignalRTriggerAuthorizeFailedException : SignalRTriggerException + { + public SignalRTriggerAuthorizeFailedException() : base("The request is unauthorized, please check the Signature.") + { + } + } +} diff --git a/src/SignalRServiceExtension/Exceptions/SignalRTriggerException.cs b/src/SignalRServiceExtension/Exceptions/SignalRTriggerException.cs new file mode 100644 index 00000000..669f3554 --- /dev/null +++ b/src/SignalRServiceExtension/Exceptions/SignalRTriggerException.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class SignalRTriggerException : Exception + { + public SignalRTriggerException() : base() + { + } + + public SignalRTriggerException(string message) : base(message) + { + } + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/Executor/ExecutionContext.cs b/src/SignalRServiceExtension/TriggerBindings/Executor/ExecutionContext.cs new file mode 100644 index 00000000..c9d63352 --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/Executor/ExecutionContext.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Azure.WebJobs.Host.Executors; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class ExecutionContext + { + public ITriggeredFunctionExecutor Executor { get; set; } + + public string AccessKey { get; set; } + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/Executor/SignalRConnectMethodExecutor.cs b/src/SignalRServiceExtension/TriggerBindings/Executor/SignalRConnectMethodExecutor.cs new file mode 100644 index 00000000..ebd884e2 --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/Executor/SignalRConnectMethodExecutor.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class SignalRConnectMethodExecutor : SignalRMethodExecutor + { + public SignalRConnectMethodExecutor(IRequestResolver resolver, ExecutionContext executionContext): base(resolver, executionContext) + { + } + + public override async Task ExecuteAsync(HttpRequestMessage request) + { + if (!Resolver.TryGetInvocationContext(request, out var context)) + { + //TODO: More detailed exception + throw new SignalRTriggerException(); + } + + var result = await ExecuteWithAuthAsync(request, ExecutionContext, context); + if (!result.Succeeded) + { + return new HttpResponseMessage(HttpStatusCode.Forbidden); + } + return new HttpResponseMessage(HttpStatusCode.OK); + } + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/Executor/SignalRDisconnectMethodExecutor.cs b/src/SignalRServiceExtension/TriggerBindings/Executor/SignalRDisconnectMethodExecutor.cs new file mode 100644 index 00000000..82dbe77a --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/Executor/SignalRDisconnectMethodExecutor.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Azure.SignalR.Serverless.Protocols; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class SignalRDisconnectMethodExecutor: SignalRMethodExecutor + { + public SignalRDisconnectMethodExecutor(IRequestResolver resolver, ExecutionContext executionContext): base(resolver, executionContext) + { + } + + public override async Task ExecuteAsync(HttpRequestMessage request) + { + if (!Resolver.TryGetInvocationContext(request, out var context)) + { + //TODO: More detailed exception + throw new SignalRTriggerException(); + } + var (message, _) = await Resolver.GetMessageAsync(request); + context.Error = message.Error; + + await ExecuteWithAuthAsync(request, ExecutionContext, context); + return new HttpResponseMessage(HttpStatusCode.OK); + } + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/Executor/SignalRInvocationMethodExecutor.cs b/src/SignalRServiceExtension/TriggerBindings/Executor/SignalRInvocationMethodExecutor.cs new file mode 100644 index 00000000..ccdc85d4 --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/Executor/SignalRInvocationMethodExecutor.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.SignalR.Protocol; +using InvocationMessage = Microsoft.Azure.SignalR.Serverless.Protocols.InvocationMessage; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class SignalRInvocationMethodExecutor: SignalRMethodExecutor + { + public SignalRInvocationMethodExecutor(IRequestResolver resolver, ExecutionContext executionContext): base(resolver, executionContext) + { + } + + public override async Task ExecuteAsync(HttpRequestMessage request) + { + if (!Resolver.TryGetInvocationContext(request, out var context)) + { + //TODO: More detailed exception + throw new SignalRTriggerException(); + } + var (message, protocol) = await Resolver.GetMessageAsync(request); + AssertConsistency(context, message); + context.Arguments = message.Arguments; + + // Only when it's an invoke, we need the result from function execution. + TaskCompletionSource tcs = null; + if (!string.IsNullOrEmpty(message.InvocationId)) + { + tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + HttpResponseMessage response; + CompletionMessage completionMessage = null; + + var functionResult = await ExecuteWithAuthAsync(request, ExecutionContext, context, tcs); + if (tcs != null) + { + if (!functionResult.Succeeded) + { + // TODO: Consider more error details + completionMessage = CompletionMessage.WithError(message.InvocationId, "Execution failed"); + response = new HttpResponseMessage(HttpStatusCode.OK); + } + else + { + var result = await tcs.Task; + completionMessage = CompletionMessage.WithResult(message.InvocationId, result); + response = new HttpResponseMessage(HttpStatusCode.OK); + } + } + else + { + response = new HttpResponseMessage(HttpStatusCode.OK); + } + + if (completionMessage != null) + { + response.Content = new ByteArrayContent(protocol.GetMessageBytes(completionMessage).ToArray()); + } + return response; + } + + private void AssertConsistency(InvocationContext context, InvocationMessage message) + { + if (!string.Equals(context.Event, message.Target, StringComparison.OrdinalIgnoreCase)) + { + // TODO: More detailed exception + throw new SignalRTriggerException(); + } + } + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/Executor/SignalRMethodExecutor.cs b/src/SignalRServiceExtension/TriggerBindings/Executor/SignalRMethodExecutor.cs new file mode 100644 index 00000000..3faa75b7 --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/Executor/SignalRMethodExecutor.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Azure.WebJobs.Host.Executors; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal abstract class SignalRMethodExecutor + { + protected IRequestResolver Resolver { get; } + protected ExecutionContext ExecutionContext { get; } + + protected SignalRMethodExecutor(IRequestResolver resolver, ExecutionContext executionContext) + { + Resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + ExecutionContext = executionContext ?? throw new ArgumentNullException(nameof(executionContext)); + } + + public abstract Task ExecuteAsync(HttpRequestMessage request); + + protected Task ExecuteWithAuthAsync(HttpRequestMessage request, ExecutionContext executor, + InvocationContext context, TaskCompletionSource tcs = null) + { + if (!Resolver.ValidateSignature(request, executor.AccessKey)) + { + throw new SignalRTriggerAuthorizeFailedException(); + } + + return ExecuteAsyncCore(executor.Executor, context, tcs); + } + + private async Task ExecuteAsyncCore(ITriggeredFunctionExecutor executor, InvocationContext context, TaskCompletionSource tcs) + { + var signalRTriggerEvent = new SignalRTriggerEvent + { + Context = context, + TaskCompletionSource = tcs, + }; + + var result = await executor.TryExecuteAsync( + new TriggeredFunctionData + { + TriggerValue = signalRTriggerEvent + }, CancellationToken.None); + + // If there's exception in invocation, tcs may not be set. + // And SetException seems not necessary. Exception can be get from FunctionResult. + if (result.Succeeded == false) + { + tcs?.TrySetResult(null); + } + + return result; + } + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/ISignalRTriggerDispatcher.cs b/src/SignalRServiceExtension/TriggerBindings/ISignalRTriggerDispatcher.cs index 15af0350..56074e60 100644 --- a/src/SignalRServiceExtension/TriggerBindings/ISignalRTriggerDispatcher.cs +++ b/src/SignalRServiceExtension/TriggerBindings/ISignalRTriggerDispatcher.cs @@ -10,7 +10,7 @@ namespace Microsoft.Azure.WebJobs.Extensions.SignalRService { internal interface ISignalRTriggerDispatcher { - void Map((string hubName, string category, string @event) key, ITriggeredFunctionExecutor executor); + void Map((string hubName, string category, string @event) key, ExecutionContext executor); Task ExecuteAsync(HttpRequestMessage req, CancellationToken token = default); } diff --git a/src/SignalRServiceExtension/TriggerBindings/Resolver/IRequestResolver.cs b/src/SignalRServiceExtension/TriggerBindings/Resolver/IRequestResolver.cs new file mode 100644 index 00000000..b80cce4a --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/Resolver/IRequestResolver.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.SignalR.Protocol; +using Microsoft.Azure.SignalR.Serverless.Protocols; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal interface IRequestResolver + { + bool ValidateContentType(HttpRequestMessage request); + + bool ValidateSignature(HttpRequestMessage request, string accessKey); + + bool TryGetInvocationContext(HttpRequestMessage request, out InvocationContext context); + + Task<(T, IHubProtocol)> GetMessageAsync(HttpRequestMessage request) where T : ServerlessMessage, new(); + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/Resolver/SignalRRequestResolver.cs b/src/SignalRServiceExtension/TriggerBindings/Resolver/SignalRRequestResolver.cs new file mode 100644 index 00000000..e6820556 --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/Resolver/SignalRRequestResolver.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Buffers; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +using Microsoft.AspNetCore.SignalR.Protocol; +using Microsoft.Azure.SignalR.Serverless.Protocols; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class SignalRRequestResolver : IRequestResolver + { + public bool ValidateContentType(HttpRequestMessage request) + { + var contentType = request.Content.Headers.ContentType.MediaType; + if (string.IsNullOrEmpty(contentType)) + { + return false; + } + return contentType == Constants.JsonContentType || contentType == Constants.MessagePackContentType; + } + + public bool ValidateSignature(HttpRequestMessage request, string accessToken) + { + //TODO: Add real signature validation + return true; + } + + public bool TryGetInvocationContext(HttpRequestMessage request, out InvocationContext context) + { + context = new InvocationContext(); + // Required properties + context.ConnectionId = request.Headers.GetValues(Constants.AsrsConnectionIdHeader).FirstOrDefault(); + if (string.IsNullOrEmpty(context.ConnectionId)) + { + return false; + } + context.Hub = request.Headers.GetValues(Constants.AsrsHubNameHeader).FirstOrDefault(); + context.Category = request.Headers.GetValues(Constants.AsrsCategory).FirstOrDefault(); + context.Event = request.Headers.GetValues(Constants.AsrsEvent).FirstOrDefault(); + // Optional properties + if (request.Headers.TryGetValues(Constants.AsrsUserId, out var values)) + { + context.UserId = values.FirstOrDefault(); + } + if (request.Headers.TryGetValues(Constants.AsrsClientQueryString, out values)) + { + context.Query = SignalRTriggerUtils.GetQueryDictionary(values.FirstOrDefault()); + } + if (request.Headers.TryGetValues(Constants.AsrsUserClaims, out values)) + { + context.Claims = SignalRTriggerUtils.GetClaimDictionary(values.FirstOrDefault()); + } + context.Headers = SignalRTriggerUtils.GetHeaderDictionary(request); + + return true; + } + + public async Task<(T, IHubProtocol)> GetMessageAsync(HttpRequestMessage request) where T : ServerlessMessage, new() + { + var payload = new ReadOnlySequence(await request.Content.ReadAsByteArrayAsync()); + var messageParser = MessageParser.GetParser(request.Content.Headers.ContentType.MediaType); + if (!messageParser.TryParseMessage(ref payload, out var message)) + { + throw new SignalRTriggerException("Parsing message failed"); + } + + return ((T)message, messageParser.Protocol); + } + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs index d34c53bf..341b7bd0 100644 --- a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs @@ -11,6 +11,13 @@ namespace Microsoft.Azure.WebJobs.Extensions.SignalRService [Binding] public class SignalRTriggerAttribute : Attribute { + + /// + /// Connection string that connect to Azure SignalR Service + /// + [AppSetting(Default = Constants.AzureSignalRConnectionStringName)] + public string ConnectionStringSetting { get; set; } + /// /// The hub of request belongs to. /// diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs index d1cfdd36..44050e77 100644 --- a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs @@ -57,7 +57,9 @@ public Task CreateListenerAsync(ListenerFactoryContext context) } // It's not a real listener, and it doesn't need a start or close. - _dispatcher.Map((_attribute.HubName, _attribute.Category, _attribute.Event), context.Executor); + _dispatcher.Map((_attribute.HubName, _attribute.Category, _attribute.Event), + new ExecutionContext{Executor = context.Executor, AccessKey = SignalRTriggerUtils.GetAccessKey(_attribute.ConnectionStringSetting)}); + return Task.FromResult(new NullListener()); } diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerDispatcher.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerDispatcher.cs new file mode 100644 index 00000000..eedea8ad --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerDispatcher.cs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class SignalRTriggerDispatcher : ISignalRTriggerDispatcher + { + private readonly Dictionary<(string hub, string category, string @event), SignalRMethodExecutor> _executors = + new Dictionary<(string, string, string), SignalRMethodExecutor>(TupleStringIgnoreCasesComparer.Instance); + private readonly IRequestResolver _resolver; + + public SignalRTriggerDispatcher(IRequestResolver resolver = null) + { + _resolver = resolver ?? new SignalRRequestResolver(); + } + + public void Map((string hubName, string category, string @event) key, ExecutionContext executor) + { + if (!_executors.ContainsKey(key)) + { + if (key.category == Constants.Category.Connections) + { + if (key.@event == Constants.Events.Connect) + { + _executors.Add(key, new SignalRConnectMethodExecutor(_resolver, executor)); + return; + } + if (key.@event == Constants.Events.Disconnect) + { + _executors.Add(key, new SignalRDisconnectMethodExecutor(_resolver, executor)); + return; + } + throw new SignalRTriggerException($"Event {key.@event} is not supported in connections"); + } + if (key.category == Constants.Category.Messages) + { + _executors.Add(key, new SignalRInvocationMethodExecutor(_resolver, executor)); + return; + } + throw new SignalRTriggerException($"Category {key.category} is not supported"); + } + + throw new SignalRTriggerException( + $"Duplicated key parameter hub: {key.hubName}, category: {key.category}, event: {key.@event}"); + } + + public async Task ExecuteAsync(HttpRequestMessage req, CancellationToken token = default) + { + // TODO: More details about response + if (!_resolver.ValidateContentType(req)) + { + return new HttpResponseMessage(HttpStatusCode.UnsupportedMediaType); + } + + if (!TryGetDispatchingKey(req, out var key)) + { + return new HttpResponseMessage(HttpStatusCode.BadRequest); + } + + if (_executors.TryGetValue(key, out var executor)) + { + try + { + return await executor.ExecuteAsync(req); + } + //TODO: Different response for more details exceptions + catch (SignalRTriggerAuthorizeFailedException ex) + { + return new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + ReasonPhrase = ex.Message + }; + } + catch (Exception ex) + { + return new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + ReasonPhrase = ex.Message + }; + } + } + + // No target hub in functions + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + private bool TryGetDispatchingKey(HttpRequestMessage request, out (string hub, string category, string @event) key) + { + key.hub = request.Headers.GetValues(Constants.AsrsHubNameHeader).First(); + key.category = request.Headers.GetValues(Constants.AsrsCategory).First(); + key.@event = request.Headers.GetValues(Constants.AsrsEvent).First(); + return !string.IsNullOrEmpty(key.hub) && + !string.IsNullOrEmpty(key.category) && + !string.IsNullOrEmpty(key.@event); + } + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/Utils/SignalRTriggerUtils.cs b/src/SignalRServiceExtension/TriggerBindings/Utils/SignalRTriggerUtils.cs new file mode 100644 index 00000000..d939e268 --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/Utils/SignalRTriggerUtils.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal static class SignalRTriggerUtils + { + private const string AccessKeyProperty = "accesskey"; + private static readonly char[] PropertySeparator = { ';' }; + private static readonly char[] KeyValueSeparator = { '=' }; + private static readonly char[] QuerySeparator = { '&' }; + private static readonly char[] HeaderSeparator = { ',' }; + private static readonly string[] ClaimsSeparator = { "= " }; + + public static string GetAccessKey(string connectionString) + { + if (string.IsNullOrEmpty(connectionString)) + { + return null; + } + + var properties = connectionString.Split(PropertySeparator, StringSplitOptions.RemoveEmptyEntries); + if (properties.Length < 2) + { + throw new ArgumentException("Connection string missing required properties endpoint and accessKey."); + } + + foreach (var property in properties) + { + var kvp = property.Split(KeyValueSeparator, 2); + if (kvp.Length != 2) continue; + + var key = kvp[0].Trim(); + if (key == AccessKeyProperty) + { + return kvp[1].Trim(); + } + } + + throw new ArgumentException("Connection string missing required properties accessKey."); + } + + public static IDictionary GetQueryDictionary(string queryString) + { + if (string.IsNullOrEmpty(queryString)) + { + return default; + } + + // The query string looks like "?key1=value1&key2=value2" + var queryArray = queryString.TrimStart('?').Split(QuerySeparator, StringSplitOptions.RemoveEmptyEntries); + return queryArray.Select(p => p.Split(KeyValueSeparator)).ToDictionary(p => p[0].Trim(), p => p[1].Trim()); + } + + public static IDictionary GetClaimDictionary(string claims) + { + if (string.IsNullOrEmpty(claims)) + { + return default; + } + + // The claim string looks like "a= v, b= v" + return claims.Split(HeaderSeparator) + .Select(p => p.Split(ClaimsSeparator, StringSplitOptions.RemoveEmptyEntries)) + .ToDictionary(p => p[0].Trim(), p => p[1].Trim()); + } + + public static IDictionary GetHeaderDictionary(HttpRequestMessage request) + { + return request.Headers.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.FirstOrDefault(), StringComparer.OrdinalIgnoreCase); + } + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/Utils/TupleStringIgnoreCasesComparer.cs b/src/SignalRServiceExtension/TriggerBindings/Utils/TupleStringIgnoreCasesComparer.cs new file mode 100644 index 00000000..8cce9ca0 --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/Utils/TupleStringIgnoreCasesComparer.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Text; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class TupleStringIgnoreCasesComparer: IEqualityComparer<(string, string, string)> + { + public static readonly TupleStringIgnoreCasesComparer Instance = new TupleStringIgnoreCasesComparer(); + + public bool Equals((string, string, string) x, (string, string, string) y) + { + return StringComparer.InvariantCultureIgnoreCase.Equals(x.Item1, y.Item1) && + StringComparer.InvariantCultureIgnoreCase.Equals(x.Item2, y.Item2) && + StringComparer.InvariantCultureIgnoreCase.Equals(x.Item3, y.Item3); + } + + public int GetHashCode((string, string, string) obj) + { + return StringComparer.InvariantCultureIgnoreCase.GetHashCode(obj.Item1) ^ + StringComparer.InvariantCultureIgnoreCase.GetHashCode(obj.Item2) ^ + StringComparer.InvariantCultureIgnoreCase.GetHashCode(obj.Item3); + } + } +} diff --git a/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/Microsoft.Azure.SignalR.Serverless.Protocols.Tests.csproj b/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/Microsoft.Azure.SignalR.Serverless.Protocols.Tests.csproj index 6cf07b23..bc984ebe 100644 --- a/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/Microsoft.Azure.SignalR.Serverless.Protocols.Tests.csproj +++ b/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/Microsoft.Azure.SignalR.Serverless.Protocols.Tests.csproj @@ -9,10 +9,12 @@ + - + + \ No newline at end of file diff --git a/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/ServerlessProtocolTests.cs b/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/ServerlessProtocolTests.cs index 85411500..b6ec9bcf 100644 --- a/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/ServerlessProtocolTests.cs +++ b/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/ServerlessProtocolTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Text; using Microsoft.AspNetCore.SignalR.Protocol; +using Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common; using Newtonsoft.Json; using Xunit; diff --git a/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/BinaryMessageParser.cs b/test/Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common/BinaryMessageParser.cs similarity index 96% rename from test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/BinaryMessageParser.cs rename to test/Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common/BinaryMessageParser.cs index 85d1cc8b..8d8d4526 100644 --- a/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/BinaryMessageParser.cs +++ b/test/Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common/BinaryMessageParser.cs @@ -3,9 +3,9 @@ using System.Collections.Generic; using System.Text; -namespace Microsoft.Azure.SignalR.Serverless.Protocols.Tests +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common { - internal static class BinaryMessageParser + public static class BinaryMessageParser { internal const int MaxLengthPrefixSize = 5; diff --git a/test/Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common/Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common.csproj b/test/Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common/Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common.csproj new file mode 100644 index 00000000..5af379c9 --- /dev/null +++ b/test/Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common/Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common.csproj @@ -0,0 +1,11 @@ + + + + netstandard2.0 + + + + + + + diff --git a/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/TextMessageParser.cs b/test/Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common/TextMessageParser.cs similarity index 87% rename from test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/TextMessageParser.cs rename to test/Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common/TextMessageParser.cs index 2b61868f..62fa9afe 100644 --- a/test/Microsoft.Azure.SignalR.Serverless.Protocols.Tests/TextMessageParser.cs +++ b/test/Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common/TextMessageParser.cs @@ -3,12 +3,12 @@ using System.Collections.Generic; using System.Text; -namespace Microsoft.Azure.SignalR.Serverless.Protocols.Tests +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common { /// /// The same as https://github.com/aspnet/SignalR/blob/release/2.2/src/Common/TextMessageParser.cs /// - internal static class TextMessageParser + public static class TextMessageParser { public static readonly byte RecordSeparator = 0x1e; diff --git a/test/SignalRServiceExtension.Tests/SignalRServiceExtension.Tests.csproj b/test/SignalRServiceExtension.Tests/SignalRServiceExtension.Tests.csproj index 69832f98..f8b3dbb9 100644 --- a/test/SignalRServiceExtension.Tests/SignalRServiceExtension.Tests.csproj +++ b/test/SignalRServiceExtension.Tests/SignalRServiceExtension.Tests.csproj @@ -13,6 +13,7 @@ + diff --git a/test/SignalRServiceExtension.Tests/Trigger/SignalRMethodExecutorTests.cs b/test/SignalRServiceExtension.Tests/Trigger/SignalRMethodExecutorTests.cs new file mode 100644 index 00000000..ec2f4e10 --- /dev/null +++ b/test/SignalRServiceExtension.Tests/Trigger/SignalRMethodExecutorTests.cs @@ -0,0 +1,129 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Protocol; +using Microsoft.Azure.SignalR.Serverless.Protocols; +using Microsoft.Azure.WebJobs.Extensions.SignalRService; +using Microsoft.Azure.WebJobs.Extensions.SignalRService.Tests.Common; +using Microsoft.Azure.WebJobs.Host.Executors; +using Moq; +using Newtonsoft.Json; +using SignalRServiceExtension.Tests.Utils; +using Xunit; +using ExecutionContext = Microsoft.Azure.WebJobs.Extensions.SignalRService.ExecutionContext; + +namespace SignalRServiceExtension.Tests.Trigger +{ + public class SignalRMethodExecutorTests + { + private readonly ITriggeredFunctionExecutor _triggeredFunctionExecutor; + private readonly TaskCompletionSource _triggeredFunctionDataTcs; + + public SignalRMethodExecutorTests() + { + _triggeredFunctionDataTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var executorMoc = new Mock(); + executorMoc.Setup(f => f.TryExecuteAsync(It.IsAny(), It.IsAny())) + .Returns((data, token) => + { + _triggeredFunctionDataTcs.TrySetResult(data); + ((SignalRTriggerEvent) data.TriggerValue).TaskCompletionSource?.TrySetResult(string.Empty); + return Task.FromResult(new FunctionResult(true)); + }); + _triggeredFunctionExecutor = executorMoc.Object; + } + + + [Fact] + public async Task SignalRConnectMethodExecutorTest() + { + var resolver = new SignalRRequestResolver(); + var methodExecutor = new SignalRConnectMethodExecutor(resolver, new ExecutionContext {Executor = _triggeredFunctionExecutor }); + var hub = Guid.NewGuid().ToString(); + var category = Guid.NewGuid().ToString(); + var @event = Guid.NewGuid().ToString(); + var connectionId = Guid.NewGuid().ToString(); + var content = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new OpenConnectionMessage {Type = 10})); + var request = TestHelpers.CreateHttpRequestMessage(hub, category, @event, connectionId, contentType: Constants.JsonContentType, content: content); + await methodExecutor.ExecuteAsync(request); + + var result = await _triggeredFunctionDataTcs.Task; + var triggerData = (SignalRTriggerEvent) result.TriggerValue; + Assert.Null(triggerData.TaskCompletionSource); + Assert.Equal(hub, triggerData.Context.Hub); + Assert.Equal(category, triggerData.Context.Category); + Assert.Equal(@event, triggerData.Context.Event); + Assert.Equal(connectionId, triggerData.Context.ConnectionId); + Assert.Equal(hub, triggerData.Context.Hub); + } + + [Fact] + public async Task SignalRDisconnectMethodExecutorTest() + { + var resolver = new SignalRRequestResolver(); + var methodExecutor = new SignalRDisconnectMethodExecutor(resolver, new ExecutionContext { Executor = _triggeredFunctionExecutor }); + var hub = Guid.NewGuid().ToString(); + var category = Guid.NewGuid().ToString(); + var @event = Guid.NewGuid().ToString(); + var connectionId = Guid.NewGuid().ToString(); + var error = Guid.NewGuid().ToString(); + var content = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new CloseConnectionMessage { Type = 11, Error = error })); + var request = TestHelpers.CreateHttpRequestMessage(hub, category, @event, connectionId, contentType: Constants.JsonContentType, content: content); + await methodExecutor.ExecuteAsync(request); + + var result = await _triggeredFunctionDataTcs.Task; + var triggerData = (SignalRTriggerEvent)result.TriggerValue; + Assert.Null(triggerData.TaskCompletionSource); + Assert.Equal(hub, triggerData.Context.Hub); + Assert.Equal(category, triggerData.Context.Category); + Assert.Equal(@event, triggerData.Context.Event); + Assert.Equal(connectionId, triggerData.Context.ConnectionId); + Assert.Equal(hub, triggerData.Context.Hub); + Assert.Equal(error, triggerData.Context.Error); + } + + [Theory] + [InlineData("json")] + [InlineData("messagepack")] + public async Task SignalRInvocationMethodExecutorTest(string protocolName) + { + var resolver = new SignalRRequestResolver(); + var methodExecutor = new SignalRInvocationMethodExecutor(resolver, new ExecutionContext { Executor = _triggeredFunctionExecutor }); + var hub = Guid.NewGuid().ToString(); + var category = Guid.NewGuid().ToString(); + var @event = Guid.NewGuid().ToString(); + var connectionId = Guid.NewGuid().ToString(); + var arguments = new object[] {Guid.NewGuid().ToString(), Guid.NewGuid().ToString()}; + + var message = new Microsoft.AspNetCore.SignalR.Protocol.InvocationMessage(Guid.NewGuid().ToString(), @event, arguments); + IHubProtocol protocol = protocolName == "json" ? (IHubProtocol)new JsonHubProtocol() : new MessagePackHubProtocol(); + var contentType = protocolName == "json" ? Constants.JsonContentType : Constants.MessagePackContentType; + var bytes = new ReadOnlySequence(protocol.GetMessageBytes(message)); + ReadOnlySequence payload; + if (protocolName == "json") + { + TextMessageParser.TryParseMessage(ref bytes, out payload); + } + else + { + BinaryMessageParser.TryParseMessage(ref bytes, out payload); + } + + var request = TestHelpers.CreateHttpRequestMessage(hub, category, @event, connectionId, contentType: contentType, content: payload.ToArray()); + await methodExecutor.ExecuteAsync(request); + + var result = await _triggeredFunctionDataTcs.Task; + var triggerData = (SignalRTriggerEvent)result.TriggerValue; + Assert.NotNull(triggerData.TaskCompletionSource); + Assert.Equal(hub, triggerData.Context.Hub); + Assert.Equal(category, triggerData.Context.Category); + Assert.Equal(@event, triggerData.Context.Event); + Assert.Equal(connectionId, triggerData.Context.ConnectionId); + Assert.Equal(hub, triggerData.Context.Hub); + Assert.Equal(arguments, triggerData.Context.Arguments); + } + } +} diff --git a/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerDispatcherTests.cs b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerDispatcherTests.cs new file mode 100644 index 00000000..8f2c445f --- /dev/null +++ b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerDispatcherTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Protocol; +using Microsoft.Azure.SignalR.Serverless.Protocols; +using Microsoft.Azure.WebJobs.Extensions.SignalRService; +using Microsoft.Azure.WebJobs.Host.Executors; +using Moq; +using SignalRServiceExtension.Tests.Utils; +using Xunit; +using ExecutionContext = Microsoft.Azure.WebJobs.Extensions.SignalRService.ExecutionContext; + +namespace SignalRServiceExtension.Tests +{ + public class SignalRTriggerDispatcherTests + { + public static IEnumerable AttributeData() + { + yield return new object[] { "connections", "connect", false }; + yield return new object[] { "connections", "disconnect", false }; + yield return new object[] { "connections", Guid.NewGuid().ToString(), true }; + yield return new object[] { "messages", Guid.NewGuid().ToString(), false }; + yield return new object[] { Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), true }; + } + + [Theory] + [MemberData(nameof(AttributeData))] + public async Task DispatcherMappingTest(string category, string @event, bool throwException) + { + var resolve = new TestRequestResolver(); + var dispatcher = new SignalRTriggerDispatcher(resolve); + var key = (hub: Guid.NewGuid().ToString(), category, @event); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var executorMoc = new Mock(); + executorMoc.Setup(f => f.TryExecuteAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new FunctionResult(true))); + var executor = executorMoc.Object; + if (throwException) + { + Assert.ThrowsAny(() => dispatcher.Map(key, new ExecutionContext {Executor = executor, AccessKey = string.Empty})); + return; + } + + dispatcher.Map(key, new ExecutionContext {Executor = executor, AccessKey = string.Empty}); + var request = TestHelpers.CreateHttpRequestMessage(key.hub, key.category, key.@event, Guid.NewGuid().ToString()); + await dispatcher.ExecuteAsync(request); + executorMoc.Verify(e => e.TryExecuteAsync(It.IsAny(), It.IsAny()), Times.Once); + + // We can handle different word cases + request = TestHelpers.CreateHttpRequestMessage(key.hub.ToUpper(), key.category.ToUpper(), key.@event.ToUpper(), Guid.NewGuid().ToString()); + await dispatcher.ExecuteAsync(request); + executorMoc.Verify(e => e.TryExecuteAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Theory] + [MemberData(nameof(AttributeData))] + public async Task ResolverInfluenceTests(string category, string @event, bool throwException) + { + if (throwException) + { + return; + } + var resolver = new TestRequestResolver(); + var dispatcher = new SignalRTriggerDispatcher(resolver); + var key = (hub: Guid.NewGuid().ToString(), category, @event); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var executorMoc = new Mock(); + executorMoc.Setup(f => f.TryExecuteAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new FunctionResult(true))); + var executor = executorMoc.Object; + dispatcher.Map(key, new ExecutionContext { Executor = executor, AccessKey = string.Empty }); + + // Test content type + resolver.ValidateContentTypeResult = false; + var request = TestHelpers.CreateHttpRequestMessage(key.hub, key.category, key.@event, Guid.NewGuid().ToString()); + var res = await dispatcher.ExecuteAsync(request); + Assert.Equal(HttpStatusCode.UnsupportedMediaType, res.StatusCode); + resolver.ValidateContentTypeResult = true; + + // Test signature + resolver.ValidateSignatureResult = false; + request = TestHelpers.CreateHttpRequestMessage(key.hub, key.category, key.@event, Guid.NewGuid().ToString()); + res = await dispatcher.ExecuteAsync(request); + Assert.Equal(HttpStatusCode.Unauthorized, res.StatusCode); + resolver.ValidateSignatureResult = true; + + // Test GetInvocationContext + resolver.GetInvocationContextResult = false; + request = TestHelpers.CreateHttpRequestMessage(key.hub, key.category, key.@event, Guid.NewGuid().ToString()); + res = await dispatcher.ExecuteAsync(request); + Assert.Equal(HttpStatusCode.InternalServerError, res.StatusCode); + resolver.GetInvocationContextResult = true; + } + + private class TestRequestResolver : IRequestResolver + { + public bool ValidateContentTypeResult { get; set; } = true; + + public bool ValidateSignatureResult { get; set; } = true; + + public bool GetInvocationContextResult { get; set; } = true; + + public bool ValidateContentType(HttpRequestMessage request) => ValidateContentTypeResult; + + public bool ValidateSignature(HttpRequestMessage request, string accessKey) => ValidateSignatureResult; + + public bool TryGetInvocationContext(HttpRequestMessage request, out InvocationContext context) + { + context = new InvocationContext(); + return GetInvocationContextResult; + } + + public Task<(T, IHubProtocol)> GetMessageAsync(HttpRequestMessage request) where T : ServerlessMessage, new() + { + return Task.FromResult<(T, IHubProtocol)>((new T(), new JsonHubProtocol())); + } + } + } +} diff --git a/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerTests.cs b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerTests.cs index 89fd9003..3750f003 100644 --- a/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerTests.cs +++ b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerTests.cs @@ -33,6 +33,7 @@ public async Task BindAsyncTest() Assert.Equal(context, await result.ValueProvider.GetValueAsync()); } + // Test CreateListenerAsync() in binding will call IDispatcher.Map() [Fact] public async Task CreateListenerTest() { @@ -42,11 +43,11 @@ public async Task CreateListenerTest() var parameterInfo = this.GetType().GetMethod(nameof(TestFunction)).GetParameters()[0]; var dispatcher = new TestTriggerDispatcher(); var hub = Guid.NewGuid().ToString(); - var category = Guid.NewGuid().ToString(); var method = Guid.NewGuid().ToString(); + var category = Guid.NewGuid().ToString(); var binding = new SignalRTriggerBinding(parameterInfo, new SignalRTriggerAttribute{HubName = hub, Category = category, Event = method}, dispatcher); await binding.CreateListenerAsync(listenerFactoryContext); - Assert.Equal(executor, dispatcher.Executors[(hub, category, method)]); + Assert.Equal(executor, dispatcher.Executors[(hub, category, method)].Executor); } public void TestFunction(InvocationContext context) diff --git a/test/SignalRServiceExtension.Tests/Utils/TestHelpers.cs b/test/SignalRServiceExtension.Tests/Utils/TestHelpers.cs index 02d7d7b1..bb73be7d 100644 --- a/test/SignalRServiceExtension.Tests/Utils/TestHelpers.cs +++ b/test/SignalRServiceExtension.Tests/Utils/TestHelpers.cs @@ -3,6 +3,9 @@ using System; using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using Microsoft.AspNetCore.Http; using Microsoft.Azure.WebJobs; using Microsoft.Azure.WebJobs.Extensions.SignalRService; using Microsoft.Azure.WebJobs.Host.Config; @@ -53,5 +56,51 @@ public static JobHost GetJobHost(this IHost host) { return host.Services.GetService() as JobHost; } + + public static HttpRequestMessage CreateHttpRequestMessage(string hub, string category, string @event, string connectionId, + string contentType = Constants.JsonContentType, byte[] content = null) + { + var context = new DefaultHttpContext(); + context.Request.ContentType = contentType; + context.Request.Method = "Post"; + context.Request.Headers.Add(Constants.AsrsHubNameHeader, hub); + context.Request.Headers.Add(Constants.AsrsCategory, category); + context.Request.Headers.Add(Constants.AsrsEvent, @event); + context.Request.Headers.Add(Constants.AsrsConnectionIdHeader, connectionId); + context.Request.Body = content == null ? Stream.Null : new MemoryStream(content); + + return CreateHttpRequestMessageFromContext(context); + } + + private static HttpRequestMessage CreateHttpRequestMessageFromContext(HttpContext httpContext) + { + var httpRequest = httpContext.Request; + var uriString = + httpRequest.Scheme + "://" + + httpRequest.Host + + httpRequest.PathBase + + httpRequest.Path + + httpRequest.QueryString; + + var message = new HttpRequestMessage(new HttpMethod(httpRequest.Method), uriString); + + // This allows us to pass the message through APIs defined in legacy code and then + // operate on the HttpContext inside. + message.Properties[nameof(HttpContext)] = httpContext; + + message.Content = new StreamContent(httpRequest.Body); + + foreach (var header in httpRequest.Headers) + { + // Every header should be able to fit into one of the two header collections. + // Try message.Headers first since that accepts more of them. + if (!message.Headers.TryAddWithoutValidation(header.Key, (IEnumerable)header.Value)) + { + message.Content.Headers.TryAddWithoutValidation(header.Key, (IEnumerable)header.Value); + } + } + + return message; + } } } \ No newline at end of file diff --git a/test/SignalRServiceExtension.Tests/Utils/TestTriggerDispatcher.cs b/test/SignalRServiceExtension.Tests/Utils/TestTriggerDispatcher.cs index 4b2e272f..5ab316dd 100644 --- a/test/SignalRServiceExtension.Tests/Utils/TestTriggerDispatcher.cs +++ b/test/SignalRServiceExtension.Tests/Utils/TestTriggerDispatcher.cs @@ -9,16 +9,16 @@ using System.Threading.Tasks; using Microsoft.Azure.WebJobs.Extensions.SignalRService; -using Microsoft.Azure.WebJobs.Host.Executors; +using ExecutionContext = Microsoft.Azure.WebJobs.Extensions.SignalRService.ExecutionContext; namespace SignalRServiceExtension.Tests.Utils { class TestTriggerDispatcher : ISignalRTriggerDispatcher { - public Dictionary<(string, string, string), ITriggeredFunctionExecutor> Executors { get; } = - new Dictionary<(string, string, string), ITriggeredFunctionExecutor>(); + public Dictionary<(string, string, string), ExecutionContext> Executors { get; } = + new Dictionary<(string, string, string), ExecutionContext>(); - public void Map((string hubName, string category, string @event) key, ITriggeredFunctionExecutor executor) + public void Map((string hubName, string category, string @event) key, ExecutionContext executor) { Executors.Add(key, executor); } From a069b32035ebe3954d04b3dc666c7d7789c89417 Mon Sep 17 00:00:00 2001 From: Wanpeng Li Date: Tue, 3 Mar 2020 12:14:04 +0800 Subject: [PATCH 06/21] refactor (#96) --- .../Bindings/SignalRCollectorBuilder.cs | 8 ++-- .../SignalRConnectionInputBindingProvider.cs | 12 +++--- .../Config/SignalRConfigProvider.cs | 42 ++++--------------- src/SignalRServiceExtension/Utils.cs | 23 ++++++++++ 4 files changed, 42 insertions(+), 43 deletions(-) create mode 100644 src/SignalRServiceExtension/Utils.cs diff --git a/src/SignalRServiceExtension/Bindings/SignalRCollectorBuilder.cs b/src/SignalRServiceExtension/Bindings/SignalRCollectorBuilder.cs index 493132ec..bbcf004d 100644 --- a/src/SignalRServiceExtension/Bindings/SignalRCollectorBuilder.cs +++ b/src/SignalRServiceExtension/Bindings/SignalRCollectorBuilder.cs @@ -5,16 +5,16 @@ namespace Microsoft.Azure.WebJobs.Extensions.SignalRService { internal class SignalRCollectorBuilder : IConverter> { - private readonly SignalRConfigProvider configProvider; + private readonly SignalROptions options; - public SignalRCollectorBuilder(SignalRConfigProvider configProvider) + public SignalRCollectorBuilder(SignalROptions options) { - this.configProvider = configProvider; + this.options = options; } public IAsyncCollector Convert(SignalRAttribute attribute) { - var client = configProvider.GetAzureSignalRClient(attribute.ConnectionStringSetting, attribute.HubName); + var client = Utils.GetAzureSignalRClient(attribute.ConnectionStringSetting, attribute.HubName, options); return new SignalRAsyncCollector(client); } } diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBindingProvider.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBindingProvider.cs index d644a6d2..2c13978d 100644 --- a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBindingProvider.cs +++ b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBindingProvider.cs @@ -10,13 +10,15 @@ namespace Microsoft.Azure.WebJobs.Extensions.SignalRService internal class SignalRConnectionInputBindingProvider : IBindingProvider { private readonly ISecurityTokenValidator securityTokenValidator; - private readonly SignalRConfigProvider signalRConfigProvider; + private readonly SignalROptions options; private readonly ISignalRConnectionInfoConfigurer signalRConnectionInfoConfigurer; + private readonly INameResolver nameResolver; - public SignalRConnectionInputBindingProvider(SignalRConfigProvider signalRConfigProvider, ISecurityTokenValidator securityTokenValidator, ISignalRConnectionInfoConfigurer signalRConnectionInfoConfigurer) + public SignalRConnectionInputBindingProvider(INameResolver nameResolver, SignalROptions options, ISecurityTokenValidator securityTokenValidator, ISignalRConnectionInfoConfigurer signalRConnectionInfoConfigurer) { + this.nameResolver = nameResolver; this.securityTokenValidator = securityTokenValidator; - this.signalRConfigProvider = signalRConfigProvider; + this.options = options; this.signalRConnectionInfoConfigurer = signalRConnectionInfoConfigurer; } @@ -28,8 +30,8 @@ public Task TryCreateAsync(BindingProviderContext context) switch (attr) { case SignalRConnectionInfoAttribute connectionInfoAttribute: - var resolvedConnectionString = signalRConfigProvider.nameResolver.Resolve(connectionInfoAttribute.ConnectionStringSetting); - return Task.FromResult((IBinding)new SignalRConnectionInputBinding(connectionInfoAttribute, signalRConfigProvider.GetAzureSignalRClient(resolvedConnectionString, connectionInfoAttribute.HubName), securityTokenValidator, signalRConnectionInfoConfigurer)); + var resolvedConnectionString = nameResolver.Resolve(connectionInfoAttribute.ConnectionStringSetting); + return Task.FromResult((IBinding)new SignalRConnectionInputBinding(connectionInfoAttribute, Utils.GetAzureSignalRClient(resolvedConnectionString, connectionInfoAttribute.HubName, options), securityTokenValidator, signalRConnectionInfoConfigurer)); case SecurityTokenValidationAttribute validationAttribute: return Task.FromResult((IBinding) new SecurityTokenValidationInputBinding(securityTokenValidator)); } diff --git a/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs b/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs index a63186a9..2e5834bb 100644 --- a/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs +++ b/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -22,16 +21,13 @@ namespace Microsoft.Azure.WebJobs.Extensions.SignalRService [Extension("SignalR", "signalr")] internal class SignalRConfigProvider : IExtensionConfigProvider, IAsyncConverter { - public IConfiguration Configuration { get; } - - internal readonly INameResolver nameResolver; - + private readonly IConfiguration configuration; + private readonly INameResolver nameResolver; private readonly ILogger logger; private readonly SignalROptions options; private readonly ILoggerFactory loggerFactory; - private readonly ISecurityTokenValidator securityTokenValidator; - private readonly ISignalRConnectionInfoConfigurer signalRConnectionInfoConfigurer; private readonly ISignalRTriggerDispatcher _dispatcher; + private readonly SignalRConnectionInputBindingProvider signalRConnectionInputBindingProvider; public SignalRConfigProvider( IOptions options, @@ -45,10 +41,9 @@ public SignalRConfigProvider( this.loggerFactory = loggerFactory; this.logger = loggerFactory.CreateLogger(LogCategories.CreateTriggerCategory("SignalR")); this.nameResolver = nameResolver; - Configuration = configuration; - this.securityTokenValidator = securityTokenValidator; - this.signalRConnectionInfoConfigurer = signalRConnectionInfoConfigurer; + this.configuration = configuration; this._dispatcher = new SignalRTriggerDispatcher(); + signalRConnectionInputBindingProvider = new SignalRConnectionInputBindingProvider(nameResolver, options.Value, securityTokenValidator, signalRConnectionInfoConfigurer); } // GetWebhookHandler() need the Obsolete @@ -75,7 +70,7 @@ public void Initialize(ExtensionConfigContext context) logger.LogWarning($"Unsupported service transport type: {serviceTransportTypeStr}. Use default {options.AzureSignalRServiceTransportType} instead."); } - StaticServiceHubContextStore.ServiceManagerStore = new ServiceManagerStore(options.AzureSignalRServiceTransportType, Configuration, loggerFactory); + StaticServiceHubContextStore.ServiceManagerStore = new ServiceManagerStore(options.AzureSignalRServiceTransportType, configuration, loggerFactory); var url = context.GetWebhookHandler(); logger.LogInformation($"Registered SignalR trigger Endpoint = {url?.GetLeftPart(UriPartial.Path)}"); @@ -90,8 +85,6 @@ public void Initialize(ExtensionConfigContext context) .BindToTrigger(new SignalRTriggerBindingProvider(_dispatcher)); // Non-trigger binding rule - var signalRConnectionInputBindingProvider = new SignalRConnectionInputBindingProvider(this, securityTokenValidator, signalRConnectionInfoConfigurer); - var signalRConnectionInfoAttributeRule = context.AddBindingRule(); signalRConnectionInfoAttributeRule.AddValidator(ValidateSignalRConnectionInfoAttributeBinding); signalRConnectionInfoAttributeRule.Bind(signalRConnectionInputBindingProvider); @@ -101,7 +94,7 @@ public void Initialize(ExtensionConfigContext context) var signalRAttributeRule = context.AddBindingRule(); signalRAttributeRule.AddValidator(ValidateSignalRAttributeBinding); - signalRAttributeRule.BindToCollector(typeof(SignalRCollectorBuilder<>), this); + signalRAttributeRule.BindToCollector(typeof(SignalRCollectorBuilder<>), options); logger.LogInformation("SignalRService binding initialized"); } @@ -111,14 +104,6 @@ public Task ConvertAsync(HttpRequestMessage input, Cancella return _dispatcher.ExecuteAsync(input, cancellationToken); } - public AzureSignalRClient GetAzureSignalRClient(string attributeConnectionString, string attributeHubName) - { - var connectionString = FirstOrDefault(attributeConnectionString, options.ConnectionString); - var hubName = FirstOrDefault(attributeHubName, options.HubName); - - return new AzureSignalRClient(StaticServiceHubContextStore.ServiceManagerStore, connectionString, hubName); - } - private void ValidateSignalRAttributeBinding(SignalRAttribute attribute, Type type) { ValidateConnectionString( @@ -135,7 +120,7 @@ private void ValidateSignalRConnectionInfoAttributeBinding(SignalRConnectionInfo private void ValidateConnectionString(string attributeConnectionString, string attributeConnectionStringName) { - var connectionString = FirstOrDefault(attributeConnectionString, options.ConnectionString); + var connectionString = Utils.FirstOrDefault(attributeConnectionString, options.ConnectionString); if (string.IsNullOrEmpty(connectionString)) { @@ -143,17 +128,6 @@ private void ValidateConnectionString(string attributeConnectionString, string a } } - private SignalRConnectionInfo GetClientConnectionInfo(SignalRConnectionInfoAttribute attribute) - { - var client = GetAzureSignalRClient(attribute.ConnectionStringSetting, attribute.HubName); - return client.GetClientConnectionInfo(attribute.UserId, attribute.IdToken, attribute.ClaimTypeList); - } - - private string FirstOrDefault(params string[] values) - { - return values.FirstOrDefault(v => !string.IsNullOrEmpty(v)); - } - private class SignalROpenType : OpenType.Poco { public override bool IsMatch(Type type, OpenTypeMatchContext context) diff --git a/src/SignalRServiceExtension/Utils.cs b/src/SignalRServiceExtension/Utils.cs new file mode 100644 index 00000000..e6c8d9c7 --- /dev/null +++ b/src/SignalRServiceExtension/Utils.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Linq; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class Utils + { + public static string FirstOrDefault(params string[] values) + { + return values.FirstOrDefault(v => !string.IsNullOrEmpty(v)); + } + + public static AzureSignalRClient GetAzureSignalRClient(string attributeConnectionString, string attributeHubName, SignalROptions options) + { + var connectionString = FirstOrDefault(attributeConnectionString, options.ConnectionString); + var hubName = FirstOrDefault(attributeHubName, options.HubName); + + return new AzureSignalRClient(StaticServiceHubContextStore.ServiceManagerStore, connectionString, hubName); + } + } +} From 0e78364374c615e1b86ab8db43b8ba46b40092c4 Mon Sep 17 00:00:00 2001 From: Chenyang Liu Date: Tue, 3 Mar 2020 13:21:58 +0800 Subject: [PATCH 07/21] Add binding expressions support (#95) * Add bindingdatacontract support * Add licence * Fix according to comments * Minor update --- .../Config/SignalRConfigProvider.cs | 26 ++++++- ...gnalRTriggerParametersNotMatchException.cs | 13 ++++ .../TriggerBindings/SignalRTriggerBinding.cs | 42 ++++++++++- .../GlobalSuppressions.cs | 6 -- .../Trigger/SignalRTriggerTests.cs | 73 +++++++++++++++++-- 5 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 src/SignalRServiceExtension/Exceptions/SignalRTriggerParametersNotMatchException.cs delete mode 100644 test/SignalRServiceExtension.Tests/GlobalSuppressions.cs diff --git a/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs b/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs index 2e5834bb..68f20ff7 100644 --- a/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs +++ b/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -81,8 +82,9 @@ public void Initialize(ExtensionConfigContext context) .AddConverter(input => input.ToObject()); // Trigger binding rule - context.AddBindingRule() - .BindToTrigger(new SignalRTriggerBindingProvider(_dispatcher)); + var triggerBindingRule = context.AddBindingRule(); + triggerBindingRule.AddValidator(ValidateSignalRTriggerAttributeBinding); + triggerBindingRule.BindToTrigger(new SignalRTriggerBindingProvider(_dispatcher)); // Non-trigger binding rule var signalRConnectionInfoAttributeRule = context.AddBindingRule(); @@ -128,6 +130,26 @@ private void ValidateConnectionString(string attributeConnectionString, string a } } + private void ValidateSignalRTriggerAttributeBinding(SignalRTriggerAttribute attribute, Type type) + { + ValidateConnectionString(attribute.ConnectionStringSetting, + $"{nameof(SignalRTriggerAttribute)}.{nameof(SignalRConnectionInfoAttribute.ConnectionStringSetting)}"); + ValidateParameterNames(attribute.ParameterNames); + } + + private void ValidateParameterNames(string[] parameterNames) + { + if (parameterNames == null || parameterNames.Length == 0) + { + return; + } + + if (parameterNames.Length != parameterNames.Distinct(StringComparer.OrdinalIgnoreCase).Count()) + { + throw new ArgumentException("Elements in ParameterNames should be ignore case unique."); + } + } + private class SignalROpenType : OpenType.Poco { public override bool IsMatch(Type type, OpenTypeMatchContext context) diff --git a/src/SignalRServiceExtension/Exceptions/SignalRTriggerParametersNotMatchException.cs b/src/SignalRServiceExtension/Exceptions/SignalRTriggerParametersNotMatchException.cs new file mode 100644 index 00000000..c8cee44f --- /dev/null +++ b/src/SignalRServiceExtension/Exceptions/SignalRTriggerParametersNotMatchException.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class SignalRTriggerParametersNotMatchException : SignalRTriggerException + { + public SignalRTriggerParametersNotMatchException(int excepted, int actual) : base( + $"The function expected {excepted} parameters but got {actual}") + { + } + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs index 44050e77..0cc33b01 100644 --- a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Text; using System.Threading; @@ -29,6 +30,7 @@ public SignalRTriggerBinding(ParameterInfo parameterInfo, SignalRTriggerAttribut _parameterInfo = parameterInfo ?? throw new ArgumentNullException(nameof(parameterInfo)); _attribute = attribute ?? throw new ArgumentNullException(nameof(attribute)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + BindingDataContract = CreateBindingContract(_attribute, _parameterInfo); } public Task BindAsync(object value, ValueBindingContext context) @@ -38,7 +40,23 @@ public Task BindAsync(object value, ValueBindingContext context) if (value is SignalRTriggerEvent triggerEvent) { var bindingContext = triggerEvent.Context; - // TODO: Add dynamic binding data in bindingData + + // If ParameterNames are set, bind them in order. + // To reduce undefined situation, number of arguments should keep consist with that of ParameterNames + if (_attribute.ParameterNames != null && _attribute.ParameterNames.Length != 0) + { + if (bindingContext.Arguments == null || + bindingContext.Arguments.Length != _attribute.ParameterNames.Length) + { + throw new SignalRTriggerParametersNotMatchException(_attribute.ParameterNames.Length, bindingContext.Arguments?.Length ?? 0); + } + + var length = _attribute.ParameterNames.Length; + for (var i = 0; i < length; i++) + { + bindingData.Add(_attribute.ParameterNames[i], bindingContext.Arguments[i]); + } + } return Task.FromResult(new TriggerData(new SignalRTriggerValueProvider(_parameterInfo, bindingContext), bindingData) { @@ -77,19 +95,35 @@ public ParameterDescriptor ToParameterDescriptor() public Type TriggerValueType => typeof(SignalRTriggerEvent); // TODO: Use dynamic contract to deal with parameterName - public IReadOnlyDictionary BindingDataContract => CreateBindingContract(); + public IReadOnlyDictionary BindingDataContract { get; } /// /// Defined what other bindings can use and return value. /// - private IReadOnlyDictionary CreateBindingContract() + private IReadOnlyDictionary CreateBindingContract(SignalRTriggerAttribute attribute, ParameterInfo parameter) { var contract = new Dictionary(StringComparer.OrdinalIgnoreCase) { - //TODO: Add names in parameterNames to contract for binding { ReturnParameterKey, typeof(object).MakeByRefType() }, }; + // Add names in ParameterNames to binding contract, that user can bind to Functions' parameter directly + if (attribute.ParameterNames != null) + { + var parameters = ((MethodInfo)parameter.Member).GetParameters().ToDictionary(p => p.Name, p => p.ParameterType, StringComparer.OrdinalIgnoreCase); + foreach (var parameterName in attribute.ParameterNames) + { + if (parameters.ContainsKey(parameterName)) + { + contract.Add(parameterName, parameters[parameterName]); + } + else + { + contract.Add(parameterName, typeof(object)); + } + } + } + return contract; } diff --git a/test/SignalRServiceExtension.Tests/GlobalSuppressions.cs b/test/SignalRServiceExtension.Tests/GlobalSuppressions.cs deleted file mode 100644 index ac6a176f..00000000 --- a/test/SignalRServiceExtension.Tests/GlobalSuppressions.cs +++ /dev/null @@ -1,6 +0,0 @@ -// This file is used by Code Analysis to maintain SuppressMessage -// attributes that are applied to this project. -// Project-level suppressions either have no target or are given -// a specific target and scoped to a namespace, type, member, etc. - -[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "xUnit1013:Public method should be marked as test", Justification = "", Scope = "member", Target = "~M:SignalRServiceExtension.Tests.SignalRTriggerTests.TestFunction(Microsoft.Azure.WebJobs.Extensions.SignalRService.InvocationContext)")] diff --git a/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerTests.cs b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerTests.cs index 3750f003..b589645d 100644 --- a/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerTests.cs +++ b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -23,9 +24,7 @@ public class SignalRTriggerTests [Fact] public async Task BindAsyncTest() { - var parameterInfo = this.GetType().GetMethod(nameof(TestFunction)).GetParameters()[0]; - var dispatcher = new TestTriggerDispatcher(); - var binding = new SignalRTriggerBinding(parameterInfo, new SignalRTriggerAttribute(), dispatcher); + var binding = CreateBinding(nameof(TestFunction), new string[0]); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var context = new InvocationContext(); var triggerContext = new SignalRTriggerEvent {Context = context, TaskCompletionSource = tcs}; @@ -40,7 +39,7 @@ public async Task CreateListenerTest() var executor = new Mock().Object; var listenerFactoryContext = new ListenerFactoryContext(new FunctionDescriptor(), executor, CancellationToken.None); - var parameterInfo = this.GetType().GetMethod(nameof(TestFunction)).GetParameters()[0]; + var parameterInfo = this.GetType().GetMethod(nameof(TestFunction), BindingFlags.Instance | BindingFlags.NonPublic).GetParameters()[0]; var dispatcher = new TestTriggerDispatcher(); var hub = Guid.NewGuid().ToString(); var method = Guid.NewGuid().ToString(); @@ -50,7 +49,71 @@ public async Task CreateListenerTest() Assert.Equal(executor, dispatcher.Executors[(hub, category, method)].Executor); } - public void TestFunction(InvocationContext context) + [Fact] + public async Task BindingDataTestWithLessParameterNames() + { + var binding = CreateBinding(nameof(TestFunctionWithTwoStringArgument), "arg0"); + var context = new InvocationContext{Arguments = new object[] {Guid.NewGuid().ToString()}}; + var triggerContext = new SignalRTriggerEvent { Context = context }; + var result = await binding.BindAsync(triggerContext, null); + var bindingData = result.BindingData; + Assert.Equal(context.Arguments[0], bindingData["arg0"]); + Assert.Equal(typeof(string), binding.BindingDataContract["arg0"]); + Assert.False(bindingData.ContainsKey("arg1")); + } + + [Fact] + public async Task BindingDataTestWithExactParameterNames() + { + var binding = CreateBinding(nameof(TestFunctionWithTwoStringArgument), "arg0", "arg1"); + var context = new InvocationContext { Arguments = new object[] { Guid.NewGuid().ToString(), Guid.NewGuid().ToString() } }; + var triggerContext = new SignalRTriggerEvent { Context = context }; + var result = await binding.BindAsync(triggerContext, null); + var bindingData = result.BindingData; + Assert.Equal(context.Arguments[0], bindingData["arg0"]); + Assert.Equal(typeof(string), binding.BindingDataContract["arg0"]); + Assert.Equal(context.Arguments[1], bindingData["arg1"]); + Assert.Equal(typeof(string), binding.BindingDataContract["arg1"]); + } + + [Fact] + public async Task BindingDataTestWithMoreParameterNames() + { + var binding = CreateBinding(nameof(TestFunctionWithTwoStringArgument), "arg0", "arg1", "arg2"); + var context = new InvocationContext { Arguments = new object[] { Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), Guid.NewGuid().ToString() } }; + var triggerContext = new SignalRTriggerEvent { Context = context }; + var result = await binding.BindAsync(triggerContext, null); + var bindingData = result.BindingData; + Assert.Equal(context.Arguments[0], bindingData["arg0"]); + Assert.Equal(typeof(string), binding.BindingDataContract["arg0"]); + Assert.Equal(context.Arguments[1], bindingData["arg1"]); + Assert.Equal(typeof(string), binding.BindingDataContract["arg1"]); + Assert.Equal(context.Arguments[2], bindingData["arg2"]); + Assert.Equal(typeof(object), binding.BindingDataContract["arg2"]); + } + + [Fact] + public async Task BindingDataTestWithUnmatchedParameterNamesAndInvocation() + { + var binding = CreateBinding(nameof(TestFunctionWithTwoStringArgument), "arg0", "arg1", "arg2"); + // Less invocation arguments than ParameterNames + var context = new InvocationContext { Arguments = new object[] { Guid.NewGuid().ToString(), Guid.NewGuid().ToString() } }; + var triggerContext = new SignalRTriggerEvent { Context = context }; + await Assert.ThrowsAsync(() => binding.BindAsync(triggerContext, null)); + } + + private SignalRTriggerBinding CreateBinding(string functionName, params string[] parameterNames) + { + var parameterInfo = this.GetType().GetMethod(functionName, BindingFlags.Instance | BindingFlags.NonPublic).GetParameters()[0]; + var dispatcher = new TestTriggerDispatcher(); + return new SignalRTriggerBinding(parameterInfo, new SignalRTriggerAttribute{ParameterNames = parameterNames}, dispatcher); + } + + internal void TestFunction(InvocationContext context) + { + } + + internal void TestFunctionWithTwoStringArgument(InvocationContext context, string arg0, string arg1) { } } From 0f7263380a917ecaf0b2f9d6dac4522397397243 Mon Sep 17 00:00:00 2001 From: Chenyang Liu Date: Thu, 5 Mar 2020 10:14:29 +0800 Subject: [PATCH 08/21] Add customer type support (#97) --- .../SignalRTriggerAttribute.cs | 7 +++ .../TriggerBindings/SignalRTriggerBinding.cs | 50 +++++++++++++++++-- .../Utils/SignalRTriggerUtils.cs | 2 +- .../Trigger/SignalRTriggerTests.cs | 4 +- 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs index 341b7bd0..03722a58 100644 --- a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs @@ -11,6 +11,13 @@ namespace Microsoft.Azure.WebJobs.Extensions.SignalRService [Binding] public class SignalRTriggerAttribute : Attribute { + public SignalRTriggerAttribute(string hubName, string category, string @event, params string[] parameterNames) + { + HubName = hubName; + Category = category; + Event = @event; + ParameterNames = parameterNames; + } /// /// Connection string that connect to Azure SignalR Service diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs index 0cc33b01..14509158 100644 --- a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs @@ -51,11 +51,7 @@ public Task BindAsync(object value, ValueBindingContext context) throw new SignalRTriggerParametersNotMatchException(_attribute.ParameterNames.Length, bindingContext.Arguments?.Length ?? 0); } - var length = _attribute.ParameterNames.Length; - for (var i = 0; i < length; i++) - { - bindingData.Add(_attribute.ParameterNames[i], bindingContext.Arguments[i]); - } + AddParameterNamesBindingData(bindingData, _attribute.ParameterNames, bindingContext.Arguments); } return Task.FromResult(new TriggerData(new SignalRTriggerValueProvider(_parameterInfo, bindingContext), bindingData) @@ -127,6 +123,50 @@ private IReadOnlyDictionary CreateBindingContract(SignalRTriggerAt return contract; } + private void AddParameterNamesBindingData(Dictionary bindingData, string[] parameterNames, object[] arguments) + { + var length = parameterNames.Length; + for (var i = 0; i < length; i++) + { + if (BindingDataContract.TryGetValue(parameterNames[i], out var type)) + { + bindingData.Add(parameterNames[i], ConvertValueIfNecessary(arguments[i], type)); + } + else + { + bindingData.Add(parameterNames[i], arguments[i]); + } + } + } + + private object ConvertValueIfNecessary(object value, Type targetType) + { + if (value != null && !targetType.IsAssignableFrom(value.GetType())) + { + var underlyingTargetType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + var jObject = value as JObject; + if (jObject != null) + { + value = jObject.ToObject(targetType); + } + else if (underlyingTargetType == typeof(Guid) && value.GetType() == typeof(string)) + { + // Guids need to be converted by their own logic + // Intentionally throw here on error to standardize behavior + value = Guid.Parse((string)value); + } + else + { + // if the type is nullable, we only need to convert to the + // correct underlying type + value = Convert.ChangeType(value, underlyingTargetType); + } + } + + return value; + } + // TODO: Add more supported type /// /// A provider that responsible for providing value in various type to be bond to function method parameter. diff --git a/src/SignalRServiceExtension/TriggerBindings/Utils/SignalRTriggerUtils.cs b/src/SignalRServiceExtension/TriggerBindings/Utils/SignalRTriggerUtils.cs index d939e268..63fd02ec 100644 --- a/src/SignalRServiceExtension/TriggerBindings/Utils/SignalRTriggerUtils.cs +++ b/src/SignalRServiceExtension/TriggerBindings/Utils/SignalRTriggerUtils.cs @@ -16,7 +16,7 @@ internal static class SignalRTriggerUtils private static readonly char[] KeyValueSeparator = { '=' }; private static readonly char[] QuerySeparator = { '&' }; private static readonly char[] HeaderSeparator = { ',' }; - private static readonly string[] ClaimsSeparator = { "= " }; + private static readonly string[] ClaimsSeparator = { ": " }; public static string GetAccessKey(string connectionString) { diff --git a/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerTests.cs b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerTests.cs index b589645d..906af17b 100644 --- a/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerTests.cs +++ b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerTests.cs @@ -44,7 +44,7 @@ public async Task CreateListenerTest() var hub = Guid.NewGuid().ToString(); var method = Guid.NewGuid().ToString(); var category = Guid.NewGuid().ToString(); - var binding = new SignalRTriggerBinding(parameterInfo, new SignalRTriggerAttribute{HubName = hub, Category = category, Event = method}, dispatcher); + var binding = new SignalRTriggerBinding(parameterInfo, new SignalRTriggerAttribute(hub, category, method), dispatcher); await binding.CreateListenerAsync(listenerFactoryContext); Assert.Equal(executor, dispatcher.Executors[(hub, category, method)].Executor); } @@ -106,7 +106,7 @@ private SignalRTriggerBinding CreateBinding(string functionName, params string[] { var parameterInfo = this.GetType().GetMethod(functionName, BindingFlags.Instance | BindingFlags.NonPublic).GetParameters()[0]; var dispatcher = new TestTriggerDispatcher(); - return new SignalRTriggerBinding(parameterInfo, new SignalRTriggerAttribute{ParameterNames = parameterNames}, dispatcher); + return new SignalRTriggerBinding(parameterInfo, new SignalRTriggerAttribute(string.Empty, string.Empty, string.Empty, parameterNames), dispatcher); } internal void TestFunction(InvocationContext context) From ad6d0f79ef2e095379ea4a9e1bf68169fde15142 Mon Sep 17 00:00:00 2001 From: Chenyang Liu Date: Fri, 6 Mar 2020 15:34:28 +0800 Subject: [PATCH 09/21] Add signature validation support (#98) * Add signature validation support * Fix tests * Minor updates --- .../Config/SignalRConfigProvider.cs | 23 +----- .../Resolver/SignalRRequestResolver.cs | 36 ++++++++- .../SignalRTriggerBindingProvider.cs | 73 +++++++++++++++++-- .../Utils/SignalRTriggerUtils.cs | 12 ++- .../Trigger/SignalRMethodExecutorTests.cs | 6 +- .../Trigger/SignalRTriggerResolverTests.cs | 51 +++++++++++++ .../Utils/TestHelpers.cs | 7 +- 7 files changed, 174 insertions(+), 34 deletions(-) create mode 100644 test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerResolverTests.cs diff --git a/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs b/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs index 68f20ff7..ed37b990 100644 --- a/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs +++ b/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs @@ -83,8 +83,7 @@ public void Initialize(ExtensionConfigContext context) // Trigger binding rule var triggerBindingRule = context.AddBindingRule(); - triggerBindingRule.AddValidator(ValidateSignalRTriggerAttributeBinding); - triggerBindingRule.BindToTrigger(new SignalRTriggerBindingProvider(_dispatcher)); + triggerBindingRule.BindToTrigger(new SignalRTriggerBindingProvider(_dispatcher, nameResolver, options)); // Non-trigger binding rule var signalRConnectionInfoAttributeRule = context.AddBindingRule(); @@ -130,26 +129,6 @@ private void ValidateConnectionString(string attributeConnectionString, string a } } - private void ValidateSignalRTriggerAttributeBinding(SignalRTriggerAttribute attribute, Type type) - { - ValidateConnectionString(attribute.ConnectionStringSetting, - $"{nameof(SignalRTriggerAttribute)}.{nameof(SignalRConnectionInfoAttribute.ConnectionStringSetting)}"); - ValidateParameterNames(attribute.ParameterNames); - } - - private void ValidateParameterNames(string[] parameterNames) - { - if (parameterNames == null || parameterNames.Length == 0) - { - return; - } - - if (parameterNames.Length != parameterNames.Distinct(StringComparer.OrdinalIgnoreCase).Count()) - { - throw new ArgumentException("Elements in ParameterNames should be ignore case unique."); - } - } - private class SignalROpenType : OpenType.Poco { public override bool IsMatch(Type type, OpenTypeMatchContext context) diff --git a/src/SignalRServiceExtension/TriggerBindings/Resolver/SignalRRequestResolver.cs b/src/SignalRServiceExtension/TriggerBindings/Resolver/SignalRRequestResolver.cs index e6820556..af7b8e70 100644 --- a/src/SignalRServiceExtension/TriggerBindings/Resolver/SignalRRequestResolver.cs +++ b/src/SignalRServiceExtension/TriggerBindings/Resolver/SignalRRequestResolver.cs @@ -5,6 +5,8 @@ using System.Buffers; using System.Linq; using System.Net.Http; +using System.Security.Cryptography; +using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Protocol; @@ -14,6 +16,15 @@ namespace Microsoft.Azure.WebJobs.Extensions.SignalRService { internal class SignalRRequestResolver : IRequestResolver { + private readonly bool _validateSignature; + + // Now it's only used in test, but when the trigger started to support AAD, + // It can be configurable in public. + internal SignalRRequestResolver(bool validateSignature = true) + { + _validateSignature = validateSignature; + } + public bool ValidateContentType(HttpRequestMessage request) { var contentType = request.Content.Headers.ContentType.MediaType; @@ -24,10 +35,31 @@ public bool ValidateContentType(HttpRequestMessage request) return contentType == Constants.JsonContentType || contentType == Constants.MessagePackContentType; } + // The algorithm is defined in spec: Hex_encoded(HMAC_SHA256(access-key, connection-id)) public bool ValidateSignature(HttpRequestMessage request, string accessToken) { - //TODO: Add real signature validation - return true; + if (!_validateSignature) + { + return true; + } + + if (!string.IsNullOrEmpty(accessToken) && + request.Headers.TryGetValues(Constants.AsrsSignature, out var values)) + { + var signatures = SignalRTriggerUtils.GetSignatureList(values.FirstOrDefault()); + if (signatures == null) + { + return false; + } + using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(accessToken))) + { + var hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(request.Headers.GetValues(Constants.AsrsConnectionIdHeader).First())); + var hash = "sha256=" + BitConverter.ToString(hashBytes).Replace("-", ""); + return signatures.Contains(hash, StringComparer.OrdinalIgnoreCase); + } + } + + return false; } public bool TryGetInvocationContext(HttpRequestMessage request, out InvocationContext context) diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBindingProvider.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBindingProvider.cs index d3ee4f84..a5195622 100644 --- a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBindingProvider.cs +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBindingProvider.cs @@ -3,21 +3,27 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Threading; using System.Threading.Tasks; - +using Microsoft.Azure.WebJobs.Description; using Microsoft.Azure.WebJobs.Host.Triggers; +using Microsoft.Extensions.Configuration; namespace Microsoft.Azure.WebJobs.Extensions.SignalRService { internal class SignalRTriggerBindingProvider : ITriggerBindingProvider { - private readonly ISignalRTriggerDispatcher dispatcher; + private readonly ISignalRTriggerDispatcher _dispatcher; + private readonly INameResolver _nameResolver; + private readonly SignalROptions _options; - public SignalRTriggerBindingProvider(ISignalRTriggerDispatcher dispatcher) + public SignalRTriggerBindingProvider(ISignalRTriggerDispatcher dispatcher, INameResolver nameResolver, SignalROptions options) { - this.dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _nameResolver = nameResolver ?? throw new ArgumentNullException(nameof(nameResolver)); + _options = options ?? throw new ArgumentNullException(nameof(options)); } public Task TryCreateAsync(TriggerBindingProviderContext context) @@ -33,8 +39,65 @@ public Task TryCreateAsync(TriggerBindingProviderContext contex { return Task.FromResult(null); } + var resolvedAttribute = GetParameterResolvedAttribute(attribute); + ValidateSignalRTriggerAttributeBinding(resolvedAttribute); + + return Task.FromResult(new SignalRTriggerBinding(parameterInfo, resolvedAttribute, _dispatcher)); + } + + private SignalRTriggerAttribute GetParameterResolvedAttribute(SignalRTriggerAttribute attribute) + { + //TODO: AutoResolve more properties in attribute + var resolvedConnectionString = GetResolvedConnectionString( + typeof(SignalRTriggerAttribute).GetProperty(nameof(attribute.ConnectionStringSetting)), + attribute.ConnectionStringSetting); + + return new SignalRTriggerAttribute(attribute.HubName, attribute.Category, attribute.Event, attribute.ParameterNames){ConnectionStringSetting = resolvedConnectionString}; + } + + private string GetResolvedConnectionString(PropertyInfo property, string configurationName) + { + string resolvedConnectionString; + if (!string.IsNullOrWhiteSpace(configurationName)) + { + resolvedConnectionString = _nameResolver.Resolve(configurationName); + } + else + { + var attribute = property.GetCustomAttribute(); + if (attribute == null) + { + throw new InvalidOperationException($"Unable to get AppSettingAttribute on property {property.Name}"); + } + resolvedConnectionString = _nameResolver.Resolve(attribute.Default); + } - return Task.FromResult(new SignalRTriggerBinding(parameterInfo, attribute, dispatcher)); + return string.IsNullOrEmpty(resolvedConnectionString) + ? _options.ConnectionString + : resolvedConnectionString; + } + + private void ValidateSignalRTriggerAttributeBinding(SignalRTriggerAttribute attribute) + { + if (string.IsNullOrEmpty(attribute.ConnectionStringSetting)) + { + throw new InvalidOperationException(string.Format(ErrorMessages.EmptyConnectionStringErrorMessageFormat, + $"{nameof(SignalRTriggerAttribute)}.{nameof(SignalRConnectionInfoAttribute.ConnectionStringSetting)}")); + } + ValidateParameterNames(attribute.ParameterNames); + } + + private void ValidateParameterNames(string[] parameterNames) + { + if (parameterNames == null || parameterNames.Length == 0) + { + return; + } + + if (parameterNames.Length != parameterNames.Distinct(StringComparer.OrdinalIgnoreCase).Count()) + { + throw new ArgumentException("Elements in ParameterNames should be ignore case unique."); + } } } } diff --git a/src/SignalRServiceExtension/TriggerBindings/Utils/SignalRTriggerUtils.cs b/src/SignalRServiceExtension/TriggerBindings/Utils/SignalRTriggerUtils.cs index 63fd02ec..b6e8d96b 100644 --- a/src/SignalRServiceExtension/TriggerBindings/Utils/SignalRTriggerUtils.cs +++ b/src/SignalRServiceExtension/TriggerBindings/Utils/SignalRTriggerUtils.cs @@ -37,7 +37,7 @@ public static string GetAccessKey(string connectionString) if (kvp.Length != 2) continue; var key = kvp[0].Trim(); - if (key == AccessKeyProperty) + if (string.Equals(key, AccessKeyProperty, StringComparison.OrdinalIgnoreCase)) { return kvp[1].Trim(); } @@ -71,6 +71,16 @@ public static IDictionary GetClaimDictionary(string claims) .ToDictionary(p => p[0].Trim(), p => p[1].Trim()); } + public static IReadOnlyList GetSignatureList(string signatures) + { + if (string.IsNullOrEmpty(signatures)) + { + return default; + } + + return signatures.Split(HeaderSeparator, StringSplitOptions.RemoveEmptyEntries); + } + public static IDictionary GetHeaderDictionary(HttpRequestMessage request) { return request.Headers.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.FirstOrDefault(), StringComparer.OrdinalIgnoreCase); diff --git a/test/SignalRServiceExtension.Tests/Trigger/SignalRMethodExecutorTests.cs b/test/SignalRServiceExtension.Tests/Trigger/SignalRMethodExecutorTests.cs index ec2f4e10..e0dafeaa 100644 --- a/test/SignalRServiceExtension.Tests/Trigger/SignalRMethodExecutorTests.cs +++ b/test/SignalRServiceExtension.Tests/Trigger/SignalRMethodExecutorTests.cs @@ -40,7 +40,7 @@ public SignalRMethodExecutorTests() [Fact] public async Task SignalRConnectMethodExecutorTest() { - var resolver = new SignalRRequestResolver(); + var resolver = new SignalRRequestResolver(false); var methodExecutor = new SignalRConnectMethodExecutor(resolver, new ExecutionContext {Executor = _triggeredFunctionExecutor }); var hub = Guid.NewGuid().ToString(); var category = Guid.NewGuid().ToString(); @@ -63,7 +63,7 @@ public async Task SignalRConnectMethodExecutorTest() [Fact] public async Task SignalRDisconnectMethodExecutorTest() { - var resolver = new SignalRRequestResolver(); + var resolver = new SignalRRequestResolver(false); var methodExecutor = new SignalRDisconnectMethodExecutor(resolver, new ExecutionContext { Executor = _triggeredFunctionExecutor }); var hub = Guid.NewGuid().ToString(); var category = Guid.NewGuid().ToString(); @@ -90,7 +90,7 @@ public async Task SignalRDisconnectMethodExecutorTest() [InlineData("messagepack")] public async Task SignalRInvocationMethodExecutorTest(string protocolName) { - var resolver = new SignalRRequestResolver(); + var resolver = new SignalRRequestResolver(false); var methodExecutor = new SignalRInvocationMethodExecutor(resolver, new ExecutionContext { Executor = _triggeredFunctionExecutor }); var hub = Guid.NewGuid().ToString(); var category = Guid.NewGuid().ToString(); diff --git a/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerResolverTests.cs b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerResolverTests.cs new file mode 100644 index 00000000..ecbe3437 --- /dev/null +++ b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerResolverTests.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using Microsoft.Azure.WebJobs.Extensions.SignalRService; +using SignalRServiceExtension.Tests.Utils; +using Xunit; + +namespace SignalRServiceExtension.Tests.Trigger +{ + public class SignalRTriggerResolverTests + { + public static IEnumerable SignatureTestData() + { + var connectionId = "0f9c97a2f0bf4706afe87a14e0797b11"; + var accessKeys = new string[] + { + "7aab239577fd4f24bc919802fb629f5f", + "a5f2815d0d0c4b00bd27e832432f91ab" + }; + var signatures = new string[] + { + "sha256=7767effcb3946f3e1de039df4b986ef02c110b1469d02c0a06f41b3b727ab561", + "sha256=d4aefb65547a00a9881fa8ac8bd03d0faf77af9da5205d45c6e57cbda4377760" + }; + + var req = TestHelpers.CreateHttpRequestMessage(String.Empty, String.Empty, String.Empty, connectionId, + signatures: signatures); + yield return new object[] { req, accessKeys[0], true }; + yield return new object[] { req, accessKeys[1], true }; + yield return new object[] { req, Guid.NewGuid().ToString(), false }; + yield return new object[] { req, null, false }; + yield return new object[] { req, string.Empty, false }; + + req = TestHelpers.CreateHttpRequestMessage(String.Empty, String.Empty, String.Empty, connectionId); + yield return new object[] { req, accessKeys[0], false }; + + req = TestHelpers.CreateHttpRequestMessage(String.Empty, String.Empty, String.Empty, connectionId, signatures: new string[0]); + yield return new object[] { req, accessKeys[0], false }; + } + + + [Theory] + [MemberData(nameof(SignatureTestData))] + public void SignatureTest(HttpRequestMessage request, string accessKey, bool validate) + { + var resolver = new SignalRRequestResolver(); + Assert.Equal(validate, resolver.ValidateSignature(request, accessKey)); + } + } +} diff --git a/test/SignalRServiceExtension.Tests/Utils/TestHelpers.cs b/test/SignalRServiceExtension.Tests/Utils/TestHelpers.cs index bb73be7d..33b94c88 100644 --- a/test/SignalRServiceExtension.Tests/Utils/TestHelpers.cs +++ b/test/SignalRServiceExtension.Tests/Utils/TestHelpers.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Net.Http; +using System.Runtime.InteropServices.ComTypes; using Microsoft.AspNetCore.Http; using Microsoft.Azure.WebJobs; using Microsoft.Azure.WebJobs.Extensions.SignalRService; @@ -58,7 +59,7 @@ public static JobHost GetJobHost(this IHost host) } public static HttpRequestMessage CreateHttpRequestMessage(string hub, string category, string @event, string connectionId, - string contentType = Constants.JsonContentType, byte[] content = null) + string contentType = Constants.JsonContentType, byte[] content = null, string[] signatures = null) { var context = new DefaultHttpContext(); context.Request.ContentType = contentType; @@ -67,6 +68,10 @@ public static HttpRequestMessage CreateHttpRequestMessage(string hub, string cat context.Request.Headers.Add(Constants.AsrsCategory, category); context.Request.Headers.Add(Constants.AsrsEvent, @event); context.Request.Headers.Add(Constants.AsrsConnectionIdHeader, connectionId); + if (signatures != null) + { + context.Request.Headers.Add(Constants.AsrsSignature, string.Join(',', signatures)); + } context.Request.Body = content == null ? Stream.Null : new MemoryStream(content); return CreateHttpRequestMessageFromContext(context); From 53c7838ff26ceadf1f999d6ad3a82b2b9338f289 Mon Sep 17 00:00:00 2001 From: Chenyang Liu Date: Tue, 10 Mar 2020 10:52:19 +0800 Subject: [PATCH 10/21] Fix some issues (#100) --- .../JsonServerlessProtocol.cs | 2 +- src/SignalRServiceExtension/Constants.cs | 20 +++++++++---------- .../SignalRTriggerDispatcher.cs | 8 ++++---- .../Utils/SignalRTriggerUtils.cs | 9 +++++---- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/Microsoft.Azure.SignalR.Serverless.Protocols/JsonServerlessProtocol.cs b/src/Microsoft.Azure.SignalR.Serverless.Protocols/JsonServerlessProtocol.cs index be8bc059..41b20bcb 100644 --- a/src/Microsoft.Azure.SignalR.Serverless.Protocols/JsonServerlessProtocol.cs +++ b/src/Microsoft.Azure.SignalR.Serverless.Protocols/JsonServerlessProtocol.cs @@ -21,7 +21,7 @@ public bool TryParseMessage(ref ReadOnlySequence input, out ServerlessMess { var textReader = new JsonTextReader(new StreamReader(new ReadOnlySequenceStream(input))); var jObject = JObject.Load(textReader); - if (jObject.TryGetValue(TypePropertyName, out var token)) + if (jObject.TryGetValue(TypePropertyName, StringComparison.OrdinalIgnoreCase, out var token)) { var type = token.Value(); switch (type) diff --git a/src/SignalRServiceExtension/Constants.cs b/src/SignalRServiceExtension/Constants.cs index aeefd637..09a9addb 100644 --- a/src/SignalRServiceExtension/Constants.cs +++ b/src/SignalRServiceExtension/Constants.cs @@ -18,17 +18,17 @@ internal static class Constants public const string AsrsSignature = AsrsHeaderPrefix + "Signature"; public const string JsonContentType = "application/json"; public const string MessagePackContentType = "application/x-msgpack"; + } - public static class Category - { - public const string Connections = "connections"; - public const string Messages = "messages"; - } + public static class Category + { + public const string Connections = "connections"; + public const string Messages = "messages"; + } - public static class Events - { - public const string Connect = "connect"; - public const string Disconnect = "disconnect"; - } + public static class Event + { + public const string Connect = "connect"; + public const string Disconnect = "disconnect"; } } diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerDispatcher.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerDispatcher.cs index eedea8ad..d3b66043 100644 --- a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerDispatcher.cs +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerDispatcher.cs @@ -27,21 +27,21 @@ public void Map((string hubName, string category, string @event) key, ExecutionC { if (!_executors.ContainsKey(key)) { - if (key.category == Constants.Category.Connections) + if (key.category == Category.Connections) { - if (key.@event == Constants.Events.Connect) + if (key.@event == Event.Connect) { _executors.Add(key, new SignalRConnectMethodExecutor(_resolver, executor)); return; } - if (key.@event == Constants.Events.Disconnect) + if (key.@event == Event.Disconnect) { _executors.Add(key, new SignalRDisconnectMethodExecutor(_resolver, executor)); return; } throw new SignalRTriggerException($"Event {key.@event} is not supported in connections"); } - if (key.category == Constants.Category.Messages) + if (key.category == Category.Messages) { _executors.Add(key, new SignalRInvocationMethodExecutor(_resolver, executor)); return; diff --git a/src/SignalRServiceExtension/TriggerBindings/Utils/SignalRTriggerUtils.cs b/src/SignalRServiceExtension/TriggerBindings/Utils/SignalRTriggerUtils.cs index b6e8d96b..378a19bb 100644 --- a/src/SignalRServiceExtension/TriggerBindings/Utils/SignalRTriggerUtils.cs +++ b/src/SignalRServiceExtension/TriggerBindings/Utils/SignalRTriggerUtils.cs @@ -55,7 +55,8 @@ public static IDictionary GetQueryDictionary(string queryString) // The query string looks like "?key1=value1&key2=value2" var queryArray = queryString.TrimStart('?').Split(QuerySeparator, StringSplitOptions.RemoveEmptyEntries); - return queryArray.Select(p => p.Split(KeyValueSeparator)).ToDictionary(p => p[0].Trim(), p => p[1].Trim()); + return queryArray.Select(p => p.Split(KeyValueSeparator, StringSplitOptions.RemoveEmptyEntries)) + .Where(l => l.Length == 2).ToDictionary(p => p[0].Trim(), p => p[1].Trim()); } public static IDictionary GetClaimDictionary(string claims) @@ -65,9 +66,9 @@ public static IDictionary GetClaimDictionary(string claims) return default; } - // The claim string looks like "a= v, b= v" - return claims.Split(HeaderSeparator) - .Select(p => p.Split(ClaimsSeparator, StringSplitOptions.RemoveEmptyEntries)) + // The claim string looks like "a: v, b: v" + return claims.Split(HeaderSeparator, StringSplitOptions.RemoveEmptyEntries) + .Select(p => p.Split(ClaimsSeparator, StringSplitOptions.RemoveEmptyEntries)).Where(l => l.Length == 2) .ToDictionary(p => p[0].Trim(), p => p[1].Trim()); } From 09ddf6070b12c17c91a5211cf75f82ff514eb0d9 Mon Sep 17 00:00:00 2001 From: Mattias Karlsson Date: Tue, 10 Mar 2020 04:46:48 +0100 Subject: [PATCH 11/21] ms-vscode.csharp to ms-dotnettools.csharp (#99) --- .../chat-with-auth/csharp/FunctionApp/.vscode/extensions.json | 2 +- samples/simple-chat/csharp/FunctionApp/.vscode/extensions.json | 2 +- samples/simple-chat/js/functionapp/.vscode/extensions.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/samples/chat-with-auth/csharp/FunctionApp/.vscode/extensions.json b/samples/chat-with-auth/csharp/FunctionApp/.vscode/extensions.json index 3a030ccc..de991f40 100644 --- a/samples/chat-with-auth/csharp/FunctionApp/.vscode/extensions.json +++ b/samples/chat-with-auth/csharp/FunctionApp/.vscode/extensions.json @@ -1,6 +1,6 @@ { "recommendations": [ "ms-azuretools.vscode-azurefunctions", - "ms-vscode.csharp" + "ms-dotnettools.csharp" ] } diff --git a/samples/simple-chat/csharp/FunctionApp/.vscode/extensions.json b/samples/simple-chat/csharp/FunctionApp/.vscode/extensions.json index 3a030ccc..de991f40 100644 --- a/samples/simple-chat/csharp/FunctionApp/.vscode/extensions.json +++ b/samples/simple-chat/csharp/FunctionApp/.vscode/extensions.json @@ -1,6 +1,6 @@ { "recommendations": [ "ms-azuretools.vscode-azurefunctions", - "ms-vscode.csharp" + "ms-dotnettools.csharp" ] } diff --git a/samples/simple-chat/js/functionapp/.vscode/extensions.json b/samples/simple-chat/js/functionapp/.vscode/extensions.json index 3a030ccc..de991f40 100644 --- a/samples/simple-chat/js/functionapp/.vscode/extensions.json +++ b/samples/simple-chat/js/functionapp/.vscode/extensions.json @@ -1,6 +1,6 @@ { "recommendations": [ "ms-azuretools.vscode-azurefunctions", - "ms-vscode.csharp" + "ms-dotnettools.csharp" ] } From d8d849469c03fcb1603d5dc3278b69ec80ea05c8 Mon Sep 17 00:00:00 2001 From: Chenyang Liu Date: Wed, 11 Mar 2020 12:22:15 +0800 Subject: [PATCH 12/21] Bug fix in non-csharp language (#102) * Bug fix in non-csharp language * Minor improvement --- src/SignalRServiceExtension/Config/SignalRConfigProvider.cs | 3 ++- .../TriggerBindings/SignalRTriggerAttribute.cs | 4 ++++ .../TriggerBindings/SignalRTriggerBinding.cs | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs b/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs index ed37b990..6e4c13a8 100644 --- a/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs +++ b/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs @@ -83,7 +83,8 @@ public void Initialize(ExtensionConfigContext context) // Trigger binding rule var triggerBindingRule = context.AddBindingRule(); - triggerBindingRule.BindToTrigger(new SignalRTriggerBindingProvider(_dispatcher, nameResolver, options)); + triggerBindingRule.AddConverter(JObject.FromObject); + triggerBindingRule.BindToTrigger(new SignalRTriggerBindingProvider(_dispatcher, nameResolver, options)); // Non-trigger binding rule var signalRConnectionInfoAttributeRule = context.AddBindingRule(); diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs index 03722a58..8aa13902 100644 --- a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs @@ -11,6 +11,10 @@ namespace Microsoft.Azure.WebJobs.Extensions.SignalRService [Binding] public class SignalRTriggerAttribute : Attribute { + public SignalRTriggerAttribute(string hubName, string category, string @event): this(hubName, category, @event, Array.Empty()) + { + } + public SignalRTriggerAttribute(string hubName, string category, string @event, params string[] parameterNames) { HubName = hubName; diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs index 14509158..250b9684 100644 --- a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBinding.cs @@ -188,7 +188,8 @@ public Task GetValueAsync() { return Task.FromResult(_value); } - else if (_parameter.ParameterType == typeof(object)) + else if (_parameter.ParameterType == typeof(object) || + _parameter.ParameterType == typeof(JObject)) { return Task.FromResult(JObject.FromObject(_value)); } From c1f7c46ff74eeb535a3850265bca9f7f24eaf973 Mon Sep 17 00:00:00 2001 From: Chenyang Liu Date: Mon, 16 Mar 2020 11:02:47 +0800 Subject: [PATCH 13/21] Add a bidirectional chat sample to demonstrate SignalR Trigger (#101) * Add a bi-directional chat sample * stash * Simplify trigger usage in C# * Hide connectionstring * Add tests and update according to comments * Improve class based model * Fix typo --- samples/bidirectional-chat/content/index.html | 239 ++++++++++++++++ samples/bidirectional-chat/csharp/.gitignore | 264 ++++++++++++++++++ .../Authorize/FunctionAuthorizeAttribute.cs | 34 +++ samples/bidirectional-chat/csharp/Function.cs | 112 ++++++++ .../csharp/FunctionApp.csproj | 26 ++ samples/bidirectional-chat/csharp/host.json | 11 + .../csharp/local.settings.sample.json | 15 + .../Config/ServiceHubContextStore.cs | 2 +- src/SignalRServiceExtension/Constants.cs | 4 +- ...gnalRTriggerParametersNotMatchException.cs | 2 +- .../TriggerBindings/ServerlessHub.cs | 54 ++++ .../TriggerBindings/SignalRIgnoreAttribute.cs | 16 ++ .../SignalRParameterAttribute.cs | 18 ++ .../SignalRTriggerAttribute.cs | 4 + .../SignalRTriggerBindingProvider.cs | 84 +++++- .../SignalRTriggerDispatcher.cs | 8 +- .../SignalRTriggerBindingProviderTests.cs | 123 ++++++++ .../Trigger/SignalRTriggerDispatcherTests.cs | 4 +- 18 files changed, 1006 insertions(+), 14 deletions(-) create mode 100644 samples/bidirectional-chat/content/index.html create mode 100644 samples/bidirectional-chat/csharp/.gitignore create mode 100644 samples/bidirectional-chat/csharp/Authorize/FunctionAuthorizeAttribute.cs create mode 100644 samples/bidirectional-chat/csharp/Function.cs create mode 100644 samples/bidirectional-chat/csharp/FunctionApp.csproj create mode 100644 samples/bidirectional-chat/csharp/host.json create mode 100644 samples/bidirectional-chat/csharp/local.settings.sample.json create mode 100644 src/SignalRServiceExtension/TriggerBindings/ServerlessHub.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/SignalRIgnoreAttribute.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/SignalRParameterAttribute.cs create mode 100644 test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerBindingProviderTests.cs diff --git a/samples/bidirectional-chat/content/index.html b/samples/bidirectional-chat/content/index.html new file mode 100644 index 00000000..6f23c919 --- /dev/null +++ b/samples/bidirectional-chat/content/index.html @@ -0,0 +1,239 @@ + + + + Serverless Chat + + + + + + +

 

+
+

Serverless chat

+
+
+
+
+ + +
+
+ +
+
+
+
+
+
Loading...
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/samples/bidirectional-chat/csharp/.gitignore b/samples/bidirectional-chat/csharp/.gitignore new file mode 100644 index 00000000..ff5b00c5 --- /dev/null +++ b/samples/bidirectional-chat/csharp/.gitignore @@ -0,0 +1,264 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# Azure Functions localsettings file +local.settings.json + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# DNX +project.lock.json +project.fragment.lock.json +artifacts/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +#*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignoreable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +node_modules/ +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc \ No newline at end of file diff --git a/samples/bidirectional-chat/csharp/Authorize/FunctionAuthorizeAttribute.cs b/samples/bidirectional-chat/csharp/Authorize/FunctionAuthorizeAttribute.cs new file mode 100644 index 00000000..204265fa --- /dev/null +++ b/samples/bidirectional-chat/csharp/Authorize/FunctionAuthorizeAttribute.cs @@ -0,0 +1,34 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Azure.WebJobs.Extensions.SignalRService; +using Microsoft.Azure.WebJobs.Host; + +namespace FunctionApp +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] +#pragma warning disable CS0618 // Type or member is obsolete + internal class FunctionAuthorizeAttribute: FunctionInvocationFilterAttribute + { + private const string AdminKey = "admin"; + + public override Task OnExecutingAsync(FunctionExecutingContext executingContext, CancellationToken cancellationToken) + { + var invocationContext = executingContext.Arguments.FirstOrDefault().Value as InvocationContext; + if (invocationContext != null) + { + if (invocationContext.Claims.TryGetValue(AdminKey, out var value) && + bool.TryParse(value, out var isAdmin) && + isAdmin) + { + return Task.CompletedTask; + } + } + throw new InvalidOperationException(); + } + } +#pragma warning restore CS0618 // Type or member is obsolete +} diff --git a/samples/bidirectional-chat/csharp/Function.cs b/samples/bidirectional-chat/csharp/Function.cs new file mode 100644 index 00000000..9fc67a89 --- /dev/null +++ b/samples/bidirectional-chat/csharp/Function.cs @@ -0,0 +1,112 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.Extensions.Http; +using Microsoft.AspNetCore.Http; +using Microsoft.Azure.WebJobs.Extensions.SignalRService; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; + +namespace FunctionApp +{ + public class SimpleChat : ServerlessHub + { + private const string Hub = nameof(SimpleChat); + private const string NewMessageTarget = "newMessage"; + private const string NewConnectionTarget = "newConnection"; + + [FunctionName("negotiate")] + public static SignalRConnectionInfo GetSignalRInfo( + [HttpTrigger(AuthorizationLevel.Anonymous)] HttpRequest req, + [SignalRConnectionInfo(HubName = Hub, UserId = "{headers.x-ms-signalr-user-id}")] SignalRConnectionInfo connectionInfo) + { + return connectionInfo; + } + + [FunctionName(nameof(Connected))] + public async Task Connected([SignalRTrigger]InvocationContext invocationContext, ILogger logger) + { + await Clients.All.SendAsync(NewConnectionTarget, new NewConnection(invocationContext.ConnectionId)); + logger.LogInformation($"{invocationContext.ConnectionId} has connected"); + } + + [FunctionName(nameof(Broadcast))] + public async Task Broadcast([SignalRTrigger]InvocationContext invocationContext, string message, ILogger logger) + { + await Clients.All.SendAsync(NewMessageTarget, new NewMessage(invocationContext, message)); + logger.LogInformation($"{invocationContext.ConnectionId} broadcast {message}"); + } + + [FunctionName(nameof(SendToGroup))] + public async Task SendToGroup([SignalRTrigger]InvocationContext invocationContext, string groupName, string message) + { + await Clients.Group(groupName).SendAsync(NewMessageTarget, new NewMessage(invocationContext, message)); + } + + [FunctionName(nameof(SendToUser))] + public async Task SendToUser([SignalRTrigger]InvocationContext invocationContext, string userName, string message) + { + await Clients.User(userName).SendAsync(NewMessageTarget, new NewMessage(invocationContext, message)); + } + + [FunctionName(nameof(SendToConnection))] + public async Task SendToConnection([SignalRTrigger]InvocationContext invocationContext, string connectionId, string message) + { + await Clients.Client(connectionId).SendAsync(NewMessageTarget, new NewMessage(invocationContext, message)); + } + + [FunctionName(nameof(JoinGroup))] + public async Task JoinGroup([SignalRTrigger]InvocationContext invocationContext, string connectionId, string groupName) + { + await Groups.AddToGroupAsync(connectionId, groupName); + } + + [FunctionName(nameof(LeaveGroup))] + public async Task LeaveGroup([SignalRTrigger]InvocationContext invocationContext, string connectionId, string groupName) + { + await Groups.RemoveFromGroupAsync(connectionId, groupName); + } + + [FunctionName(nameof(JoinUserToGroup))] + public async Task JoinUserToGroup([SignalRTrigger]InvocationContext invocationContext, string userName, string groupName) + { + await UserGroups.AddToGroupAsync(userName, groupName); + } + + [FunctionName(nameof(LeaveUserFromGroup))] + public async Task LeaveUserFromGroup([SignalRTrigger]InvocationContext invocationContext, string userName, string groupName) + { + await UserGroups.RemoveFromGroupAsync(userName, groupName); + } + + [FunctionName(nameof(Disconnect))] + public void Disconnect([SignalRTrigger]InvocationContext invocationContext) + { + } + + private class NewConnection + { + public string ConnectionId { get; } + + public NewConnection(string connectionId) + { + ConnectionId = connectionId; + } + } + + private class NewMessage + { + public string ConnectionId { get; } + public string Sender { get; } + public string Text { get; } + + public NewMessage(InvocationContext invocationContext, string message) + { + Sender = string.IsNullOrEmpty(invocationContext.UserId) ? string.Empty : invocationContext.UserId; + ConnectionId = invocationContext.ConnectionId; + Text = message; + } + } + } +} diff --git a/samples/bidirectional-chat/csharp/FunctionApp.csproj b/samples/bidirectional-chat/csharp/FunctionApp.csproj new file mode 100644 index 00000000..215102e6 --- /dev/null +++ b/samples/bidirectional-chat/csharp/FunctionApp.csproj @@ -0,0 +1,26 @@ + + + netcoreapp3.1 + v3 + bidirectional_chat + + + + + + + + + + PreserveNewest + + + Always + Never + + + Always + Never + + + \ No newline at end of file diff --git a/samples/bidirectional-chat/csharp/host.json b/samples/bidirectional-chat/csharp/host.json new file mode 100644 index 00000000..bb3b8dad --- /dev/null +++ b/samples/bidirectional-chat/csharp/host.json @@ -0,0 +1,11 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingExcludedTypes": "Request", + "samplingSettings": { + "isEnabled": true + } + } + } +} \ No newline at end of file diff --git a/samples/bidirectional-chat/csharp/local.settings.sample.json b/samples/bidirectional-chat/csharp/local.settings.sample.json new file mode 100644 index 00000000..0058e836 --- /dev/null +++ b/samples/bidirectional-chat/csharp/local.settings.sample.json @@ -0,0 +1,15 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "", + "AzureWebJobsDashboard": "", + "FUNCTIONS_WORKER_RUNTIME": "dotnet", + "AzureSignalRConnectionString": "", + "AzureSignalRServiceTransportType": "Transient" + }, + "Host": { + "LocalHttpPort": 7071, + "CORS": "http://localhost:5500", + "CORSCredentials": true + } +} \ No newline at end of file diff --git a/src/SignalRServiceExtension/Config/ServiceHubContextStore.cs b/src/SignalRServiceExtension/Config/ServiceHubContextStore.cs index b737c875..3e4e79f5 100644 --- a/src/SignalRServiceExtension/Config/ServiceHubContextStore.cs +++ b/src/SignalRServiceExtension/Config/ServiceHubContextStore.cs @@ -11,7 +11,7 @@ namespace Microsoft.Azure.WebJobs.Extensions.SignalRService { internal class ServiceHubContextStore : IServiceHubContextStore { - private readonly ConcurrentDictionary> lazy, IServiceHubContext value)> store = new ConcurrentDictionary>, IServiceHubContext value)>(); + private readonly ConcurrentDictionary> lazy, IServiceHubContext value)> store = new ConcurrentDictionary>, IServiceHubContext value)>(StringComparer.OrdinalIgnoreCase); private readonly ILoggerFactory loggerFactory; public IServiceManager ServiceManager { get; set; } diff --git a/src/SignalRServiceExtension/Constants.cs b/src/SignalRServiceExtension/Constants.cs index 09a9addb..89d41ae9 100644 --- a/src/SignalRServiceExtension/Constants.cs +++ b/src/SignalRServiceExtension/Constants.cs @@ -28,7 +28,7 @@ public static class Category public static class Event { - public const string Connect = "connect"; - public const string Disconnect = "disconnect"; + public const string Connected = "connected"; + public const string Disconnected = "disconnected"; } } diff --git a/src/SignalRServiceExtension/Exceptions/SignalRTriggerParametersNotMatchException.cs b/src/SignalRServiceExtension/Exceptions/SignalRTriggerParametersNotMatchException.cs index c8cee44f..3df51b41 100644 --- a/src/SignalRServiceExtension/Exceptions/SignalRTriggerParametersNotMatchException.cs +++ b/src/SignalRServiceExtension/Exceptions/SignalRTriggerParametersNotMatchException.cs @@ -6,7 +6,7 @@ namespace Microsoft.Azure.WebJobs.Extensions.SignalRService internal class SignalRTriggerParametersNotMatchException : SignalRTriggerException { public SignalRTriggerParametersNotMatchException(int excepted, int actual) : base( - $"The function expected {excepted} parameters but got {actual}") + $"The function accept {excepted} arguments but message provided {actual}.") { } } diff --git a/src/SignalRServiceExtension/TriggerBindings/ServerlessHub.cs b/src/SignalRServiceExtension/TriggerBindings/ServerlessHub.cs new file mode 100644 index 00000000..8010e098 --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/ServerlessHub.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Azure.SignalR.Management; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + /// + /// When a class derived from , + /// all the method in the are identified as using class based model. + /// + public abstract class ServerlessHub : IDisposable + { + private bool _disposed; + + public ServerlessHub() + { + var hubName = GetType().Name; + var hubContext = StaticServiceHubContextStore.Get().GetAsync(hubName).GetAwaiter().GetResult(); + Clients = hubContext.Clients; + Groups = hubContext.Groups; + UserGroups = hubContext.UserGroups; + } + + public IHubClients Clients { get; } + + public IGroupManager Groups { get; } + + public IUserGroupManager UserGroups { get; } + + /// + /// Releases all resources currently used by this instance. + /// + /// true if this method is being invoked by the method, + /// otherwise false. + protected virtual void Dispose(bool disposing) + { + } + + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + Dispose(true); + _disposed = true; + } + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRIgnoreAttribute.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRIgnoreAttribute.cs new file mode 100644 index 00000000..931302df --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRIgnoreAttribute.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + /// + /// In class based model, mark the parameter explicitly not to be a SignalR parameter. + /// That means it won't be bound to a InvocationMessage argument. + /// + [AttributeUsage(AttributeTargets.Parameter)] + public class SignalRIgnoreAttribute : Attribute + { + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRParameterAttribute.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRParameterAttribute.cs new file mode 100644 index 00000000..c028db78 --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRParameterAttribute.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + /// + /// Mark the parameter as the SignalR parameter that need to bind arguments. + /// It's mutually exclusive with . That means + /// you can not set and use + /// at the same time. + /// + [AttributeUsage(AttributeTargets.Parameter)] + public class SignalRParameterAttribute : Attribute + { + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs index 8aa13902..78e5df51 100644 --- a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs @@ -11,6 +11,10 @@ namespace Microsoft.Azure.WebJobs.Extensions.SignalRService [Binding] public class SignalRTriggerAttribute : Attribute { + public SignalRTriggerAttribute() + { + } + public SignalRTriggerAttribute(string hubName, string category, string @event): this(hubName, category, @event, Array.Empty()) { } diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBindingProvider.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBindingProvider.cs index a5195622..bb4a7a29 100644 --- a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBindingProvider.cs +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBindingProvider.cs @@ -9,7 +9,7 @@ using System.Threading.Tasks; using Microsoft.Azure.WebJobs.Description; using Microsoft.Azure.WebJobs.Host.Triggers; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; namespace Microsoft.Azure.WebJobs.Extensions.SignalRService { @@ -39,20 +39,56 @@ public Task TryCreateAsync(TriggerBindingProviderContext contex { return Task.FromResult(null); } - var resolvedAttribute = GetParameterResolvedAttribute(attribute); + var resolvedAttribute = GetParameterResolvedAttribute(attribute, parameterInfo); ValidateSignalRTriggerAttributeBinding(resolvedAttribute); return Task.FromResult(new SignalRTriggerBinding(parameterInfo, resolvedAttribute, _dispatcher)); } - private SignalRTriggerAttribute GetParameterResolvedAttribute(SignalRTriggerAttribute attribute) + internal SignalRTriggerAttribute GetParameterResolvedAttribute(SignalRTriggerAttribute attribute, ParameterInfo parameterInfo) { //TODO: AutoResolve more properties in attribute + var hubName = attribute.HubName; + var category = attribute.Category; + var @event = attribute.Event; + var parameterNames = attribute.ParameterNames ?? Array.Empty(); + + // We have two models for C#, one is function based model which also work in multiple language + // Another one is class based model, which is highly close to SignalR itself but must keep some conventions. + var method = (MethodInfo)parameterInfo.Member; + var declaredType = method.DeclaringType; + string[] parameterNamesFromAttribute; + + if (declaredType != null && declaredType.IsSubclassOf(typeof(ServerlessHub))) + { + // Class based model + parameterNamesFromAttribute = method.GetParameters().Where(IsLegalClassBasedParameter).Select(p => p.Name).ToArray(); + hubName = string.IsNullOrEmpty(hubName) ? declaredType.Name : hubName; + category = string.IsNullOrEmpty(category) ? GetCategoryFromMethodName(method.Name) : category; + @event = string.IsNullOrEmpty(@event) ? method.Name : @event; + } + else + { + parameterNamesFromAttribute = method.GetParameters(). + Where(p => p.GetCustomAttribute(false) != null). + Select(p => p.Name).ToArray(); + } + + if (parameterNamesFromAttribute.Length != 0 && parameterNames.Length != 0) + { + throw new InvalidOperationException( + $"{nameof(SignalRTriggerAttribute)}.{nameof(SignalRTriggerAttribute.ParameterNames)} and {nameof(SignalRParameterAttribute)} can not be set in the same Function."); + } + + parameterNames = parameterNamesFromAttribute.Length != 0 + ? parameterNamesFromAttribute + : parameterNames; + var resolvedConnectionString = GetResolvedConnectionString( typeof(SignalRTriggerAttribute).GetProperty(nameof(attribute.ConnectionStringSetting)), attribute.ConnectionStringSetting); - return new SignalRTriggerAttribute(attribute.HubName, attribute.Category, attribute.Event, attribute.ParameterNames){ConnectionStringSetting = resolvedConnectionString}; + return new SignalRTriggerAttribute(hubName, category, @event, parameterNames) {ConnectionStringSetting = resolvedConnectionString}; } private string GetResolvedConnectionString(PropertyInfo property, string configurationName) @@ -87,6 +123,17 @@ private void ValidateSignalRTriggerAttributeBinding(SignalRTriggerAttribute attr ValidateParameterNames(attribute.ParameterNames); } + private string GetCategoryFromMethodName(string name) + { + if (string.Equals(name, Event.Connected, StringComparison.OrdinalIgnoreCase) || + string.Equals(name, Event.Disconnected, StringComparison.OrdinalIgnoreCase)) + { + return Category.Connections; + } + + return Category.Messages; + } + private void ValidateParameterNames(string[] parameterNames) { if (parameterNames == null || parameterNames.Length == 0) @@ -99,5 +146,34 @@ private void ValidateParameterNames(string[] parameterNames) throw new ArgumentException("Elements in ParameterNames should be ignore case unique."); } } + + private bool IsLegalClassBasedParameter(ParameterInfo parameter) + { + // In class based model, we treat all the parameters as a legal parameter except the cases below + // 1. Parameter decorated by [SignalRIgnore] + // 2. Parameter decorated Attribute that has BindingAttribute + // 3. Two special type ILogger and CancellationToken + + if (parameter.ParameterType.IsAssignableFrom(typeof(ILogger)) || + parameter.ParameterType.IsAssignableFrom(typeof(CancellationToken))) + { + return false; + } + if (parameter.GetCustomAttribute() != null) + { + return false; + } + if (HasBindingAttribute(parameter.GetCustomAttributes())) + { + return false; + } + + return true; + } + + private bool HasBindingAttribute(IEnumerable attributes) + { + return attributes.Any(attribute => attribute.GetType().GetCustomAttribute(false) != null); + } } } diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerDispatcher.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerDispatcher.cs index d3b66043..8c1b1fe0 100644 --- a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerDispatcher.cs +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerDispatcher.cs @@ -27,21 +27,21 @@ public void Map((string hubName, string category, string @event) key, ExecutionC { if (!_executors.ContainsKey(key)) { - if (key.category == Category.Connections) + if (string.Equals(key.category,Category.Connections, StringComparison.OrdinalIgnoreCase)) { - if (key.@event == Event.Connect) + if (string.Equals(key.@event, Event.Connected, StringComparison.OrdinalIgnoreCase)) { _executors.Add(key, new SignalRConnectMethodExecutor(_resolver, executor)); return; } - if (key.@event == Event.Disconnect) + if (string.Equals(key.@event, Event.Disconnected, StringComparison.OrdinalIgnoreCase)) { _executors.Add(key, new SignalRDisconnectMethodExecutor(_resolver, executor)); return; } throw new SignalRTriggerException($"Event {key.@event} is not supported in connections"); } - if (key.category == Category.Messages) + if (string.Equals(key.category, Category.Messages, StringComparison.OrdinalIgnoreCase)) { _executors.Add(key, new SignalRInvocationMethodExecutor(_resolver, executor)); return; diff --git a/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerBindingProviderTests.cs b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerBindingProviderTests.cs new file mode 100644 index 00000000..7b639107 --- /dev/null +++ b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerBindingProviderTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text; +using System.Threading; +using Microsoft.Azure.WebJobs; +using Microsoft.Azure.WebJobs.Extensions.SignalRService; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using SignalRServiceExtension.Tests.Utils; +using Xunit; + +namespace SignalRServiceExtension.Tests +{ + public class SignalRTriggerBindingProviderTests + { + [Fact] + public void ResolveAttributeParameterTest() + { + var bindingProvider = CreateBindingProvider(); + var attribute = new SignalRTriggerAttribute(); + var parameter = typeof(TestServerlessHub).GetMethod(nameof(TestServerlessHub.TestFunction), BindingFlags.Instance | BindingFlags.NonPublic).GetParameters()[0]; + var resolvedAttribute = bindingProvider.GetParameterResolvedAttribute(attribute, parameter); + Assert.Equal(nameof(TestServerlessHub), resolvedAttribute.HubName); + Assert.Equal(Category.Messages, resolvedAttribute.Category); + Assert.Equal(nameof(TestServerlessHub.TestFunction), resolvedAttribute.Event); + Assert.Equal(new string[] {"arg0", "arg1"}, resolvedAttribute.ParameterNames); + + // With SignalRIgoreAttribute + parameter = typeof(TestServerlessHub).GetMethod(nameof(TestServerlessHub.TestFunctionWithIgnore), BindingFlags.Instance | BindingFlags.NonPublic).GetParameters()[0]; + resolvedAttribute = bindingProvider.GetParameterResolvedAttribute(attribute, parameter); + Assert.Equal(new string[] { "arg0", "arg1" }, resolvedAttribute.ParameterNames); + + // With ILogger and CancellationToken + parameter = typeof(TestServerlessHub).GetMethod(nameof(TestServerlessHub.TestFunctionWithSpecificType), BindingFlags.Instance | BindingFlags.NonPublic).GetParameters()[0]; + resolvedAttribute = bindingProvider.GetParameterResolvedAttribute(attribute, parameter); + Assert.Equal(new string[] { "arg0", "arg1" }, resolvedAttribute.ParameterNames); + } + + [Fact] + public void ResolveConnectionAttributeParameterTest() + { + var bindingProvider = CreateBindingProvider(); + var attribute = new SignalRTriggerAttribute(); + var parameter = typeof(TestConnectedServerlessHub).GetMethod(nameof(TestConnectedServerlessHub.Connected), BindingFlags.Instance | BindingFlags.NonPublic).GetParameters()[0]; + var resolvedAttribute = bindingProvider.GetParameterResolvedAttribute(attribute, parameter); + Assert.Equal(nameof(TestConnectedServerlessHub), resolvedAttribute.HubName); + Assert.Equal(Category.Connections, resolvedAttribute.Category); + Assert.Equal(nameof(TestConnectedServerlessHub.Connected), resolvedAttribute.Event); + Assert.Equal(new string[] { "arg0", "arg1" }, resolvedAttribute.ParameterNames); + + parameter = typeof(TestConnectedServerlessHub).GetMethod(nameof(TestConnectedServerlessHub.Disconnected), BindingFlags.Instance | BindingFlags.NonPublic).GetParameters()[0]; + resolvedAttribute = bindingProvider.GetParameterResolvedAttribute(attribute, parameter); + Assert.Equal(nameof(TestConnectedServerlessHub), resolvedAttribute.HubName); + Assert.Equal(Category.Connections, resolvedAttribute.Category); + Assert.Equal(nameof(TestConnectedServerlessHub.Disconnected), resolvedAttribute.Event); + Assert.Equal(new string[] { "arg0", "arg1" }, resolvedAttribute.ParameterNames); + } + + [Fact] + public void ResolveNonServerlessHubAttributeParameterTest() + { + var bindingProvider = CreateBindingProvider(); + var attribute = new SignalRTriggerAttribute(); + var parameter = typeof(TestNonServerlessHub).GetMethod(nameof(TestNonServerlessHub.TestFunction), BindingFlags.Instance | BindingFlags.NonPublic).GetParameters()[0]; + var resolvedAttribute = bindingProvider.GetParameterResolvedAttribute(attribute, parameter); + Assert.Null(resolvedAttribute.HubName); + Assert.Null(resolvedAttribute.Category); + Assert.Null(resolvedAttribute.Event); + Assert.Equal(new string[] { "arg0", "arg1" }, resolvedAttribute.ParameterNames); + } + + [Fact] + public void ResolveAttributeParameterConflictTest() + { + var bindingProvider = CreateBindingProvider(); + var attribute = new SignalRTriggerAttribute(){ParameterNames = new string[] {"arg0"}}; + var parameter = typeof(TestServerlessHub).GetMethod(nameof(TestServerlessHub.TestFunction), BindingFlags.Instance | BindingFlags.NonPublic).GetParameters()[0]; + Assert.ThrowsAny(() => bindingProvider.GetParameterResolvedAttribute(attribute, parameter)); + } + + private SignalRTriggerBindingProvider CreateBindingProvider() + { + var dispatcher = new TestTriggerDispatcher(); + return new SignalRTriggerBindingProvider(dispatcher, new DefaultNameResolver(new ConfigurationSection(new ConfigurationRoot(new List()), String.Empty)), new SignalROptions()); + } + + public class TestServerlessHub : ServerlessHub + { + internal void TestFunction([SignalRTrigger]InvocationContext context, string arg0, int arg1) + { + } + + internal void TestFunctionWithIgnore([SignalRTrigger]InvocationContext context, string arg0, int arg1, [SignalRIgnore]int arg2) + { + } + + internal void TestFunctionWithSpecificType([SignalRTrigger]InvocationContext context, string arg0, int arg1, ILogger logger, CancellationToken token) + { + } + } + + public class TestNonServerlessHub + { + internal void TestFunction([SignalRTrigger]InvocationContext context, + [SignalRParameter]string arg0, + [SignalRParameter]int arg1) + { + } + } + + public class TestConnectedServerlessHub : ServerlessHub + { + internal void Connected([SignalRTrigger]InvocationContext context, string arg0, int arg1) + { + } + + internal void Disconnected([SignalRTrigger]InvocationContext context, string arg0, int arg1) + { + } + } + } +} diff --git a/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerDispatcherTests.cs b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerDispatcherTests.cs index 8f2c445f..6a72888c 100644 --- a/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerDispatcherTests.cs +++ b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerDispatcherTests.cs @@ -20,8 +20,8 @@ public class SignalRTriggerDispatcherTests { public static IEnumerable AttributeData() { - yield return new object[] { "connections", "connect", false }; - yield return new object[] { "connections", "disconnect", false }; + yield return new object[] { "connections", "connected", false }; + yield return new object[] { "connections", "disconnected", false }; yield return new object[] { "connections", Guid.NewGuid().ToString(), true }; yield return new object[] { "messages", Guid.NewGuid().ToString(), false }; yield return new object[] { Guid.NewGuid().ToString(), Guid.NewGuid().ToString(), true }; From 0f7b646c88ff6a640f9ad19fc27f2b863b8f5c53 Mon Sep 17 00:00:00 2001 From: Chenyang Liu Date: Tue, 17 Mar 2020 10:48:38 +0800 Subject: [PATCH 14/21] Update message-pack packages --- build/dependencies.props | 1 + .../Microsoft.Azure.SignalR.Serverless.Protocols.csproj | 1 + 2 files changed, 2 insertions(+) diff --git a/build/dependencies.props b/build/dependencies.props index af3fb450..6299af18 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -14,6 +14,7 @@ 1.9.11 11.0.2 4.5.3 + 3.1.2 diff --git a/src/Microsoft.Azure.SignalR.Serverless.Protocols/Microsoft.Azure.SignalR.Serverless.Protocols.csproj b/src/Microsoft.Azure.SignalR.Serverless.Protocols/Microsoft.Azure.SignalR.Serverless.Protocols.csproj index da22b89b..88ab585e 100644 --- a/src/Microsoft.Azure.SignalR.Serverless.Protocols/Microsoft.Azure.SignalR.Serverless.Protocols.csproj +++ b/src/Microsoft.Azure.SignalR.Serverless.Protocols/Microsoft.Azure.SignalR.Serverless.Protocols.csproj @@ -8,6 +8,7 @@ + From 8272b540e03154c7a4bc03912d460fd6673b8238 Mon Sep 17 00:00:00 2001 From: Wanpeng Li Date: Thu, 19 Mar 2020 13:29:39 +0800 Subject: [PATCH 15/21] update (#109) --- build/dependencies.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/dependencies.props b/build/dependencies.props index 6299af18..53fd7f68 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -14,7 +14,7 @@ 1.9.11 11.0.2 4.5.3 - 3.1.2 + 1.1.5 From 2eefea46113cc72b99e5aba60b1203890ba1de19 Mon Sep 17 00:00:00 2001 From: Wanpeng Li Date: Fri, 20 Mar 2020 17:43:42 +0800 Subject: [PATCH 16/21] Resolve attribute dynamically (#110) * add attr cloner and binding base * update * modify prj setting * update * apply bindingBase * minor update * update --- .../csharp/FunctionApp/FunctionApp.csproj | 1 - .../csharp/FunctionApp/FunctionApp.sln | 10 +- .../csharp/FunctionApp/Startup.cs | 1 + .../SignalRInputBindings/AttributeCloner.cs | 532 ++++++++++++++++++ .../SignalRInputBindings/BindingBase.cs | 65 +++ ...ingProvider.cs => InputBindingProvider.cs} | 19 +- .../SignalRConnectionInputBinding.cs | 48 +- .../Bindings/TypeUtility.cs | 86 +++ .../Config/SignalRConfigProvider.cs | 8 +- .../Config/SignalRWebJobsBuilderExtensions.cs | 3 + src/SignalRServiceExtension/Utils.cs | 6 +- 11 files changed, 735 insertions(+), 44 deletions(-) create mode 100644 src/SignalRServiceExtension/Bindings/SignalRInputBindings/AttributeCloner.cs create mode 100644 src/SignalRServiceExtension/Bindings/SignalRInputBindings/BindingBase.cs rename src/SignalRServiceExtension/Bindings/SignalRInputBindings/{SignalRConnectionInputBindingProvider.cs => InputBindingProvider.cs} (60%) create mode 100644 src/SignalRServiceExtension/Bindings/TypeUtility.cs diff --git a/samples/chat-with-custom-auth/csharp/FunctionApp/FunctionApp.csproj b/samples/chat-with-custom-auth/csharp/FunctionApp/FunctionApp.csproj index b267b833..09524ac8 100644 --- a/samples/chat-with-custom-auth/csharp/FunctionApp/FunctionApp.csproj +++ b/samples/chat-with-custom-auth/csharp/FunctionApp/FunctionApp.csproj @@ -5,7 +5,6 @@ - diff --git a/samples/chat-with-custom-auth/csharp/FunctionApp/FunctionApp.sln b/samples/chat-with-custom-auth/csharp/FunctionApp/FunctionApp.sln index 6f688905..6fe2d6f8 100644 --- a/samples/chat-with-custom-auth/csharp/FunctionApp/FunctionApp.sln +++ b/samples/chat-with-custom-auth/csharp/FunctionApp/FunctionApp.sln @@ -1,12 +1,14 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.27703.2047 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29905.134 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FunctionApp", "FunctionApp.csproj", "{185119A1-81E7-4A9C-BFD7-C3C976BDA463}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Azure.WebJobs.Extensions.SignalRService", "..\..\..\..\src\SignalRServiceExtension\Microsoft.Azure.WebJobs.Extensions.SignalRService.csproj", "{43AD6D39-E440-4812-A86F-22EA23E62456}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Azure.SignalR.Serverless.Protocols", "..\..\..\..\src\Microsoft.Azure.SignalR.Serverless.Protocols\Microsoft.Azure.SignalR.Serverless.Protocols.csproj", "{AE7231EC-8A21-41BD-8D39-4446107E874D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,10 @@ Global {43AD6D39-E440-4812-A86F-22EA23E62456}.Debug|Any CPU.Build.0 = Debug|Any CPU {43AD6D39-E440-4812-A86F-22EA23E62456}.Release|Any CPU.ActiveCfg = Release|Any CPU {43AD6D39-E440-4812-A86F-22EA23E62456}.Release|Any CPU.Build.0 = Release|Any CPU + {AE7231EC-8A21-41BD-8D39-4446107E874D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE7231EC-8A21-41BD-8D39-4446107E874D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE7231EC-8A21-41BD-8D39-4446107E874D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE7231EC-8A21-41BD-8D39-4446107E874D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/samples/chat-with-custom-auth/csharp/FunctionApp/Startup.cs b/samples/chat-with-custom-auth/csharp/FunctionApp/Startup.cs index b86253aa..578bdda6 100644 --- a/samples/chat-with-custom-auth/csharp/FunctionApp/Startup.cs +++ b/samples/chat-with-custom-auth/csharp/FunctionApp/Startup.cs @@ -29,6 +29,7 @@ public override void Configure(IFunctionsHostBuilder builder) .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true) .AddEnvironmentVariables() .Build(); + // todo [wanl]: check if exists var issuerSigningKey = config["IssuerSigningKey"]; // base64 encoded for "myfunctionauthtest"; // Register the access token provider as a singleton, customer can register one's own diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/AttributeCloner.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/AttributeCloner.cs new file mode 100644 index 00000000..cc6f97c3 --- /dev/null +++ b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/AttributeCloner.cs @@ -0,0 +1,532 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Reflection; +using Microsoft.Azure.WebJobs.Description; +using Microsoft.Azure.WebJobs.Host; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Azure.WebJobs.Host.Bindings.Path; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; +using BindingData = System.Collections.Generic.IReadOnlyDictionary; +using BindingDataContract = System.Collections.Generic.IReadOnlyDictionary; +// Func to transform Attribute,BindingData into value for cloned attribute property/constructor arg +// Attribute is the new cloned attribute - null if constructor arg (new cloned attr not created yet) +using BindingDataResolver = System.Func, object>; + +using Validator = System.Action; + +#pragma warning disable CS0618 // Type or member is obsolete +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + // Clone an attribute and resolve it. + // This can be tricky since some read-only properties are set via the constructor. + // This assumes that the property name matches the constructor argument name. + internal class AttributeCloner + where TAttribute : Attribute + { + private readonly TAttribute _source; + + // Which constructor do we invoke to instantiate the new attribute? + // The attribute is configured through a) constructor arguments, b) settable properties. + private readonly ConstructorInfo _matchedCtor; + + // Compute the arguments to pass to the chosen constructor. Arguments are based on binding data. + private readonly BindingDataResolver[] _ctorParamResolvers; + + // Compute the values to apply to Settable properties on newly created attribute. + private readonly Action[] _propertySetters; + + private readonly Dictionary _autoResolves = new Dictionary(); + + private static readonly BindingFlags Flags = BindingFlags.Instance | BindingFlags.Public; + private readonly IConfiguration _configuration; + + internal AttributeCloner( + TAttribute source, + BindingDataContract bindingDataContract, + IConfiguration configuration, + INameResolver nameResolver = null) + { + _configuration = configuration; + + nameResolver = nameResolver ?? new EmptyNameResolver(); + _source = source; + + Type attributeType = typeof(TAttribute); + + PropertyInfo[] allProperties = attributeType.GetProperties(Flags); + + // Create dictionary of all non-null properties on source attribute. + Dictionary nonNullProps = allProperties + .Where(prop => prop.GetValue(source) != null) + .ToDictionary(prop => prop.Name, prop => prop, StringComparer.OrdinalIgnoreCase); + + // Pick the ctor with the longest parameter list where all are matched to non-null props. + var ctorAndParams = attributeType.GetConstructors(Flags) + .Select(ctor => new { ctor = ctor, parameters = ctor.GetParameters() }) + .OrderByDescending(tuple => tuple.parameters.Length) + .FirstOrDefault(tuple => tuple.parameters.All(param => nonNullProps.ContainsKey(param.Name))); + + if (ctorAndParams == null) + { + throw new InvalidOperationException("Can't figure out which ctor to call."); + } + + _matchedCtor = ctorAndParams.ctor; + + // Get appropriate binding data resolver (appsetting, autoresolve, or originalValue) for each constructor parameter + _ctorParamResolvers = ctorAndParams.parameters + .Select(param => GetResolver(nonNullProps[param.Name], nameResolver, bindingDataContract)) + .ToArray(); + + // Get appropriate binding data resolver (appsetting, autoresolve, or originalValue) for each writeable property + _propertySetters = allProperties + .Where(prop => prop.CanWrite) + .Select(prop => + { + var resolver = GetResolver(prop, nameResolver, bindingDataContract); + return (Action)((attr, data) => prop.SetValue(attr, resolver(attr, data))); + }) + .ToArray(); + } + + // transforms binding data to appropriate resolver (appsetting, autoresolve, or originalValue) + private BindingDataResolver GetResolver(PropertyInfo propInfo, INameResolver nameResolver, BindingDataContract contract) + { + // Do the attribute lookups once upfront, and then cache them (via func closures) for subsequent runtime usage. + object originalValue = propInfo.GetValue(_source); + ConnectionStringAttribute connStrAttr = propInfo.GetCustomAttribute(); + AppSettingAttribute appSettingAttr = propInfo.GetCustomAttribute(); + AutoResolveAttribute autoResolveAttr = propInfo.GetCustomAttribute(); + Validator validator = GetValidatorFunc(propInfo, appSettingAttr != null); + + if (appSettingAttr == null && autoResolveAttr == null && connStrAttr == null) + { + validator(originalValue); + + // No special attributes, treat as literal. + return (newAttr, bindingData) => originalValue; + } + + int attrCount = new Attribute[] { connStrAttr, appSettingAttr, autoResolveAttr }.Count(a => a != null); + if (attrCount > 1) + { + throw new InvalidOperationException($"Property '{propInfo.Name}' can only be annotated with one of the types {nameof(AppSettingAttribute)}, {nameof(AutoResolveAttribute)}, and {nameof(ConnectionStringAttribute)}."); + } + + // attributes only work on string properties. + if (propInfo.PropertyType != typeof(string)) + { + throw new InvalidOperationException($"{nameof(ConnectionStringAttribute)}, {nameof(AutoResolveAttribute)}, or {nameof(AppSettingAttribute)} property '{propInfo.Name}' must be of type string."); + } + + var str = (string)originalValue; + + // first try to resolve with connection string + if (connStrAttr != null) + { + return GetConfigurationResolver(str, connStrAttr.Default, propInfo, validator, s => _configuration.GetConnectionStringOrSetting(nameResolver.ResolveWholeString(s))); + } + + // then app setting + if (appSettingAttr != null) + { + return GetConfigurationResolver(str, appSettingAttr.Default, propInfo, validator, s => _configuration[s]); + } + + // Must have an [AutoResolve] + // try to resolve with auto resolve ({...}, %...%) + return GetAutoResolveResolver(str, autoResolveAttr, nameResolver, propInfo, contract, validator); + } + + // Apply AutoResolve attribute + internal BindingDataResolver GetAutoResolveResolver(string originalValue, AutoResolveAttribute autoResolveAttr, INameResolver nameResolver, PropertyInfo propInfo, BindingDataContract contract, Validator validator) + { + if (string.IsNullOrWhiteSpace(originalValue)) + { + if (autoResolveAttr.Default != null) + { + return GetBuiltinTemplateResolver(autoResolveAttr.Default, nameResolver, validator); + } + else + { + validator(originalValue); + return (newAttr, bindingData) => originalValue; + } + } + else + { + _autoResolves[propInfo] = autoResolveAttr; + return GetTemplateResolver(originalValue, autoResolveAttr, nameResolver, propInfo, contract, validator); + } + } + + // Both ConnectionString and AppSetting have the same behavior, but perform the lookup differently. + internal static BindingDataResolver GetConfigurationResolver(string propertyValue, string defaultValue, PropertyInfo propInfo, Validator validator, Func resolveValue) + { + string configurationKey = propertyValue ?? defaultValue; + string resolvedValue = null; + + if (!string.IsNullOrEmpty(configurationKey)) + { + resolvedValue = resolveValue(configurationKey); + } + + // If a value is non-null and cannot be found, we throw to match the behavior + // when %% values are not found in ResolveWholeString below. + if (resolvedValue == null && propertyValue != null) + { + // It's important that we only log the attribute property name, not the actual value to ensure + // that in cases where users accidentally use a secret key *value* rather than indirect setting name + // that value doesn't get written to logs. + throw new InvalidOperationException($"Unable to resolve the value for property '{propInfo.DeclaringType.Name}.{propInfo.Name}'. Make sure the setting exists and has a valid value."); + } + + // validate after the %% is substituted. + validator(resolvedValue); + + return (newAttr, bindingData) => resolvedValue; + } + + // Run validition. This needs to be run at different stages. + // In general, run as early as possible. If there are { } tokens, then we can't run until runtime. + // But if there are no { }, we can run statically. + // If there's no [AutoResolve], [AppSettings], then we can run immediately. + private static Validator GetValidatorFunc(PropertyInfo propInfo, bool dontLogValues) + { + // This implicitly caches the attribute lookup once and then shares for each runtime invocation. + var attrs = propInfo.GetCustomAttributes(); + + return (value) => + { + foreach (var attr in attrs) + { + try + { + attr.Validate(value, propInfo.Name); + } + catch (Exception e) + { + if (dontLogValues) + { + throw new InvalidOperationException($"Validation failed for property '{propInfo.Name}'. {e.Message}"); + } + else + { + throw new InvalidOperationException($"Validation failed for property '{propInfo.Name}', value '{value}'. {e.Message}"); + } + } + } + }; + } + + // Resolve for AutoResolve.Default templates. + // These only have access to the {sys} builtin variable and don't get access to trigger binding data. + internal static BindingDataResolver GetBuiltinTemplateResolver(string originalValue, INameResolver nameResolver, Validator validator) + { + string resolvedValue = nameResolver.ResolveWholeString(originalValue); + + var template = BindingTemplate.FromString(resolvedValue); + if (!template.HasParameters) + { + // No { } tokens, bind eagerly up front. + validator(originalValue); + } + + SystemBindingData.ValidateStaticContract(template); + + // For static default contracts, we only have access to the built in binding data. + return (newAttr, bindingData) => + { + var newValue = template.Bind(SystemBindingData.GetSystemBindingData(bindingData)); + validator(newValue); + return newValue; + }; + } + + // AutoResolve + internal static BindingDataResolver GetTemplateResolver(string originalValue, AutoResolveAttribute attr, INameResolver nameResolver, PropertyInfo propInfo, BindingDataContract contract, Validator validator) + { + string resolvedValue = nameResolver.ResolveWholeString(originalValue); + var template = BindingTemplate.FromString(resolvedValue); + + if (!template.HasParameters) + { + // No { } tokens, bind eagerly up front. + validator(resolvedValue); + } + + IResolutionPolicy policy = GetPolicy(attr.ResolutionPolicyType, propInfo); + template.ValidateContractCompatibility(contract); + return (newAttr, bindingData) => TemplateBind(policy, propInfo, newAttr, template, bindingData, validator); + } + + public TAttribute ResolveFromBindingData(BindingContext ctx) + { + var attr = ResolveFromBindings(ctx.BindingData); + return attr; + } + + // When there's only 1 resolvable property + internal TAttribute New(string invokeString) + { + if (_autoResolves.Count() != 1) + { + throw new InvalidOperationException("Invalid invoke string format for attribute."); + } + var overrideProps = _autoResolves.Select(pair => pair.Key) + .ToDictionary(prop => prop.Name, prop => invokeString, StringComparer.OrdinalIgnoreCase); + return New(overrideProps); + } + + // Clone the source attribute, but override the properties with the supplied. + internal TAttribute New(IDictionary overrideProperties) + { + IDictionary propertyValues = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Populate inititial properties from the source + Type t = typeof(TAttribute); + var properties = t.GetProperties(Flags); + foreach (var prop in properties) + { + propertyValues[prop.Name] = prop.GetValue(_source); + } + + foreach (var kv in overrideProperties) + { + propertyValues[kv.Key] = kv.Value; + } + + var ctorArgs = Array.ConvertAll(_matchedCtor.GetParameters(), param => propertyValues[param.Name]); + var newAttr = (TAttribute)_matchedCtor.Invoke(ctorArgs); + + foreach (var prop in properties) + { + if (prop.CanWrite) + { + var val = propertyValues[prop.Name]; + prop.SetValue(newAttr, val); + } + } + return newAttr; + } + + internal TAttribute ResolveFromBindings(BindingData bindingData) + { + // Invoke ctor + var ctorArgs = Array.ConvertAll(_ctorParamResolvers, func => func(_source, bindingData)); + var newAttr = (TAttribute)_matchedCtor.Invoke(ctorArgs); + + foreach (var setProp in _propertySetters) + { + setProp(newAttr, bindingData); + } + + return newAttr; + } + + private static string TemplateBind(IResolutionPolicy policy, PropertyInfo prop, Attribute attr, BindingTemplate template, BindingData bindingData, Validator validator) + { + if (bindingData == null) + { + // Skip validation if no binding data provided. We can't do the { } substitutions. + return template?.Pattern; + } + + var newValue = policy.TemplateBind(prop, attr, template, bindingData); + validator(newValue); + return newValue; + } + + internal static IResolutionPolicy GetPolicy(Type formatterType, PropertyInfo propInfo) + { + if (formatterType != null) + { + if (!typeof(IResolutionPolicy).IsAssignableFrom(formatterType)) + { + throw new InvalidOperationException($"The {nameof(AutoResolveAttribute.ResolutionPolicyType)} on {propInfo.Name} must derive from {typeof(IResolutionPolicy).Name}."); + } + + try + { + var obj = Activator.CreateInstance(formatterType); + return (IResolutionPolicy)obj; + } + catch (MissingMethodException) + { + throw new InvalidOperationException($"The {nameof(AutoResolveAttribute.ResolutionPolicyType)} on {propInfo.Name} must derive from {typeof(IResolutionPolicy).Name} and have a default constructor."); + } + } + + // return the default policy + return new DefaultResolutionPolicy(); + } + + // If no name resolver is specified, then any %% becomes an error. + private class EmptyNameResolver : INameResolver + { + public string Resolve(string name) => null; + } + + /// + /// Class providing support for built in system binding expressions + /// + /// + /// It's expected this class is created and added to the binding data. + /// + private class SystemBindingData + { + // The public name for this binding in the binding expressions. + public const string Name = "sys"; + + // An internal name for this binding that uses characters that gaurantee it can't be overwritten by a user. + // This is never seen by the user. + // This ensures that we can always unambiguously retrieve this later. + private const string InternalKeyName = "$sys"; + + private static readonly IReadOnlyDictionary DefaultSystemContract = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { Name, typeof(SystemBindingData) } + }; + + /// + /// The method name that the binding lives in. + /// The method name can be override by the + /// + public string MethodName { get; set; } + + /// + /// Get the current UTC date. + /// + public DateTime UtcNow => DateTime.UtcNow; + + /// + /// Return a new random guid. This create a new guid each time it's called. + /// + public Guid RandGuid => Guid.NewGuid(); + + // Given a full bindingData, create a binding data with just the system object . + // This can be used when resolving default contracts that shouldn't be using an instance binding data. + internal static IReadOnlyDictionary GetSystemBindingData(IReadOnlyDictionary bindingData) + { + var data = GetFromData(bindingData); + var systemBindingData = new Dictionary + { + { Name, data } + }; + return systemBindingData; + } + + // Validate that a template only uses static (non-instance) binding variables. + // Enforces we're not referring to other data from the trigger. + internal static void ValidateStaticContract(BindingTemplate template) + { + try + { + template.ValidateContractCompatibility(SystemBindingData.DefaultSystemContract); + } + catch (InvalidOperationException e) + { + throw new InvalidOperationException($"Default contract can only refer to the '{SystemBindingData.Name}' binding data: " + e.Message); + } + } + + internal void AddToBindingData(Dictionary bindingData) + { + // User data takes precedence, so if 'sys' already exists, add via the internal name. + string sysName = bindingData.ContainsKey(SystemBindingData.Name) ? SystemBindingData.InternalKeyName : SystemBindingData.Name; + bindingData[sysName] = this; + } + + // Given per-instance binding data, extract just the system binding data object from it. + private static SystemBindingData GetFromData(IReadOnlyDictionary bindingData) + { + object val; + if (bindingData.TryGetValue(InternalKeyName, out val)) + { + return val as SystemBindingData; + } + if (bindingData.TryGetValue(Name, out val)) + { + return val as SystemBindingData; + } + return null; + } + } + + // Helpers for providing default behavior for an IAttributeInvokeDescriptor that + // convert between a TAttribute and a string representation (invoke string). + // Properties with [AutoResolve] are the interesting ones to serialize and deserialize. + // Assume any property without a [AutoResolve] attribute is read-only and so doesn't need to be included in the invoke string. + private static class DefaultAttributeInvokerDescriptor + { + public static TAttribute FromInvokeString(AttributeCloner cloner, string invokeString) + { + if (invokeString == null) + { + throw new ArgumentNullException("invokeString"); + } + + // Instantiating new attributes can be tricky since sometimes the arg is to the ctor and sometimes + // its a property setter. AttributeCloner already solves this, so use it here to do the actual attribute instantiation. + // This has an instantiation problem similar to what Attribute Cloner has + if (invokeString[0] == '{') + { + var propertyValues = JsonConvert.DeserializeObject>(invokeString); + + var attr = cloner.New(propertyValues); + return attr; + } + else + { + var attr = cloner.New(invokeString); + return attr; + } + } + + public static string ToInvokeString(IDictionary resolvableProps, TAttribute source) + { + Dictionary vals = new Dictionary(); + foreach (var pair in resolvableProps.AsEnumerable()) + { + var prop = pair.Key; + var str = (string)prop.GetValue(source); + if (!string.IsNullOrWhiteSpace(str)) + { + vals[prop.Name] = str; + } + } + + if (vals.Count == 0) + { + return string.Empty; + } + if (vals.Count == 1) + { + // Flat + return vals.First().Value; + } + return JsonConvert.SerializeObject(vals); + } + } + + /// + /// Resolution policy for { } in binding templates. + /// The default policy is just a direct substitution for the binding data. + /// Derived policies can enforce formatting / escaping when they do injection. + /// + private class DefaultResolutionPolicy : IResolutionPolicy + { + public string TemplateBind(PropertyInfo propInfo, Attribute attribute, BindingTemplate template, IReadOnlyDictionary bindingData) + { + return template.Bind(bindingData); + } + } + } +} +#pragma warning restore CS0618 // Type or member is obsolete diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/BindingBase.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/BindingBase.cs new file mode 100644 index 00000000..bf1e26b3 --- /dev/null +++ b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/BindingBase.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Azure.WebJobs.Host.Protocols; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + // todo: extend to implement ITriggerBinding + // Helper class for implementing IBinding with the attribute resolver pattern. + internal abstract class BindingBase : IBinding + where TAttribute : Attribute + { + protected readonly AttributeCloner Cloner; + private readonly ParameterDescriptor param; + + public BindingBase(BindingProviderContext context, IConfiguration configuration, INameResolver nameResolver) + { + var attributeSource = TypeUtility.GetResolvedAttribute(context.Parameter); + Cloner = new AttributeCloner(attributeSource, context.BindingDataContract, configuration, nameResolver); + + param = new ParameterDescriptor + { + Name = context.Parameter.Name, + DisplayHints = new ParameterDisplayHints + { + Description = "value" + } + }; + } + + public bool FromAttribute + { + get + { + return true; + } + } + + protected abstract Task BuildAsync(TAttribute attrResolved, IReadOnlyDictionary bindingContext); + + public async Task BindAsync(BindingContext context) + { + var attrResolved = Cloner.ResolveFromBindingData(context); + return await BuildAsync(attrResolved, context.BindingData); + } + + public Task BindAsync(object value, ValueBindingContext context) + { + //todo [wanl]: figure out what will trigger it + throw new NotImplementedException(); + } + + public ParameterDescriptor ToParameterDescriptor() + { + return param; + } + } +} + diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBindingProvider.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/InputBindingProvider.cs similarity index 60% rename from src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBindingProvider.cs rename to src/SignalRServiceExtension/Bindings/SignalRInputBindings/InputBindingProvider.cs index 2c13978d..36b8002a 100644 --- a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBindingProvider.cs +++ b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/InputBindingProvider.cs @@ -1,24 +1,28 @@ // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System; using System.Reflection; using System.Threading.Tasks; using Microsoft.Azure.WebJobs.Host.Bindings; +using Microsoft.Extensions.Configuration; namespace Microsoft.Azure.WebJobs.Extensions.SignalRService { - internal class SignalRConnectionInputBindingProvider : IBindingProvider + // this input binding provider doesn't support converter and pattern matcher + internal class InputBindingProvider : IBindingProvider { private readonly ISecurityTokenValidator securityTokenValidator; - private readonly SignalROptions options; private readonly ISignalRConnectionInfoConfigurer signalRConnectionInfoConfigurer; private readonly INameResolver nameResolver; + private readonly IConfiguration configuration; - public SignalRConnectionInputBindingProvider(INameResolver nameResolver, SignalROptions options, ISecurityTokenValidator securityTokenValidator, ISignalRConnectionInfoConfigurer signalRConnectionInfoConfigurer) + // todo [wanl]: hubName uses [AutoResolve] + public InputBindingProvider(IConfiguration configuration, INameResolver nameResolver, ISecurityTokenValidator securityTokenValidator, ISignalRConnectionInfoConfigurer signalRConnectionInfoConfigurer) { + this.configuration = configuration; this.nameResolver = nameResolver; this.securityTokenValidator = securityTokenValidator; - this.options = options; this.signalRConnectionInfoConfigurer = signalRConnectionInfoConfigurer; } @@ -29,10 +33,9 @@ public Task TryCreateAsync(BindingProviderContext context) { switch (attr) { - case SignalRConnectionInfoAttribute connectionInfoAttribute: - var resolvedConnectionString = nameResolver.Resolve(connectionInfoAttribute.ConnectionStringSetting); - return Task.FromResult((IBinding)new SignalRConnectionInputBinding(connectionInfoAttribute, Utils.GetAzureSignalRClient(resolvedConnectionString, connectionInfoAttribute.HubName, options), securityTokenValidator, signalRConnectionInfoConfigurer)); - case SecurityTokenValidationAttribute validationAttribute: + case SignalRConnectionInfoAttribute signalRConnectionInfoAttribute: + return Task.FromResult((IBinding)new SignalRConnectionInputBinding(context, configuration, nameResolver, securityTokenValidator, signalRConnectionInfoConfigurer)); + case SecurityTokenValidationAttribute securityTokenValidationAttribute: return Task.FromResult((IBinding) new SecurityTokenValidationInputBinding(securityTokenValidator)); } } diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding.cs index 1936fada..157c81f6 100644 --- a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding.cs +++ b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding.cs @@ -1,38 +1,42 @@ // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System; +using System.Collections.Generic; +using System.Reflection; using Microsoft.AspNetCore.Http; using Microsoft.Azure.WebJobs.Host.Bindings; using Microsoft.Azure.WebJobs.Host.Protocols; using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; namespace Microsoft.Azure.WebJobs.Extensions.SignalRService { - internal class SignalRConnectionInputBinding : IBinding + internal class SignalRConnectionInputBinding : BindingBase { private const string HttpRequestName = "$request"; - private readonly SignalRConnectionInfoAttribute attribute; private readonly ISecurityTokenValidator securityTokenValidator; - private readonly AzureSignalRClient azureSignalRClient; private readonly ISignalRConnectionInfoConfigurer signalRConnectionInfoConfigurer; - public bool FromAttribute => true; - - public SignalRConnectionInputBinding(SignalRConnectionInfoAttribute attribute, AzureSignalRClient azureSignalRClient, ISecurityTokenValidator securityTokenValidator, ISignalRConnectionInfoConfigurer signalRConnectionInfoConfigurer) + public SignalRConnectionInputBinding( + BindingProviderContext context, + IConfiguration configuration, + INameResolver nameResolver, + ISecurityTokenValidator securityTokenValidator, + ISignalRConnectionInfoConfigurer signalRConnectionInfoConfigurer) : base(context, configuration, nameResolver) { this.securityTokenValidator = securityTokenValidator; - this.azureSignalRClient = azureSignalRClient; - this.attribute = attribute; this.signalRConnectionInfoConfigurer = signalRConnectionInfoConfigurer; } - public Task BindAsync(object value, ValueBindingContext context) + protected override Task BuildAsync(SignalRConnectionInfoAttribute attrResolved, + IReadOnlyDictionary bindingData) { - var bindingData = ((BindingContext)value).BindingData; - + var azureSignalRClient = Utils.GetAzureSignalRClient(attrResolved.ConnectionStringSetting, attrResolved.HubName); if (!bindingData.ContainsKey(HttpRequestName) || securityTokenValidator == null) { - var info = azureSignalRClient.GetClientConnectionInfo(attribute.UserId, attribute.IdToken, attribute.ClaimTypeList); + var info = azureSignalRClient.GetClientConnectionInfo(attrResolved.UserId, attrResolved.IdToken, + attrResolved.ClaimTypeList); return Task.FromResult((IValueProvider)new SignalRValueProvider(info)); } @@ -47,28 +51,20 @@ public Task BindAsync(object value, ValueBindingContext context) if (signalRConnectionInfoConfigurer == null) { - var info = azureSignalRClient.GetClientConnectionInfo(attribute.UserId, attribute.IdToken, attribute.ClaimTypeList); + var info = azureSignalRClient.GetClientConnectionInfo(attrResolved.UserId, attrResolved.IdToken, + attrResolved.ClaimTypeList); return Task.FromResult((IValueProvider)new SignalRValueProvider(info)); } var signalRConnectionDetail = new SignalRConnectionDetail { - UserId = attribute.UserId, - Claims = azureSignalRClient.GetCustomClaims(attribute.IdToken, attribute.ClaimTypeList), + UserId = attrResolved.UserId, + Claims = azureSignalRClient.GetCustomClaims(attrResolved.IdToken, attrResolved.ClaimTypeList), }; signalRConnectionInfoConfigurer.Configure(tokenResult, request, signalRConnectionDetail); - var customizedInfo = azureSignalRClient.GetClientConnectionInfo(signalRConnectionDetail.UserId, signalRConnectionDetail.Claims); + var customizedInfo = azureSignalRClient.GetClientConnectionInfo(signalRConnectionDetail.UserId, + signalRConnectionDetail.Claims); return Task.FromResult((IValueProvider)new SignalRValueProvider(customizedInfo)); } - - public Task BindAsync(BindingContext context) - { - return BindAsync(context, null); - } - - public ParameterDescriptor ToParameterDescriptor() - { - return new ParameterDescriptor(); - } } } \ No newline at end of file diff --git a/src/SignalRServiceExtension/Bindings/TypeUtility.cs b/src/SignalRServiceExtension/Bindings/TypeUtility.cs new file mode 100644 index 00000000..9f916d18 --- /dev/null +++ b/src/SignalRServiceExtension/Bindings/TypeUtility.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Text; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class TypeUtility + { + internal static TAttribute GetResolvedAttribute(ParameterInfo parameter) where TAttribute : Attribute + { + var attribute = parameter.GetCustomAttribute(); + + var attributeConnectionProvider = attribute as IConnectionProvider; + if (attributeConnectionProvider != null && string.IsNullOrEmpty(attributeConnectionProvider.Connection)) + { + // if the attribute doesn't specify an explicit connnection, walk up + // the hierarchy looking for an override specified via attribute + var connectionProviderAttribute = attribute.GetType().GetCustomAttribute(); + if (connectionProviderAttribute?.ProviderType != null) + { + var connectionOverrideProvider = GetHierarchicalAttributeOrNull(parameter, connectionProviderAttribute.ProviderType) as IConnectionProvider; + if (connectionOverrideProvider != null && !string.IsNullOrEmpty(connectionOverrideProvider.Connection)) + { + attributeConnectionProvider.Connection = connectionOverrideProvider.Connection; + } + } + } + + return attribute; + } + + /// + /// Walk from the parameter up to the containing type, looking for an instance + /// of the specified attribute type, returning it if found. + /// + /// The parameter to check. + /// The attribute type to look for. + internal static Attribute GetHierarchicalAttributeOrNull(ParameterInfo parameter, Type attributeType) + { + if (parameter == null) + { + return null; + } + + var attribute = parameter.GetCustomAttribute(attributeType); + if (attribute != null) + { + return attribute; + } + + var method = parameter.Member as MethodInfo; + if (method == null) + { + return null; + } + return GetHierarchicalAttributeOrNull(method, attributeType); + } + + /// + /// Walk from the method up to the containing type, looking for an instance + /// of the specified attribute type, returning it if found. + /// + /// The method to check. + /// The attribute type to look for. + internal static Attribute GetHierarchicalAttributeOrNull(MethodInfo method, Type type) + { + var attribute = method.GetCustomAttribute(type); + if (attribute != null) + { + return attribute; + } + + attribute = method.DeclaringType.GetCustomAttribute(type); + if (attribute != null) + { + return attribute; + } + + return null; + } + } +} diff --git a/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs b/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs index 6e4c13a8..bbc96d68 100644 --- a/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs +++ b/src/SignalRServiceExtension/Config/SignalRConfigProvider.cs @@ -28,7 +28,7 @@ internal class SignalRConfigProvider : IExtensionConfigProvider, IAsyncConverter private readonly SignalROptions options; private readonly ILoggerFactory loggerFactory; private readonly ISignalRTriggerDispatcher _dispatcher; - private readonly SignalRConnectionInputBindingProvider signalRConnectionInputBindingProvider; + private readonly InputBindingProvider inputBindingProvider; public SignalRConfigProvider( IOptions options, @@ -44,7 +44,7 @@ public SignalRConfigProvider( this.nameResolver = nameResolver; this.configuration = configuration; this._dispatcher = new SignalRTriggerDispatcher(); - signalRConnectionInputBindingProvider = new SignalRConnectionInputBindingProvider(nameResolver, options.Value, securityTokenValidator, signalRConnectionInfoConfigurer); + inputBindingProvider = new InputBindingProvider(configuration, nameResolver, securityTokenValidator, signalRConnectionInfoConfigurer); } // GetWebhookHandler() need the Obsolete @@ -89,10 +89,10 @@ public void Initialize(ExtensionConfigContext context) // Non-trigger binding rule var signalRConnectionInfoAttributeRule = context.AddBindingRule(); signalRConnectionInfoAttributeRule.AddValidator(ValidateSignalRConnectionInfoAttributeBinding); - signalRConnectionInfoAttributeRule.Bind(signalRConnectionInputBindingProvider); + signalRConnectionInfoAttributeRule.Bind(inputBindingProvider); var securityTokenValidationAttributeRule = context.AddBindingRule(); - securityTokenValidationAttributeRule.Bind(signalRConnectionInputBindingProvider); + securityTokenValidationAttributeRule.Bind(inputBindingProvider); var signalRAttributeRule = context.AddBindingRule(); signalRAttributeRule.AddValidator(ValidateSignalRAttributeBinding); diff --git a/src/SignalRServiceExtension/Config/SignalRWebJobsBuilderExtensions.cs b/src/SignalRServiceExtension/Config/SignalRWebJobsBuilderExtensions.cs index a2e1eb8a..ec064f79 100644 --- a/src/SignalRServiceExtension/Config/SignalRWebJobsBuilderExtensions.cs +++ b/src/SignalRServiceExtension/Config/SignalRWebJobsBuilderExtensions.cs @@ -7,6 +7,9 @@ namespace Microsoft.Azure.WebJobs.Extensions.SignalRService { + // todo [wanl]: remove hardcode key = hubName, HubName in attribute already marked as [AutoResolve], its behavior should be [AutoResolve]. + // Then all resolve jobs are put in resolvers, we can also remove the SignalROption after we apply resolve jobs inside bindings. + /// /// Extension methods for SignalR Service integration /// diff --git a/src/SignalRServiceExtension/Utils.cs b/src/SignalRServiceExtension/Utils.cs index e6c8d9c7..066be262 100644 --- a/src/SignalRServiceExtension/Utils.cs +++ b/src/SignalRServiceExtension/Utils.cs @@ -12,10 +12,10 @@ public static string FirstOrDefault(params string[] values) return values.FirstOrDefault(v => !string.IsNullOrEmpty(v)); } - public static AzureSignalRClient GetAzureSignalRClient(string attributeConnectionString, string attributeHubName, SignalROptions options) + public static AzureSignalRClient GetAzureSignalRClient(string attributeConnectionString, string attributeHubName, SignalROptions options = null) { - var connectionString = FirstOrDefault(attributeConnectionString, options.ConnectionString); - var hubName = FirstOrDefault(attributeHubName, options.HubName); + var connectionString = FirstOrDefault(attributeConnectionString, options?.ConnectionString); + var hubName = FirstOrDefault(attributeHubName, options?.HubName); return new AzureSignalRClient(StaticServiceHubContextStore.ServiceManagerStore, connectionString, hubName); } From f784d27e41f7a7d1453f1c54e6a39dd56dbb3c13 Mon Sep 17 00:00:00 2001 From: Chenyang Liu Date: Mon, 23 Mar 2020 14:32:32 +0800 Subject: [PATCH 17/21] Improvements in the class based model (#108) * Some imporvements in class based model * Add some summarys and some updates accoring to comments * Add more summary * minor updates --- samples/bidirectional-chat/content/index.html | 2 +- .../Authorize/FunctionAuthorizeAttribute.cs | 28 ++++----- samples/bidirectional-chat/csharp/Function.cs | 16 +++-- src/SignalRServiceExtension/Constants.cs | 2 + .../SignalRInvocationMethodExecutor.cs | 6 +- .../InvocationContextExtensions.cs | 36 +++++++++++ .../TriggerBindings/ServerlessHub.cs | 60 ++++++++++++++++++- .../TriggerBindings/SignalRFilterAttribute.cs | 35 +++++++++++ .../SignalRTriggerAttribute.cs | 8 +-- .../SignalRTriggerBindingProvider.cs | 44 ++++++++++---- .../SignalRTriggerBindingProviderTests.cs | 14 ++--- version.props | 2 +- 12 files changed, 200 insertions(+), 53 deletions(-) create mode 100644 src/SignalRServiceExtension/TriggerBindings/InvocationContextExtensions.cs create mode 100644 src/SignalRServiceExtension/TriggerBindings/SignalRFilterAttribute.cs diff --git a/samples/bidirectional-chat/content/index.html b/samples/bidirectional-chat/content/index.html index 6f23c919..53f02748 100644 --- a/samples/bidirectional-chat/content/index.html +++ b/samples/bidirectional-chat/content/index.html @@ -214,7 +214,7 @@

Serverless chat

// customize your JWT token payload here var data = { - "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name": userName, + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier": userName, "exp": 1699819025, 'admin': isAdmin }; diff --git a/samples/bidirectional-chat/csharp/Authorize/FunctionAuthorizeAttribute.cs b/samples/bidirectional-chat/csharp/Authorize/FunctionAuthorizeAttribute.cs index 204265fa..c7bfb1d9 100644 --- a/samples/bidirectional-chat/csharp/Authorize/FunctionAuthorizeAttribute.cs +++ b/samples/bidirectional-chat/csharp/Authorize/FunctionAuthorizeAttribute.cs @@ -3,32 +3,28 @@ using System.Text; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; using Microsoft.Azure.WebJobs.Extensions.SignalRService; -using Microsoft.Azure.WebJobs.Host; namespace FunctionApp { - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] -#pragma warning disable CS0618 // Type or member is obsolete - internal class FunctionAuthorizeAttribute: FunctionInvocationFilterAttribute + /// + /// It's an example to demonstrate using SignalRFilterAttribute to implement an Authorization attribute. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + internal class FunctionAuthorizeAttribute: SignalRFilterAttribute { private const string AdminKey = "admin"; - public override Task OnExecutingAsync(FunctionExecutingContext executingContext, CancellationToken cancellationToken) + public override Task FilterAsync(InvocationContext invocationContext, CancellationToken cancellationToken) { - var invocationContext = executingContext.Arguments.FirstOrDefault().Value as InvocationContext; - if (invocationContext != null) + if (invocationContext.Claims.TryGetValue(AdminKey, out var value) && + bool.TryParse(value, out var isAdmin) && + isAdmin) { - if (invocationContext.Claims.TryGetValue(AdminKey, out var value) && - bool.TryParse(value, out var isAdmin) && - isAdmin) - { - return Task.CompletedTask; - } + return Task.CompletedTask; } - throw new InvalidOperationException(); + + throw new Exception($"{invocationContext.ConnectionId} doesn't have admin role"); } } -#pragma warning restore CS0618 // Type or member is obsolete } diff --git a/samples/bidirectional-chat/csharp/Function.cs b/samples/bidirectional-chat/csharp/Function.cs index 9fc67a89..1095b08d 100644 --- a/samples/bidirectional-chat/csharp/Function.cs +++ b/samples/bidirectional-chat/csharp/Function.cs @@ -12,25 +12,23 @@ namespace FunctionApp { public class SimpleChat : ServerlessHub { - private const string Hub = nameof(SimpleChat); private const string NewMessageTarget = "newMessage"; private const string NewConnectionTarget = "newConnection"; [FunctionName("negotiate")] - public static SignalRConnectionInfo GetSignalRInfo( - [HttpTrigger(AuthorizationLevel.Anonymous)] HttpRequest req, - [SignalRConnectionInfo(HubName = Hub, UserId = "{headers.x-ms-signalr-user-id}")] SignalRConnectionInfo connectionInfo) + public SignalRConnectionInfo Negotiate([HttpTrigger(AuthorizationLevel.Anonymous)]HttpRequest req) { - return connectionInfo; + return Negotiate(req.Headers["x-ms-signalr-user-id"], GetClaims(req.Headers["Authorization"])); } - [FunctionName(nameof(Connected))] - public async Task Connected([SignalRTrigger]InvocationContext invocationContext, ILogger logger) + [FunctionName(nameof(OnConnected))] + public async Task OnConnected([SignalRTrigger]InvocationContext invocationContext, ILogger logger) { await Clients.All.SendAsync(NewConnectionTarget, new NewConnection(invocationContext.ConnectionId)); logger.LogInformation($"{invocationContext.ConnectionId} has connected"); } + [FunctionAuthorize] [FunctionName(nameof(Broadcast))] public async Task Broadcast([SignalRTrigger]InvocationContext invocationContext, string message, ILogger logger) { @@ -80,8 +78,8 @@ public async Task LeaveUserFromGroup([SignalRTrigger]InvocationContext invocatio await UserGroups.RemoveFromGroupAsync(userName, groupName); } - [FunctionName(nameof(Disconnect))] - public void Disconnect([SignalRTrigger]InvocationContext invocationContext) + [FunctionName(nameof(OnDisconnected))] + public void OnDisconnected([SignalRTrigger]InvocationContext invocationContext) { } diff --git a/src/SignalRServiceExtension/Constants.cs b/src/SignalRServiceExtension/Constants.cs index 89d41ae9..e6564afa 100644 --- a/src/SignalRServiceExtension/Constants.cs +++ b/src/SignalRServiceExtension/Constants.cs @@ -18,6 +18,8 @@ internal static class Constants public const string AsrsSignature = AsrsHeaderPrefix + "Signature"; public const string JsonContentType = "application/json"; public const string MessagePackContentType = "application/x-msgpack"; + public const string OnConnected = "OnConnected"; + public const string OnDisconnected = "OnDisconnected"; } public static class Category diff --git a/src/SignalRServiceExtension/TriggerBindings/Executor/SignalRInvocationMethodExecutor.cs b/src/SignalRServiceExtension/TriggerBindings/Executor/SignalRInvocationMethodExecutor.cs index ccdc85d4..dfe1858d 100644 --- a/src/SignalRServiceExtension/TriggerBindings/Executor/SignalRInvocationMethodExecutor.cs +++ b/src/SignalRServiceExtension/TriggerBindings/Executor/SignalRInvocationMethodExecutor.cs @@ -47,8 +47,10 @@ public override async Task ExecuteAsync(HttpRequestMessage { if (!functionResult.Succeeded) { - // TODO: Consider more error details - completionMessage = CompletionMessage.WithError(message.InvocationId, "Execution failed"); + var errorMessage = functionResult.Exception?.InnerException?.Message ?? + functionResult.Exception?.Message ?? + "Method execution failed."; + completionMessage = CompletionMessage.WithError(message.InvocationId, errorMessage); response = new HttpResponseMessage(HttpStatusCode.OK); } else diff --git a/src/SignalRServiceExtension/TriggerBindings/InvocationContextExtensions.cs b/src/SignalRServiceExtension/TriggerBindings/InvocationContextExtensions.cs new file mode 100644 index 00000000..d048c291 --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/InvocationContextExtensions.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Azure.SignalR.Management; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + public static class InvocationContextExtensions + { + /// + /// Gets an object that can be used to invoke methods on the clients connected to this hub. + /// + public static async Task GetClientsAsync(this InvocationContext invocationContext) + { + return (await StaticServiceHubContextStore.Get().GetAsync(invocationContext.Hub)).Clients; + } + + /// + /// Get the group manager of this hub. + /// + public static async Task GetGroupsAsync(this InvocationContext invocationContext) + { + return (await StaticServiceHubContextStore.Get().GetAsync(invocationContext.Hub)).Groups; + } + + /// + /// Get the user group manager of this hub. + /// + public static async Task GetUserGroupManagerAsync(this InvocationContext invocationContext) + { + return (await StaticServiceHubContextStore.Get().GetAsync(invocationContext.Hub)).UserGroups; + } + } +} diff --git a/src/SignalRServiceExtension/TriggerBindings/ServerlessHub.cs b/src/SignalRServiceExtension/TriggerBindings/ServerlessHub.cs index 8010e098..e9587c95 100644 --- a/src/SignalRServiceExtension/TriggerBindings/ServerlessHub.cs +++ b/src/SignalRServiceExtension/TriggerBindings/ServerlessHub.cs @@ -2,6 +2,10 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; using Microsoft.AspNetCore.SignalR; using Microsoft.Azure.SignalR.Management; @@ -9,27 +13,77 @@ namespace Microsoft.Azure.WebJobs.Extensions.SignalRService { /// /// When a class derived from , - /// all the method in the are identified as using class based model. + /// all the methods in the class are identified as using class based model. + /// HubName is resolved from class name. + /// Event is resolved from method name. + /// Category is determined by the method name. Only OnConnected and OnDisconnected will + /// be considered as Connections and others will be Messages. + /// ParameterNames will be automatically resolved by all the parameters of the method in order, except the + /// parameter which belongs to a binding parameter, or has the type of or + /// , or marked by . + /// Note that MUST use parameterless constructor in class based model. /// public abstract class ServerlessHub : IDisposable { + private static readonly Lazy JwtSecurityTokenHandler = new Lazy(() => new JwtSecurityTokenHandler()); private bool _disposed; + private readonly IServiceManager _serviceManager; public ServerlessHub() { - var hubName = GetType().Name; - var hubContext = StaticServiceHubContextStore.Get().GetAsync(hubName).GetAwaiter().GetResult(); + HubName = GetType().Name; + var store = StaticServiceHubContextStore.Get(); + var hubContext = store.GetAsync(HubName).GetAwaiter().GetResult(); + _serviceManager = store.ServiceManager; Clients = hubContext.Clients; Groups = hubContext.Groups; UserGroups = hubContext.UserGroups; } + /// + /// Gets an object that can be used to invoke methods on the clients connected to this hub. + /// public IHubClients Clients { get; } + /// + /// Get the group manager of this hub. + /// public IGroupManager Groups { get; } + /// + /// Get the user group manager of this hub. + /// public IUserGroupManager UserGroups { get; } + /// + /// Get the hub name of this hub. + /// + public string HubName { get; } + + /// + /// Return a to finish a client negotiation. + /// + protected SignalRConnectionInfo Negotiate(string userId = null, IList claims = null, TimeSpan? lifeTime = null) + { + return new SignalRConnectionInfo + { + Url = _serviceManager.GetClientEndpoint(HubName), + AccessToken = _serviceManager.GenerateClientAccessToken(HubName, userId, claims, lifeTime) + }; + } + + /// + /// Get claim list from a JWT. + /// + protected IList GetClaims(string jwt) + { + if (jwt.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + jwt = jwt.Substring("Bearer ".Length).Trim(); + } + return JwtSecurityTokenHandler.Value.ReadJwtToken(jwt).Claims.ToList(); + } + /// /// Releases all resources currently used by this instance. /// diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRFilterAttribute.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRFilterAttribute.cs new file mode 100644 index 00000000..4a0e579a --- /dev/null +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRFilterAttribute.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)] +#pragma warning disable CS0618 // Type or member is obsolete + public abstract class SignalRFilterAttribute : FunctionInvocationFilterAttribute + { + public override Task OnExecutingAsync(FunctionExecutingContext executingContext, + CancellationToken cancellationToken) + { + if (executingContext.Arguments.FirstOrDefault().Value is InvocationContext invocationContext) + { + return FilterAsync(invocationContext, cancellationToken); + } + // Should not hit the Exception. + throw new InvalidOperationException($"{nameof(FunctionExceptionContext)} doesn't contain {nameof(InvocationContext)}."); + } + + /// + /// Executed before the Function method being executed. + /// Throwing exceptions can terminate the Function execution and response the invocation failure. + /// + public abstract Task FilterAsync(InvocationContext invocationContext, CancellationToken cancellationToken); + } +#pragma warning restore CS0618 // Type or member is obsolete +} diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs index 78e5df51..9d2b224c 100644 --- a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerAttribute.cs @@ -37,25 +37,25 @@ public SignalRTriggerAttribute(string hubName, string category, string @event, p /// The hub of request belongs to. /// [AutoResolve] - public string HubName { get; set; } + public string HubName { get; } /// /// The event of the request. /// [AutoResolve] - public string Event { get; set; } + public string Event { get; } /// /// Two optional value: connections and messages /// [AutoResolve] - public string Category { get; set; } + public string Category { get; } /// /// Used for messages category. All the name defined in will map to /// Arguments in InvocationMessage by order. And the name can be used in parameters of method /// directly. /// - public string[] ParameterNames { get; set; } + public string[] ParameterNames { get; } } } diff --git a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBindingProvider.cs b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBindingProvider.cs index bb4a7a29..fe5f4943 100644 --- a/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBindingProvider.cs +++ b/src/SignalRServiceExtension/TriggerBindings/SignalRTriggerBindingProvider.cs @@ -62,22 +62,29 @@ internal SignalRTriggerAttribute GetParameterResolvedAttribute(SignalRTriggerAtt if (declaredType != null && declaredType.IsSubclassOf(typeof(ServerlessHub))) { // Class based model + if (!string.IsNullOrEmpty(hubName) || + !string.IsNullOrEmpty(category) || + !string.IsNullOrEmpty(@event) || + parameterNames.Length != 0) + { + throw new ArgumentException($"{nameof(SignalRTriggerAttribute)} must use parameterless constructor in class based model."); + } parameterNamesFromAttribute = method.GetParameters().Where(IsLegalClassBasedParameter).Select(p => p.Name).ToArray(); - hubName = string.IsNullOrEmpty(hubName) ? declaredType.Name : hubName; - category = string.IsNullOrEmpty(category) ? GetCategoryFromMethodName(method.Name) : category; - @event = string.IsNullOrEmpty(@event) ? method.Name : @event; + hubName = declaredType.Name; + category = GetCategoryFromMethodName(method.Name); + @event = GetEventFromMethodName(method.Name, category); } else { parameterNamesFromAttribute = method.GetParameters(). Where(p => p.GetCustomAttribute(false) != null). Select(p => p.Name).ToArray(); - } - if (parameterNamesFromAttribute.Length != 0 && parameterNames.Length != 0) - { - throw new InvalidOperationException( - $"{nameof(SignalRTriggerAttribute)}.{nameof(SignalRTriggerAttribute.ParameterNames)} and {nameof(SignalRParameterAttribute)} can not be set in the same Function."); + if (parameterNamesFromAttribute.Length != 0 && parameterNames.Length != 0) + { + throw new InvalidOperationException( + $"{nameof(SignalRTriggerAttribute)}.{nameof(SignalRTriggerAttribute.ParameterNames)} and {nameof(SignalRParameterAttribute)} can not be set in the same Function."); + } } parameterNames = parameterNamesFromAttribute.Length != 0 @@ -125,8 +132,8 @@ private void ValidateSignalRTriggerAttributeBinding(SignalRTriggerAttribute attr private string GetCategoryFromMethodName(string name) { - if (string.Equals(name, Event.Connected, StringComparison.OrdinalIgnoreCase) || - string.Equals(name, Event.Disconnected, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(name, Constants.OnConnected, StringComparison.OrdinalIgnoreCase) || + string.Equals(name, Constants.OnDisconnected, StringComparison.OrdinalIgnoreCase)) { return Category.Connections; } @@ -134,6 +141,23 @@ private string GetCategoryFromMethodName(string name) return Category.Messages; } + private string GetEventFromMethodName(string name, string category) + { + if (category == Category.Connections) + { + if (string.Equals(name, Constants.OnConnected, StringComparison.OrdinalIgnoreCase)) + { + return Event.Connected; + } + if (string.Equals(name, Constants.OnDisconnected, StringComparison.OrdinalIgnoreCase)) + { + return Event.Disconnected; + } + } + + return name; + } + private void ValidateParameterNames(string[] parameterNames) { if (parameterNames == null || parameterNames.Length == 0) diff --git a/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerBindingProviderTests.cs b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerBindingProviderTests.cs index 7b639107..821cd1bd 100644 --- a/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerBindingProviderTests.cs +++ b/test/SignalRServiceExtension.Tests/Trigger/SignalRTriggerBindingProviderTests.cs @@ -42,18 +42,18 @@ public void ResolveConnectionAttributeParameterTest() { var bindingProvider = CreateBindingProvider(); var attribute = new SignalRTriggerAttribute(); - var parameter = typeof(TestConnectedServerlessHub).GetMethod(nameof(TestConnectedServerlessHub.Connected), BindingFlags.Instance | BindingFlags.NonPublic).GetParameters()[0]; + var parameter = typeof(TestConnectedServerlessHub).GetMethod(nameof(TestConnectedServerlessHub.OnConnected), BindingFlags.Instance | BindingFlags.NonPublic).GetParameters()[0]; var resolvedAttribute = bindingProvider.GetParameterResolvedAttribute(attribute, parameter); Assert.Equal(nameof(TestConnectedServerlessHub), resolvedAttribute.HubName); Assert.Equal(Category.Connections, resolvedAttribute.Category); - Assert.Equal(nameof(TestConnectedServerlessHub.Connected), resolvedAttribute.Event); + Assert.Equal(Event.Connected, resolvedAttribute.Event); Assert.Equal(new string[] { "arg0", "arg1" }, resolvedAttribute.ParameterNames); - parameter = typeof(TestConnectedServerlessHub).GetMethod(nameof(TestConnectedServerlessHub.Disconnected), BindingFlags.Instance | BindingFlags.NonPublic).GetParameters()[0]; + parameter = typeof(TestConnectedServerlessHub).GetMethod(nameof(TestConnectedServerlessHub.OnDisconnected), BindingFlags.Instance | BindingFlags.NonPublic).GetParameters()[0]; resolvedAttribute = bindingProvider.GetParameterResolvedAttribute(attribute, parameter); Assert.Equal(nameof(TestConnectedServerlessHub), resolvedAttribute.HubName); Assert.Equal(Category.Connections, resolvedAttribute.Category); - Assert.Equal(nameof(TestConnectedServerlessHub.Disconnected), resolvedAttribute.Event); + Assert.Equal(Event.Disconnected, resolvedAttribute.Event); Assert.Equal(new string[] { "arg0", "arg1" }, resolvedAttribute.ParameterNames); } @@ -74,7 +74,7 @@ public void ResolveNonServerlessHubAttributeParameterTest() public void ResolveAttributeParameterConflictTest() { var bindingProvider = CreateBindingProvider(); - var attribute = new SignalRTriggerAttribute(){ParameterNames = new string[] {"arg0"}}; + var attribute = new SignalRTriggerAttribute(string.Empty, string.Empty, String.Empty, new string[] {"arg0"}); var parameter = typeof(TestServerlessHub).GetMethod(nameof(TestServerlessHub.TestFunction), BindingFlags.Instance | BindingFlags.NonPublic).GetParameters()[0]; Assert.ThrowsAny(() => bindingProvider.GetParameterResolvedAttribute(attribute, parameter)); } @@ -111,11 +111,11 @@ internal void TestFunction([SignalRTrigger]InvocationContext context, public class TestConnectedServerlessHub : ServerlessHub { - internal void Connected([SignalRTrigger]InvocationContext context, string arg0, int arg1) + internal void OnConnected([SignalRTrigger]InvocationContext context, string arg0, int arg1) { } - internal void Disconnected([SignalRTrigger]InvocationContext context, string arg0, int arg1) + internal void OnDisconnected([SignalRTrigger]InvocationContext context, string arg0, int arg1) { } } diff --git a/version.props b/version.props index f7f08a79..4c20f4de 100644 --- a/version.props +++ b/version.props @@ -1,6 +1,6 @@ - 1.1.0 + 1.2.0 preview1 $(VersionPrefix) $(VersionPrefix)-$(VersionSuffix)-final From 4fc2b4a0eee5fe3abe0d35784a932a12199c39d0 Mon Sep 17 00:00:00 2001 From: Daniel Wertheim Date: Wed, 25 Mar 2020 04:13:34 +0100 Subject: [PATCH 18/21] Reduces number of accesses to Header lookup and ignores case of Bearer (#113) --- .../Auth/DefaultSecurityTokenValidator.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/SignalRServiceExtension/Auth/DefaultSecurityTokenValidator.cs b/src/SignalRServiceExtension/Auth/DefaultSecurityTokenValidator.cs index 1f3f293f..57805f0d 100644 --- a/src/SignalRServiceExtension/Auth/DefaultSecurityTokenValidator.cs +++ b/src/SignalRServiceExtension/Auth/DefaultSecurityTokenValidator.cs @@ -28,15 +28,16 @@ public SecurityTokenResult ValidateToken(HttpRequest request) { try { - // Gets the token from the Authorization header - if (request != null && - request.Headers.ContainsKey(AuthHeaderName) && - request.Headers[AuthHeaderName].ToString().StartsWith(BearerPrefix)) + if (request?.Headers.TryGetValue(AuthHeaderName, out var authHeader) == true) { - var token = request.Headers[AuthHeaderName].ToString().Substring(BearerPrefix.Length); - // Validates the token - var principal = handler.ValidateToken(token, tokenValidationParameters, out _); - return SecurityTokenResult.Success(principal); + var authHeaderValue = authHeader.ToString(); + if (authHeaderValue.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase)) + { + var token = authHeaderValue.Substring(BearerPrefix.Length); + var principal = handler.ValidateToken(token, tokenValidationParameters, out _); + + return SecurityTokenResult.Success(principal); + } } // token is null or whitespace From 7bd935b4472bc90b10f83247301357f8ff94828e Mon Sep 17 00:00:00 2001 From: Wanpeng Li Date: Tue, 21 Apr 2020 13:29:40 +0800 Subject: [PATCH 19/21] Fix JS (#119) * fix * split value provider * update --- .../{ => Common}/AttributeCloner.cs | 0 .../{ => Common}/BindingBase.cs | 0 .../{ => Common}/InputBindingProvider.cs | 16 +++--- .../SecurityTokenValidationInputBinding.cs | 4 +- .../SecurityTokenValidationValueProvider.cs | 34 +++++++++++++ .../SignalRConnectionInfoValueProvider.cs | 50 +++++++++++++++++++ .../SignalRConnectionInputBinding.cs | 15 +++--- .../SignalRValueProvider.cs | 31 ------------ 8 files changed, 101 insertions(+), 49 deletions(-) rename src/SignalRServiceExtension/Bindings/SignalRInputBindings/{ => Common}/AttributeCloner.cs (100%) rename src/SignalRServiceExtension/Bindings/SignalRInputBindings/{ => Common}/BindingBase.cs (100%) rename src/SignalRServiceExtension/Bindings/SignalRInputBindings/{ => Common}/InputBindingProvider.cs (70%) rename src/SignalRServiceExtension/Bindings/SignalRInputBindings/{ => SecurityTokenValidationInputBinding}/SecurityTokenValidationInputBinding.cs (86%) create mode 100644 src/SignalRServiceExtension/Bindings/SignalRInputBindings/SecurityTokenValidationInputBinding/SecurityTokenValidationValueProvider.cs create mode 100644 src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding/SignalRConnectionInfoValueProvider.cs rename src/SignalRServiceExtension/Bindings/SignalRInputBindings/{ => SignalRConnectionInputBinding}/SignalRConnectionInputBinding.cs (83%) delete mode 100644 src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRValueProvider.cs diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/AttributeCloner.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/Common/AttributeCloner.cs similarity index 100% rename from src/SignalRServiceExtension/Bindings/SignalRInputBindings/AttributeCloner.cs rename to src/SignalRServiceExtension/Bindings/SignalRInputBindings/Common/AttributeCloner.cs diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/BindingBase.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/Common/BindingBase.cs similarity index 100% rename from src/SignalRServiceExtension/Bindings/SignalRInputBindings/BindingBase.cs rename to src/SignalRServiceExtension/Bindings/SignalRInputBindings/Common/BindingBase.cs diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/InputBindingProvider.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/Common/InputBindingProvider.cs similarity index 70% rename from src/SignalRServiceExtension/Bindings/SignalRInputBindings/InputBindingProvider.cs rename to src/SignalRServiceExtension/Bindings/SignalRInputBindings/Common/InputBindingProvider.cs index 36b8002a..e6b5debd 100644 --- a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/InputBindingProvider.cs +++ b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/Common/InputBindingProvider.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using System; using System.Reflection; using System.Threading.Tasks; using Microsoft.Azure.WebJobs.Host.Bindings; @@ -29,15 +28,14 @@ public InputBindingProvider(IConfiguration configuration, INameResolver nameReso public Task TryCreateAsync(BindingProviderContext context) { var parameterInfo = context.Parameter; - foreach (var attr in parameterInfo.GetCustomAttributes()) + + if (parameterInfo.GetCustomAttribute() != null) + { + return Task.FromResult(new SignalRConnectionInputBinding(context, configuration, nameResolver, securityTokenValidator, signalRConnectionInfoConfigurer)); + } + if (parameterInfo.GetCustomAttribute() != null) { - switch (attr) - { - case SignalRConnectionInfoAttribute signalRConnectionInfoAttribute: - return Task.FromResult((IBinding)new SignalRConnectionInputBinding(context, configuration, nameResolver, securityTokenValidator, signalRConnectionInfoConfigurer)); - case SecurityTokenValidationAttribute securityTokenValidationAttribute: - return Task.FromResult((IBinding) new SecurityTokenValidationInputBinding(securityTokenValidator)); - } + return Task.FromResult(new SecurityTokenValidationInputBinding(securityTokenValidator)); } return Task.FromResult(null); } diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SecurityTokenValidationInputBinding.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SecurityTokenValidationInputBinding/SecurityTokenValidationInputBinding.cs similarity index 86% rename from src/SignalRServiceExtension/Bindings/SignalRInputBindings/SecurityTokenValidationInputBinding.cs rename to src/SignalRServiceExtension/Bindings/SignalRInputBindings/SecurityTokenValidationInputBinding/SecurityTokenValidationInputBinding.cs index d9620604..dba5088d 100644 --- a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SecurityTokenValidationInputBinding.cs +++ b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SecurityTokenValidationInputBinding/SecurityTokenValidationInputBinding.cs @@ -32,10 +32,10 @@ public Task BindAsync(object value, ValueBindingContext context) if (securityTokenValidator == null) { - return Task.FromResult((IValueProvider)new SignalRValueProvider(null)); + return Task.FromResult(new SecurityTokenValidationValueProvider(null, "")); } - return Task.FromResult((IValueProvider)new SignalRValueProvider(securityTokenValidator.ValidateToken(request))); + return Task.FromResult(new SecurityTokenValidationValueProvider(securityTokenValidator.ValidateToken(request), "")); } public Task BindAsync(BindingContext context) diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SecurityTokenValidationInputBinding/SecurityTokenValidationValueProvider.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SecurityTokenValidationInputBinding/SecurityTokenValidationValueProvider.cs new file mode 100644 index 00000000..37d3d10b --- /dev/null +++ b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SecurityTokenValidationInputBinding/SecurityTokenValidationValueProvider.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Bindings; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class SecurityTokenValidationValueProvider : IValueProvider + { + private SecurityTokenResult result; + private string invokeString; + + // todo: fix invoke string in another PR + public SecurityTokenValidationValueProvider(SecurityTokenResult result, string invokeString) + { + this.result= result; + this.invokeString = invokeString; + } + + public Task GetValueAsync() + { + return Task.FromResult(result); + } + + public string ToInvokeString() + { + return invokeString; + } + + public Type Type => typeof(SecurityTokenResult); + } +} diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding/SignalRConnectionInfoValueProvider.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding/SignalRConnectionInfoValueProvider.cs new file mode 100644 index 00000000..186008f2 --- /dev/null +++ b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding/SignalRConnectionInfoValueProvider.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Threading.Tasks; +using Microsoft.Azure.WebJobs.Host.Bindings; +using Newtonsoft.Json.Linq; + +namespace Microsoft.Azure.WebJobs.Extensions.SignalRService +{ + internal class SignalRConnectionInfoValueProvider : IValueProvider + { + private SignalRConnectionInfo info; + private string invokeString; + + // todo: fix invoke string in another PR + public SignalRConnectionInfoValueProvider(SignalRConnectionInfo info, Type type, string invokeString) + { + this.info = info; + this.invokeString = invokeString; + this.Type = type; + } + + public Task GetValueAsync() + { + return Task.FromResult(GetUserTypeInfo()); + } + + public string ToInvokeString() + { + return invokeString; + } + + public Type Type { get; } + + private object GetUserTypeInfo() + { + if (Type == typeof(JObject)) + { + return JObject.FromObject(info); + } + if (Type == typeof(string)) + { + return JObject.FromObject(info).ToString(); + } + + return info; + } + } +} diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding/SignalRConnectionInputBinding.cs similarity index 83% rename from src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding.cs rename to src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding/SignalRConnectionInputBinding.cs index 157c81f6..de484cf5 100644 --- a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding.cs +++ b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRConnectionInputBinding/SignalRConnectionInputBinding.cs @@ -3,11 +3,9 @@ using System; using System.Collections.Generic; -using System.Reflection; +using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.Azure.WebJobs.Host.Bindings; -using Microsoft.Azure.WebJobs.Host.Protocols; -using System.Threading.Tasks; using Microsoft.Extensions.Configuration; namespace Microsoft.Azure.WebJobs.Extensions.SignalRService @@ -17,6 +15,7 @@ internal class SignalRConnectionInputBinding : BindingBase BuildAsync(SignalRConnectionInfoAttribute attrResolved, IReadOnlyDictionary bindingData) { var azureSignalRClient = Utils.GetAzureSignalRClient(attrResolved.ConnectionStringSetting, attrResolved.HubName); + if (!bindingData.ContainsKey(HttpRequestName) || securityTokenValidator == null) { var info = azureSignalRClient.GetClientConnectionInfo(attrResolved.UserId, attrResolved.IdToken, attrResolved.ClaimTypeList); - return Task.FromResult((IValueProvider)new SignalRValueProvider(info)); + return Task.FromResult(new SignalRConnectionInfoValueProvider(info, userType, "")); } var request = bindingData[HttpRequestName] as HttpRequest; @@ -46,14 +47,14 @@ protected override Task BuildAsync(SignalRConnectionInfoAttribut if (tokenResult.Status != SecurityTokenStatus.Valid) { - return Task.FromResult((IValueProvider)new SignalRValueProvider(null)); + return Task.FromResult(new SignalRConnectionInfoValueProvider(null, userType, "")); } if (signalRConnectionInfoConfigurer == null) { var info = azureSignalRClient.GetClientConnectionInfo(attrResolved.UserId, attrResolved.IdToken, attrResolved.ClaimTypeList); - return Task.FromResult((IValueProvider)new SignalRValueProvider(info)); + return Task.FromResult(new SignalRConnectionInfoValueProvider(info, userType, "")); } var signalRConnectionDetail = new SignalRConnectionDetail @@ -64,7 +65,7 @@ protected override Task BuildAsync(SignalRConnectionInfoAttribut signalRConnectionInfoConfigurer.Configure(tokenResult, request, signalRConnectionDetail); var customizedInfo = azureSignalRClient.GetClientConnectionInfo(signalRConnectionDetail.UserId, signalRConnectionDetail.Claims); - return Task.FromResult((IValueProvider)new SignalRValueProvider(customizedInfo)); + return Task.FromResult(new SignalRConnectionInfoValueProvider(customizedInfo, userType, "")); } } } \ No newline at end of file diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRValueProvider.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRValueProvider.cs deleted file mode 100644 index 8e317641..00000000 --- a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/SignalRValueProvider.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using System; -using System.Threading.Tasks; -using Microsoft.Azure.WebJobs.Host.Bindings; - -namespace Microsoft.Azure.WebJobs.Extensions.SignalRService -{ - internal class SignalRValueProvider : IValueProvider - { - private object value; - - public SignalRValueProvider(object value) - { - this.value = value; - } - - public Task GetValueAsync() - { - return Task.FromResult(value); - } - - public string ToInvokeString() - { - return value?.ToString(); - } - - public Type Type { get; } - } -} From 6e0081783ef03b7731503ace13f52c7ebdb8f84c Mon Sep 17 00:00:00 2001 From: Chenyang Liu Date: Mon, 11 May 2020 14:35:06 +0800 Subject: [PATCH 20/21] Add README for bidirectional sample (#120) * Add readme for bidirectional sample * Minor updates * Update * Fix typo --- samples/bidirectional-chat/README.md | 116 ++++++++++++++++++ samples/bidirectional-chat/chatroom.png | Bin 0 -> 26382 bytes samples/bidirectional-chat/cors.png | Bin 0 -> 104901 bytes .../{FunctionApp.csproj => extensions.csproj} | 0 samples/bidirectional-chat/getkeys.png | Bin 0 -> 41124 bytes 5 files changed, 116 insertions(+) create mode 100644 samples/bidirectional-chat/README.md create mode 100644 samples/bidirectional-chat/chatroom.png create mode 100644 samples/bidirectional-chat/cors.png rename samples/bidirectional-chat/csharp/{FunctionApp.csproj => extensions.csproj} (100%) create mode 100644 samples/bidirectional-chat/getkeys.png diff --git a/samples/bidirectional-chat/README.md b/samples/bidirectional-chat/README.md new file mode 100644 index 00000000..c21e0e36 --- /dev/null +++ b/samples/bidirectional-chat/README.md @@ -0,0 +1,116 @@ +# Azure function bidirectional chatroom sample + +This is a chatroom sample that demonstrates bidirectional message pushing between Azure SignalR Service and Azure Function in serverless scenario. It leverages the **upstream** provided by Azure SignalR Service that features proxying messages from client to upstream endpoints in serverless scenario. Azure Functions with SignalR trigger binding allows you to write code to receive and push messages in several languages, including JavaScript, Python, C#, etc. + +- [Prerequisites](#prerequisites) +- [Run sample in Azure](#run-sample-in-azure) + + + +## Prerequisites + +The following softwares are required to build this tutorial. +* [.NET SDK](https://dotnet.microsoft.com/download) (Version 3.1, required for Functions extensions) +* [Azure Functions Core Tools](https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=windows%2Ccsharp%2Cbash#install-the-azure-functions-core-tools) (Version 3) +* [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest) + + + +## Run sample in Azure + +It's a quick try of this sample. You will create an Azure SignalR Service and an Azure Function app to host sample. And you will launch chatroom locally but connecting to Azure SignalR Service and Azure Function. + +### Create Azure SignalR Service + +1. Create Azure SignalR Service using `az cli` + + ```bash + az signalr create -n -g --service-mode Serverless --sku Free_F1 + ``` + + For more details about creating Azure SignalR Service, see the [tutorial](https://docs.microsoft.com/en-us/azure/azure-signalr/signalr-quickstart-azure-functions-javascript#create-an-azure-signalr-service-instance). + +### Deploy project to Azure Function + +1. Deploy with Azure Functions Core Tools + 1. [Install Azure Functions Core Tools](https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=windows%2Ccsharp%2Cbash#install-the-azure-functions-core-tools) + 2. [Create Azure Function App](https://docs.microsoft.com/en-us/azure/azure-functions/scripts/functions-cli-create-serverless#sample-script) (code snippet shown below) + + ```bash + #!/bin/bash + + # Function app and storage account names must be unique. + storageName=mystorageaccount$RANDOM + functionAppName=myserverlessfunc$RANDOM + region=westeurope + + # Create a resource group. + az group create --name myResourceGroup --location $region + + # Create an Azure storage account in the resource group. + az storage account create \ + --name $storageName \ + --location $region \ + --resource-group myResourceGroup \ + --sku Standard_LRS + + # Create a serverless function app in the resource group. + az functionapp create \ + --name $functionAppName \ + --storage-account $storageName \ + --consumption-plan-location $region \ + --resource-group myResourceGroup \ + --functions-version 3 + ``` + + 3. Renaming `local.settings.sample.json` to `local.settings.json` + 4. Publish the sample to the Azure Function you created before. + + ```bash + cd /bidirectional-chat/csharp + // If prompted function app version, use --force + func azure functionapp publish + ``` + +2. Update application settings + + ```bash + az functionapp config appsettings set --resource-group --name --setting AzureSignalRConnectionString="" + ``` + +3. Update Azure SignalR Service Upstream settings + + Open the Azure Portal and nevigate to the Function App created before. Find `signalr_extension` key in the **App keys** blade. + + ![Overview with auth](getkeys.png) + + Copy the `signalr_extensions` value and use `az resource` command to set the upstream setting. + + ```bash + az resource update --ids --set properties.upstream.templates="[{'UrlTemplate': '/runtime/webhooks/signalr?code=', 'EventPattern': '*', 'HubPattern': '*', 'CategoryPattern': '*'}]" + ``` + +### Use a chat sample website to test end to end + +1. Enable function app cross origin resource sharing (CORS) + + Although there is a CORS setting in local.settings.json, it is not propagated to the function app in Azure. You need to set it separately. + + 1. Open the function app in the Azure Portal. + 2. In the left blade, select **CORS** blade. + 3. In the **Allowed Origins** section, add `http://127.0.0.1:5500` (It is the local web server's url). + 4. In order for the SignalR JavaScript SDK call your function app from a browser, support for credentials in CORS must be enabled. Select the **Enable Access-Control-Allow-Credentials** checkbox. + 5. Click **Save** to persist the CORS settings. + ![CORS](cors.png) + +2. Install [Live Server](https://marketplace.visualstudio.com/items?itemName=ritwickdey.LiveServer) for your VS Code, that can serve web pages locally +3. Open `bidirectional-chat/content/index.html` and edit base url + + ```js + window.apiBaseUrl = ''; + ``` + +4. With **index.html** open, start Live Server by opening the VS Code command palette (**F1**) and selecting **Live Server: Open with Live Server**. Live Server will open the application in a browser. + +5. Try send messages by entering them into the main chat box. + ![Chatroom](chatroom.png) diff --git a/samples/bidirectional-chat/chatroom.png b/samples/bidirectional-chat/chatroom.png new file mode 100644 index 0000000000000000000000000000000000000000..6e6071bdf8205436aba7504c0e073a91ff8ca0b3 GIT binary patch literal 26382 zcmeFZXH-+$w>ECahNv7wK;RrvP!W+D=_&|P0xC_qigc1t5|D%h%MnBb4k9g~_ZE;6 zN+3iJ3er0PLIQ}igb;xwln{91(et~X?sz}mf4OUn4A#!rd+oL6oSAFRXFiL_JLX1$ zhs6)?+Ox@ORCnnoyLP$f7#rNMdg9EW2!;MOMA+G>&5q7|Q2%`Y z%i?D%pr_sjgxy{;k9P0u!-aQXr~1hkc#$rkihGtgzt2&(j(nEKQolBP+Hat19H-e9 z_P6XmRlmA>uxL-k!HUF8&-b2pu40oXG#XUmt1y(VSt0%3%jc$*+7=fVw>0KDQ$~AI zbpj3I|KQKW_u}Xn)2`s{ZWl`W?EjVE3`or{jZ%c&*%^W<@C8Hnc37xqTt9sTeRts_r;QXTBB=>Nms83kcw)Cpb_THLUWOEv zZgAkSPYW!B#w7fjGCy#DY-TzC*9vesDOOz-=%g!D<+|kv}<1^*rgPkxoFsP z3eMgM9#KZPY-`sXExqvKliIO&U=K}m2@3B+Bv^P^|Kx=o0s4Qdohqzs!FZq zJ$;`xMEWAGfg4@sG*&&*!-(p6_fZC09`5gos0jI%Z9|ZYOaA9uFRAbxea_1be9*&` z;-F8}Iqd$@DU{@uq{d9>&%**-1ME*_7p{`x61B2Ux)pA==OKyoS$RWxqx>ALsa2l9HdGrN_Pn+12-FCxmYuMRyLK?; z6Q?qzUBNM1#QAqzR*gAxDf&&#C}*V}ovL!z5?t?MQC0+d{y`S0US_qduwPSA&gICZ z#$91YqQ~qtO-DWEyzC!~sTBv;^@L7avI~ZpS31)>7(U;<3;?bq1>=%UGp6be4L*jM27IU18V(?zBz3I6VNi z%XO3QLLKP1+8BV!Fy@}I?9LdU0t)<;=CQX|NOP>osKYQXMdmF7?CbK*p=$0W@^8L^ zi{%5aHEg}x|85_4XkT(JBIomeSJiuhrF|@y%_xnRjITBrCAJ_bpaaeYGa_o#CwsU) z)th%~6P`TVhTVifw2S?xdFTMUxI|L*sq2uz{I@CSnmSGK3y{yPYHM_w!=-*SK&^_( zJ)*kYq73p@cu%M`{H)THGTEY^Q=ItC=WAmE!*g^zuBYZM@+G+8tOY=^eBn=Md`)6k z|2LbOhN*?K$TgBN%J@B^DqY?p(7{ZDI7Pcv>^;}@Yjv3_(E7>hP4ix%*_jr$3c<)H z>9lLBMx1AhX_>}q*WSQ6s2fGtW(>)Y+yLbe$V>1xxTvP) z)Iq?i!j9c)fUua-6h?e1+JS8Adz)$>9A%d-{98}5?hV&*gAu~LiJjW>UAsR!+o9ne zDaAgGysP?b*HtyO0mQ_Jy<_2i5$t)J92-4+^)&WXba=s>_iN$fJzv^MPO;YN1u$S< zI$p&^pRgtakScS|9RmNw>QYvy>oK;i%1~Ll5h?n4= zY?>)VwQWfLUuQ6ir+n0!3kDUWrZ5(Tl$N zsLno?dyuSj+toc`KI>*dLLmFBLOG`;euUH8gYzv{Rh_sT7%h4*lx?S@+7t_jh)XFc z0xO=E=)iG+5bf^#!?mWdM}fp^t%rI6-VDMmxbnfT?%pjg^6>I{rbzgc_&2&Tt1!}w zRkdyeNUwHrYGshO7s9Q7our1oHFiDVrMK_zfRmk_L(A05US6exUme;p4omK*XmKvx zq`^3II#Y_E_IEDnMesMc@EbxNe0^bdNrwio`UHKZ^l11j1)U^Tl|hTk+D_3x>y}^7 zX*9Osd$&^FyTQCHW7npYzYrX?~8 zMMtb1)3zQ4V%o!&_Q_Yl=n5O+iw7?46%$`A2g}bHDsWwC^DK@eEbhS?f7w0;VT$*Y zlx|NSmc52G>7sW~=ApQBhor)c8FB1(!oX|xw-kIQZ)7O}K62nM9kGEqZ;qYuoWv<}>7Pz@{aw^5fuKg+UF zH0Dj8t+$~WU=arADIRLyda6y)lsDoDCo&7Sg;!uj0I7;%+5A|3p!%czZsw$ZO;YK1 zBT|A@apmL07Qx%cx5!RK`z-@uLzQE*ry8Es>82XZdYL_+beVkjhxl}~eRMh4l=AhO z+xv!5zB5#9lc=@P+rJFH>Z8`p>@(&HwY?s#1)75UpIgJc z&1Amw9b0}$ML?eLpMWdqB6>XK!fl7Ld~?4|*GQ$?GA%7vhz}th{ppFiJXkj1M+DDr zXMCNst}zz$IN~~16*m%ysW}Uy%L+vjI5?`QOd$$!$C0Ow4VLz;TRsLnqwbi}7G8Q& z1W{#JT4G8mRt30-<{u8?Zl)JRU0G?tzBaM`tiuM=(aNBe7Emw38|<4IKtBceF0X*` zcmIm~9=HHx;Dtqnq8-&oLCUAsi|g&}JflSWYA!)!3B52N-7L)&`A zOOmDA)aJcM`hD$;Y=6xHIF|vn?(K8eS{YRZVT-=EWM3hC*Vd4%uO2uq2<$UKe*b;V zU0<97D1PXHEgrU_we_F35A}%4cZw^I)YxC)cIM}*ue~}JIayu4W49lV45>cPPHv)p zdS!@hnmML0u|TNi1YbQ~zs98AKG__eyK$$EDjxhW9RNbP(^tsaukjC5bJ<{0Jlr) zsN)FXZ=ZyPp;`kr^(hrRlUh=@iHNAk1ueRn+uW>|$-^vgaxig8{vv5CT5SwZ#t8kK z_57Y=MU&M%HMywV(5x%b3j=jG*L0@#g-o+)1t(;x#;&qfb(2SuEiH*mnu@Mse(a2# zJU}&Ae`Pib{wN0kHp7QLD!IYbO^K`lZ1_ET+ms<==#ek>7!pMlM?cVdE5w5}l66cn z&UB$lZ(2~Ck~6iE42R0?vrDdQ6_+Uhd(%vfbP)&1+v}Iz=1zHkoF01ZldQ<@dXu}F z`@AtoZ{NvZ^Yk@R6{D{IQPAX@9{~2p+kHtZZ~waG`5xwxQr4bUG86|mx<&|k(3Ito-o-L0K#5y;Y!-0(fQ z9HK;cqK`@H8`k#3Ee<7mx4#Tl)!V9q#JK;~)z4bihd#=n_C+`v=B8NCMK7+lpJcvN z%CW-TQnGOSHyv2~%U6Rz52GE2TzSsCGYp=*QcAXvlwg;!Eyw2YJzu_LC| zgfg>Qmv2EieT8m9NpfL+ZbrHogHY#E6mD2T>xnvcDb_%?DEhumq4B30N7}odhKnmSDAuq8c*V+C^P$xG2vEe* zJX;Ks>zV;{2z6e>_O6xNW#bey;hf~qLOgNMYcw=-sOU=H3!ycV*9JwePnPM1X>0^K z5bpk5RAv>l4Xh*X(~isZ%dYJ18o;fZREJvU4VOh8EBTRubUMyB21VE~*}4zd?E~zP z3ZS8iq3HZ@-Sw|sNi+2DuZKaD`T~u0LU3#isN7q)x8ar;v>1XCfh^K zj3v|_tFv5zA)ht8#9i8Mu~h@{b22KlAMYPa^O47ePMOVoo2AzX`eOrv%LlE617Jf) z*;tzEgimRC-%7|4TqgqL_APe!Af!=014#ZINJQG0B`HO#t@)rwlO$eu;Mx{_n!#F& z{lnJh@l&rIfp(3}nAPj^1zFAGP?sX+&cu0pZhq}f%>-lYu)=n28$QGut&l13*!vAY zyuv>Y?0m0?5yvYYpv{PYmX7^^hWPCj>9f?UI@1o1Z#4Hgv51AX!8b063&T~9#y%S| zE|74xzj@ut9?oq$_hgr2tJJIcl0M|zT!~w$vO{h89xr9Yvh z;ZmYEZ5J~ScAbCor~~%1?I~IlnpJxhWv3t&kYZBqNLlT?=R{-h4Vr92&PXpQ@kq!U z&Aztu@kJW%`X@A3ZMpJ1*4H$AWBiOD!3``fR8~4rStJZ|sFXgQYw7-T+D~nu6^R34 z;;9K(U1|xG)pn)VX~uZZz;Ij0rd>#+dn`;kJn80yFHmoQp;xcs=GAX~XmhEq@Oo0- zQ8NcIakUaF3rb4)i5n9`MUM-2*3U%G(+NlQ%YD1H-BOm&Xpw<-ts-ZhDtPg``b@2& zjJ%5;7s!3yA-Pcs`F?AaJ->ocl}%$Y$mu%1FG?Xa;@oWh?bV~vb!7&69Tv;G`cA)4 zWe?kVC93?KZzd9d-w$8g(s{+2T_OUndE5k6=p0Cnv!gK&v~9Y3fBJF=?X2n&t8H0C zn+a4^%XfIOZ*y)Q|JKBMJD{noLEA`1nQ|Ql5mcimTlvK=UAkz?K9Yw=-gIyBx zpsdD@QMT5tvZ@s|>wQ&(E$r;{N^4NgmW>nU}B>o%ZdloSJ*5d3AE zABH~T*+1pCYZp|&Y313gUsq3?CH@!r+qLWeF;FO827MH=l?kU~Ya5k*>FC2pXuq*s zh^v5k(xLxAvv{HZ(&+!;$oBN=xFM@L1krSRqw$IPNTmywIV-bcUjgXBru5Ew#FeJ?N0Nxe3hLmX?&iDNAo?&L1cYPFyG+ukTM2 zfnOkI04gL#97<-6%z57M!imG3a*Rqmyxy(Z%oPtmf4F~ajM6Wfmi=(XafVuLH?bK~ zRMye`JwIswJD-1E>NihUrocx|4S)`rIRrU##kiw3YJ>A_=Wn5d9>yp~2nvNSO4t@t zF?saLXN9vMb9G1Snhp$cukU;9kCj!5ouumQ^Y%?m!oyiQ_DXUt0KF?fVuOK@h@h=B zGfn>nZW>WHt0*A zpamdlxur$_g1)d+Lyd2D|MM05c-Nm2ax&%HAGu6>>|`=$w5NJZ@XIkWKbir&8!p-j zQGt>APf}Ev;!d{71z8aWU)*2iC%&7Hdr)T_-#K{V<&iY6^LP}LIlq{e88T`Lv+Ywu z>%#vMciyT8H!0iN*;31kV?Hg?GDzhe5^CTj3so&eBYqm59D2~UdGjRY!CEsSEs>yh z=}PC_?u6j^NTQ$;S;uJY-Q__BNyO8v#a*Y~FaK2O;+sj{9}m_{cz9LY_$zM{c{MA4 z9SH?1Vv15bURN(F^3;|bR@ryXNtgG%>4+GqLu7_gPg>h1!76M9ia!5Xb5r40iB;7Q zW3}Dh|E#+=5Bfsj>{8$B&w)7j^(-mmKNxsn(R3ix>*M9FCbujpf|do3pwUo971vZZ za(zB!Ca7-76_Q|>b@Ua+Ybib*ZsgU@Wr5SIe?aeEo23|N&x(n) zo|4O)lnL;I)&uT9o_(CwjapRBb$j=gUO_ zo0dk7D9_;d6NtsJO1mth!vbD(q$w_&zGLx@k>^7PeHVXO z+6}-#^R`Z zO-H4_)ra%4%E~eA+8tY>Y*4+eLTN9jdpnIlNng2z=O{yT1TuR@b%l?>cy0Z!tragd zG5l%OtDD_TmFHH9*G(Fb?)UFYhJbGM7hbg(-L7#7iAaP*QdWLv5J+7?UK?*lW^j}2 z4oslw%aFU-7vtE~D=8Ft_6H}@Wf!VI5l^&D7)pWGyV%}p|6=wdB?pb5VnI7Y z8l!!TFYR3<+>s6(tQ_#7z&k4>%+T)nb{`K`M~S>PK$Ew2w3<}VGZHR$$dz;Luexh( zy7DeX#neKRoY3Dgk*utHBPo_C$Nth^ckxB_*Qo+~s^5L$1$51!2o>0B`@DaDJ~Ha^ zK<-qGlRaXha~@TnL4o9pitcZ9wE;f^UnsL8AQsoOG*-TM6?ftKNJZ=PC=1LfdP>p$ z653hH%TGNM!}zmJW+NL*h>91K&s)vgZT*u?akjs7a+9wUq5eUpsJACu5*_;atd1Q9 z|FS^v$_#2+u2>71h;rwG2#Eaf?F~7!de(&-r2T?$_hkTVHQdZ8Z0?>;B{b&Bpou~< zJUWrE(DgO)a_x7yG4=O%^eQ_D<+taS9JAX6! zoiQJtV7J*9C}Gv$R+NA2mH7l8G?;O=$(=zM`EV$GjP*W1cwt%fZDF7t_VtlFwRX*` z_E%lA)Q`#c>a3;AV75+IVhsSvEXSs1Oo3!Es=* z*nF(k<$BqZr=>J=3~DVrA<_%c@Q2GlxGepcwGG4u7z=`A1wiaaLuF>(g;F$SEUi~p)i%0UYVr*z^6(#RKi!*C z6m{X!rInVkD``Eb_fuK<&%?UCH5o@nfjgmH{rPh>YEPKgsHp0q??>!_FNxG8;P+70 zqXD5-H?^57ltrMYH+-$nr?avWExYBZKhR38eg%qEoxvlvU3sEbls*+{XdZ|#}QM}VZX$e58PI;q%tQut<-(&Q|Dng~!SwH%LpgaUS^oWNx%={>mxsA=cm0E~?F| zKqBe^%5uuK64Uf#;qUx8d)cf1Xz;_0Awhd{BX--BK^a9WT2aFhRcP}`C)mR>exdHf zYTyo5-V|rQf3wByMdQVEXARA;>}uXjm%o$HH_w*pI+Tw}&)|j}CS+%L{7fL?R=wTQ zKBpaQB62*YuJMj*<8n;VkV>r8ZPBhlwg+y}IQaXFiW$%dy7sV%fwL@%1_Z0 zzw~kh(t*7&V~O8>o3C64--ge4({Qq}?IpM(Nug@r`_}nx2)X&}3UJBXf&^meGl z$K%!tDMgX>8}@hl>@~=FbLr**sqFS)R!Kmk=yHFFyy}DJ`ExC72dYn<;S73*OtaSu ze*@%4XEyge!&C4fQ&ryU;iEmR;QqR@oo_!KWp5W0GYK`|za}o&QuVfuqAl6fy=7~h zI0Ygr-Y6>Dyyy6#@92OppiKnl&!Zh9^`1f=U$YG(>;EZyyLiVmPgU8dS(v>x)*P;* zrT?M^^3E_-#}V0hQ3-yuzqcx{1)04Ew;)PeohNLP$GXeh~y2ji##=9m+LiqIyqJA27r8}e?WDq`|uEWv~&z@qN9oU zOI@MSuoi1VNjve=u0Qq?o)73sn1Dmi^rn#5f+R)u6#s;*mRq3DrbP~*=zSVcQRDe z?kB*(`k_zDYr|u@`jWp~wb$Qi!<>=JX;q@$ue(=X8yf@hq0ay^`kO5{0ydh> zFM~bfLwxK~25PGyVbMzs@Ur2U^m6H$R$M7WH1OPKoWYu4TGQ1VqpRV+@m`le67wgV z@*4sq*x&x{|I(D(FE_11y+2pU`^$r0t{)R99_pn(c6wqR2POn&l}RwVdQa8B*7+sk z^hTo325?oFmGTo(_w$4GOjdjNnU@(BnM*$rO7FOa`m6dDEwDz~?~SywaIFdW*5i7` zVNZu_!#sq9R@V8dA)^;YCYWImSnNzShvY@qtm^SN7OAUUM)5r!(v-;P8iH+fImtt> zF@h55RIaz`Y)paGM#oD$%qA;829VJq_4ZpJ*}moyhf^`&=9Dcq+W#7n6twln%XHnn zz%fwi;Ox2G%?$FTy!f8M-H6LJ zvtH6aHyxhgVH=2jlf9ljSHqc`e1y{#*r@O~@h z;eC0b2aEg)Y6}KA()XZ7v;K{mztGKTR1KeQ6{sZtW71qr$0bv-TO~4SC=0 zB`$H=4_jY!ctN=0Z1kp!^dNyYC3kW^dkpevRQO$-XFYe#lieI;56VkPH;cwRdhx;( z#@tN&qmU7+I8;eUL8B!mY#|bh6L~n(!)n+k5iJxABTIoN1JTC{DBYS^wViO3ZjBpg z8l7N2%Xed}+EpuWBybU>UUm_{ohRHjeZDLB<_lFG$4=bdA=}G#qP+OezMUE|G^YHn zeGx*s5G;0e{BAzNR6>nKuNR*JzTQ{0X5(#%1$?Z6xuw`3rkBM{J8+sP2Omad*Jheq zfm>L#jkcAH$TuH&_J?vYSzwzA*9t2)teW(!MKmm%dCF7;wIvyC+<&h@hcEiXi#z#_ z?-FT|BTKyw#aD)qbYW;<#l*Xh)B4Sy?KtRoTBmTH+=K!@9l<(lp}LHd;>V z)aT#xVcc;p-hXR99zp=PIHXw@N=Ns+x*Xh|=4KB{sxLM>;_jFofe?ZA?>&+CaR+}V zIC>)Mt9+muD62MIZRS!97aql61{al?s;D~=XFkt2l^6A{d_5n>>(S`AT$_70iNbp) z^_rQUmE)D9W^aiw{=Dj5H1aKuw-~=Ba%pY(K=i8nvfHuAz6d_p%kHr>6VaF;aLe5(r$7pmcS6lnb!PDl@S=V0=e(KOsu}y5 z$8q3fxN)`1WKRELW3)3{ycMRK?tgY)>)yr#m{jHqr%J95Dh*!1<7wc9CL*}`@u$aG!XZML&N zUSV?(JiPuWWr^|!P&z|c;u%q;v%+e+dlHAQT<~@- z>#Abf_}oF@*?vdU>>tOZ*i(X6d&unH&3?tEQ?F#3CG_T{!nXXat*whHlnBGfn$OjO zW-dSQoOhqZz2#Y-n%(T1if>|q&~9t|8l$V6xA#Xgpfp_#!(mM10BhLGgjnxeSdddn z*0$ZKxJvzQd>J7VmKU=N@h+=gO=5D2+k;wG$1K8voB+}xdWHuuGezs}a@b@lpT+p^B z&>^ur!Z|I+|KeAsV2vJ4Lf(vOGhb>H2m{%zxQ=%1RIe%A3#wTtd!47Gu(nrF4IWT* z@r9dw&PNEMG(*Ea3AhuJA1nQvU@0a5&)z7gE-f|dDJ@dkknU>spI&80r|cJ>z5*EC zG~1AOt~L*KhJUx0%AKWiG{t3~+0+>`wwN{vv<0-cv(sMpCcr7Imtx zuH5MidD&iHX+ZA58}G2YR5LVxQXP7`_=MZ1HK+f|QyMFkOYH2g{`;m%=MX_+)038# zSG*qfQatYKv$&sA@NC(8;y(=EoSFYY;xFX{FdRTVzqtJ%Hi*aAi>zk4!YY8jxW!MK zyH6OM$#O+V<)C(;jhj*vWDI-~R>UW`1_v!P$8r_|d7H%+{6}()|AWvzTlKPx7w3fL z1_v>Q{aZPMZ5i}ALf`{f%49W2iMNDc(vK7fp1trdt@)3t8#K~H;XtHZsQSB8gHHK9 zUgU%bkP`60zf{$~JzT5Q4Sig359gIXdXZbJ8SVd)pZ)wsPGh5o!n1y$!pyVP=wEc| zr!QJtJez5U@ci%p6*NWQf4GWrjZX=uSg;w{E(;dapWyPYT~0keG=RDT8e!qP&n)j} zS|faI{Ka>-?G`3tr9*MVwZjoI5~b3^cPp!HsNt?1xgBP_f0erOZ#Ljbj!P7(sXG}6CDA;ftN|nWy=Lp(_IgdrGV}4 zb&2KFY`l(pNZf`wi74t^b^?W8MBUnP9bOA?LOv$4Q<^kEf0JO-A$xO}5`yGm&ke}1 z=c4@6W z;*{ph7eKW;TiUA6@3m|))OeOAe)%2-SjzGm5uMTXs|o z?OG8kWDqx!QX?g>KgOqfd;{9&|BBh}PJ*W)kcvnueJDW>#%m!5RVA(irB zR^uvg+lXqWMfvHwo|cuZy5b^XRg=|y%qPe?aGLs}rnyV^Mx*ExRb5K9NJj~aDki!x zVNpw-+AlNl#7IXj%mQF`rugGt-GcZ7X~lQvj#*D{Cb7QCOMA_4xL7?dnLs+5CK9rz zn>InbR*_~xNW`VN42xa|#il}>H13pDG?*#Tuz87@2=V;8h&oplGZ-$aPO~U)!VYXR zwD7dlcv%0_XYJx_4FYjigs?f~f!SLaHc{$w8EMW$`o9M6>@;OR43any>g*z}+7Fl|D~Rh5 zE6ggG^V|~U7PrPZ_ip3_v#6QcPQe=snq3aI9J^K@GA$a;j^DB z%|ViY&C6*gA3K`%O|p)ug)SYY*j$svgVYsNcFK~R$Fc!2p50ONHIgO96KQ$n8M;?1 z%RV+&!ob8}R;!*>(H{pdBW9);t_fU5Io+3>Rjs&>*N8_> zSo91e=zQwNQyMeErwV-DkRhKVQ^dgz8)J;q3%;#!&8nT3ZJCXdm)E4rtrje}F$2l| z1+76y|HolzNVv;_B4j@L%b!%5YtlxGJC-b*ava9@yJPYvkTJDL#Nu$iEp=EgJN|5p zZhJ`Pw@8gm`_QoSIs!h)AmG*#7}7vv7tr8dy&-5Cm^c(zx6<+nxy>y1WXMaG^o+W~ zf*f}w_nJ$W30FHgP+79MgSPc9l2Vvs8Pa7cohT-Gtpf0{Vb}%@;(omk6rTjHuvF>d z?hU#j1C310hxb+W&MXvY{fv%wxd4uuo9^uv%ew)0@Nwbnjm_|+;Jf-Gb5v)~LEb{X zUr(Ej#&1nBx}Ew+g3_U$+u*wksXM*3QI8^Ad{rR?ZVffEGiB^)JenIAqiIO+jVHfx zwP)eZ96HD3-sfg3vZ>sLt_dL=jhaaQ-aIk7M#HT-Y0X`CTF0Fc^c*jB>D;m4O%lU$ zaACZw>Ic(wjCWREPFt-tWGu93=vgl_)qM&Wu^Qcax+7&UBA36Dec!t|B_v7{aHp<( z(F|peft}Yh7^?vi5&|*M=agYCf|b5O`W_)*zt~*MMCqXx~+*KFBXza1FSjR!gE_Lif3E|ZBxow%{%uCU3^&% zk{A|Z$zyX{NczqKn&>^@vFZI}>E-6wJN};i{M3$-M>Qks>5G%hmF^< z9~=e(*Y_VH35x~jF}r4r?WOungMTgLE=A=UHV4X?ze}q}R5_zey$eeR^JO9yo~KSX zu1VgIC*OxOF59lw5)Xv*EvD^-4(&DfVqLuA1!~ z_qZ+vOASZ5y#{Z6#G->ouS#04A$004)#QEe+iOlDm2K86ZGQwLkeT{eZdfecLj5Uk z!9Ah;iGxveYPK{r2JDwEcrQ1d8=aVUTNkAZrXI?gNKcfHOk? zFc}kZV(&f5RnI6_Gjs}Qy}Gvh_WSfN-#7NToi)b{{MxUt2Md}@@J~TC%z?2EE_t{F zNXcAb*zjoNo>xqp1);EwPl%~e+dL5;-FN0XPNOx!LarkR$;9PTSo<{dBw+!=jJRgC z+tS_)n(gy4-IH8{omZoIInck82iy?k?a966!ZeGoociGWrbk?hZD_3S`j1 z0$lL)X)@)DOG$M%hj4;V=vG9Bm1Fx}uV2`aQj~Ne7+pa8HTWJJJ)B34H2* zU)j@j7lC8EOsbw4X&T#Uox@E{r_(BGogsuKoAW}t1pxGcOXc4KqQ6TFbF{4GnpP_> z4L4<~g;7gnbXO~=XO{?vJ#qa{6 zA@H;qBj#IjkI(#xa<8GWr0PuzX7V`Cipf+af1mZSUtP4cUqXnG@1eFF5gDNUx02>uF6t8ufoGU|cOXh87Oc1h!BBDtEBcZ`Qg0dBn zzrPYWc(PaFlvV-PF2YSAP4nQNL*;?{CZES*0B2Hi*?wr9KTh=XA%b*1L z2IBYG=g&l@$jxJuT>8;^O{FBx`oeOIv%^m6_F7m?vF(y5!R<+g4mkIUuFg_cl2-v& zZj4pZph|@1#BB6yRK{;tY}}<(&fm30y4N*@7N-?B%P(!jC6;boFL<+&ZN7NI?pMBa z9Nir|Bs+|BFqe4IwACZd>T4Y%1=n>)h8@p_d|om_RMVFTFBY_3~}z$J1{dp zK@7vnem2mJ?GxKS5y_ewpChn0U9`%@Ol%={n?ICa3VsD3?^d5IndomUpY!b1t0`>k zB!_!kvgW1?s9hur6?1Gk->Gm2@AbScuh@~!5w!d$NTT^Ti+0GRDx1B~#SR?~6Dz5q z|5>q?u)HfZTS<;FFzX==Sk*3D0SKVo9Lr8D?;aBF8CWCg-Kycvb`hhx zxkOB!lFH$P;Cs)nX!@cj9?wd%A_2tF@R^>%N8G!bh^++$?tbR0^k%i`76{Ep2H`W; zfr4w7O^_D*Cd+3H?Bfe$2>-Est?afRmM@5LCZ)(UO%e{b3HY#bq|5%2{gK;5LZTeX z|C8SEYNx2G(3-TK?IhHCgX=qw7d(M&_oF}k+f$oAMM@aM3I+7-^|!RQItNeuv3>{R zLRMg(DI7Rx_DdfWlQlu}F#IhMMTYtI?*?c5xax`Y6wRW!g}w--JwHd9*Tp;e!RN+A z?C%l`^MZFweAFB*<%Cb^OseaZh3xR;c|FYH>atA{mD0W)U6v(pa;j@A_-2f|SvSl5 z7q`6E3ED8p>XRlFH$q8Jaf(sCO$A6Y~cI`UOC))g)+OA^`|K^qdJw1<)-fzB=iRmqHnjU@g%lY>z zjQ%HWk7b0s;ll;r2>%ZHXIG0Ss8s!^lk7DLGb4ti)etlQ{}&55wnqYkQ?i#R5p4b^ zZ|-G0@kr9$^32dBm6va_bZv~n2Sa}y!`OwFmv~pBkk6-!)wv>Q-+I`qRE_s|E5ETZ z-bX3MCRt$9bE1{@2=nh5>Q_<2^5CS_9@UOnFC7Ww5oP#WWSm5JIzfAM`+KpPNU;Y1WWJ$ot+XYvl32j7@L7}{?cG57D9e6Pms=#N!oIiLHevHO(Ue#$MM9}hI| z|M9@>x%>Njm7w&#&m&sXj!$=8Uwc4DI56d!HFoQ^AI3*n{d4P~f5v9;Z}s0-&~Ta? zufV@Cu9!NPx=ZxOfR!s|8m&{|aLH?`e%gBkPJLXs(!x1^VAs6vy&-O@@MLyrX2Wfp z^ow)X#_yExjW`fvH2LA|HOlAje;wI1@BH82qvLl|i+=4a_+US-24&tdgBG=3ArFAyGTvb8;pn z;jy0ZY()jFwtv53@(B;Z@;Jkf?=! z3y|qrt;#E_6BEP9jX|6#o_!Hhzt2AyaWWw~_v>2oML-+pkF&($cw4XSdsx2fJMBIu8m52__9+{5q(yk&T z*%+;^d#`Rhf@nYJA0g~I&AtUWC$@TC4<#m=@h$q?1L79GV~0-e=Tg_}&9~F(`|mRL zk5C}DT0!EUTvFFp36NPY#@Dtot*Otpuf|nl5B&zO?BwvMp@*X0i$-BX#Y~eF`dUSE<)l|n<;c5Re#(&OE;`f&;ZH7Fnd zDdetCXE29ioIcSG!whX7(({`cXO70|kdig${fcb|thGlZDmu4a7Bj+x{<=!%UNxZI zuG0Gm>-gg-oG?0NRUYUoi8@xRy}vi)ptiKl_pq~3HP|zqa9;I#>1bHP_~iYd19}QJ zSnvAuF3I(KA`@8e zo7UfhwKtHE;kk5@P@Cpwje@#x1b47VL92+Q_4%O#Uu7_Ka^Q!LTQO;8EhsBTG7+>I zeqBWH%TqV`)Q31J1Q{{#-sxE#(}ge#p=vF8$m77SZqThhTUOax6SZsr!WoS_>ZHtB z#cL=pBYFO=k8_c!!f3+5NWEu*!O!!yCEFfc71{P>>U21Nx|NUFQCdZlnxM0hSLISB z2?k7w8d!l=53?Xe5jabfzF!r*&VWkv9v)8c#qjuhWKn(p2ytKQN48+CUz|9pc3 zWf*FF?%?~O+Nrb6of7ry;PbJd2iilTPJj`Ko}FwPTs!xk_i%ja`98Aq9TMSv8mMVx z^!nrI5XJnw(xjb8KFO)8&ubgM^}(}OeC$xBtbG!}bJ<}TAL1=Qgn_^BA9T!Z38wfx zGcKR}DqoiBya)_Ro5h_&<>-9e7v|6f->QD{v4c3VsqiW7f!|s6FrMN=J8DfML1k0# zMA!uAs!m^0Eub;{gRD zdPT}5hT-ov&?R*4Q=1;#L--HJW?A26S@Dhm6zd-=Er@P3t5=BG1&Ez>P1EBqh z0cnQCn$6TZhiFb7TohzjQp^H+<-5RI>SQkL&Xtp9ra@HiwQ`}czl*|LuaU*XPBN-C znE6#i0(dl1uP47)L&p4YPtcdb#rGN3hL$>IJ2<7m6Ls?Ecyc$z2a(X zwnkL_Q76=>Z@3%Z662p%>1tMzNOG2xEHG?(tEM+r* z_~kK$jTZg@mRXAmmu8uKvKdyeUzBvsPiY_Kt%y9lGW%#&t=!f8jZJY#=guj;EET4J z+(D+nqyT-aBmJs9)ae0fIyE;`{Tgli&!Hx8#RS$E*K;w|hr>T2E07lNb5v1rv8`vx zGWTT!0D=!mHS2vl^lGDV^#8PXol#9@>skjCQHo^-M4AeMh!80PK@m|wkm@)iB~+0> zC@Tsf3QI%de*f*kWc4Ke13I9Pzf6#mERMz-bOXreH)+Cs) zwbKb>ZVGfRj*!q#X(PJty%T0z^o|q|YnKxh{2MY>${mxqqM4D~tY<2wzqR+W)bOt- ztG0uG8o`ZRAZu)j5^P}H_KI#p&48zmTENvCJlf-HchU|=#BXe z&$VNvHD|yUEQ2*}e8pKfnVPTKs@I=UeU z`X;(TVaQ)9zF!+Hlk^su$WTuat0QIZcqhj+!Rx$3jp2?p#QA=&f!k_i&*4shHm>@y z87A9;@PJS??tsXtQXDy94Lwir^x_tuV-#m%o_=j@Ql~s?K8FJ5d5oN0ZXP`H)9ab5 zrnjVfz33r3#o46|{<&9R#2y(_aMm_H%E2;WkFe5^GqXa?)#SVRnd0M|j?@{YK%_xN zpdHmbSLGX9>t5LYI#%8nyB#ZC^iL}qYO5tl6|*I+N_^eJfa&XjlLM`xK{it52Tp!T z$C0sGAyE+4lMm$7dJXmGBfn@()ourJoT^86k;u{TtzHi*JExIi$DaY{zOJZi`L&=6 zOHPK}%|Lc{7^FblMQLU9NE&9%1H@EOnl7+Dwo<4uTV8RB^Fdi3*WI(9m5eoOJa)jT zHk7?77>ycaQq+mgmbaianhaoyj|s!*n|lOzhwWp|IMkCX#Xe;;I2}EEW+>lm(Nads zYOUY(j$PbT>${*9iD71zk1qKnfS`XLJx_36S{P_OVw#4h@k{Z`Y|QmhcKC|9@lz$G zt=vq2Coi%{nNKyuG1XTa+y1n-%Ko+sB5}t6W;X)-$R!UV9QP_q^}W!23V*36Z=w&0 zSA&@;fFy2<(7vfrM8>(G8WmR(H}ye7kVwy8lZgFQSHvJ`xf;JryO5VQ*@(73J~<`> z6_??PAE4j*1nXY+$J!k3)xz_BG>*%N+&67@Nj`{`ngCozA8Pvr;^H-vz@P2Ior1%6 z&Kd`-hIoqC!BUQyBBe$4#}9Wy*Z8Lk7Mz@!z?$V>lqh)oQIAAJIMB%ia$3xr@+}{OoQKws(-lP<2^9KDo&cSH z`B_0Z0WVz!knvxHScd8)Nmo%`ewSH92*D#7elOJGURD5MjTQCic;ITH=6LbmohEooJeB`E<&KPyw`sN_*#XS{w$nthy(OnYFk>WL<-N1 zkE%dd_Cm$1VTZ^$MVFhGfkS@e>VbUm1v+6in9iQhFxYRRSO3o5on&!^^;1O=H4Hxb)k=zAHBfiS{3(u#iXp4@NojO$73%s!K`FmIZ+OW|U$?$<8QZc?&_Rjecqu1Q6 ztv0A0du)hBBUcZpSw2AfK1pm(@G4FqpoRt@gx79jXsm7ce6Giuiax=n~a+Ec-B5@7p zfp{e!)R*=)T`dsGgw$~~wU+@eZ*aZjJfgOw0ygHI);W>-ZtA~Ite}M$*!~1`UE4~AFS;viDHhIP<56^X1sj~A(_!mpv;Z!MqG6r=@)Fde(y}f30I+mEJQO043n(u_PLxOr0fDx>E&ji3A@^Zy_=4rL13Vx zTE4${qipki=G`-v1w2S+t=Z%Xl;Y>Zz(6sQjgGwZ94L)BFJqc>dV>xl7yo;3BP6eIF zg>5~|yU4wAr(Hv~Ig;~9QjgZ;L0L%wPBBNLTIAJ-o*gdqX_Pi7|MJH|#`FUe9d+OQ0Sbs?D#^)cjeCJR+#TNNn%~FQ@^>mZE^Zp0d@M^ zoN&PQJW9%^;N9rh%}vAP?$$e@>jFyCXTjGcFA8sxa;P8qKjr~E4G2e97XbHbMvkgZ z^~Nscrk+oYKGuTXiE+cl!ld?Zbs+rM7;&ov=6eq`@JQR4lmr{1?gVe0qGcZz`cHg$ zx3FY>uOydsa4r*I(%%KDQPpr_tIHXDc^EygscUy-B<5ye9r6ZtmK>$v5Hq$FgwVKC zrmo?WB&$PH?SJW0jY>MfT}?cjx%OF5Ez&KAtX3Hw*$>BhA9gD6k;(DbyVk9qI(p2Y zMysl#nJG=O`6^K7NW530LW!S`(DKGv(#>`EqR?}tNh??2sJl( z#?_?M@$P=|M#{tX(>NeLEis`lRJm3K5&7yP`p`j8DniYvC(qg74<#p>26P(C7ef2! z{DKC?;LIN*eWUf>e(*+Bg4)ROA3nd$>#b(Rgmo09>{H&s`+dkOpw_NcQ$;~L@EL@& z))AS>TDP6ad!-gaj-|TdXK;xJ9?0U;^zc!=l#t3-i;r2O3WEK@0=MjzUxaaLcmW8A z7J*N-kT@1_6^Y+G9llQZH)^*#(M}66CZH)izy+>^sm0#r*c^H>H9z9N_eZ{CP4oTz z#;N8rf_Cbr+M={-tHh{M3eGrKW_1mVX`;c-%#5$H9^Y|lnhxWS8K>U;wPDZI-f2{& zzR^om(Pr%Y0f>csYhUc2;U|u>E4tdm+24AaH6~nWg*eGXW_+^kEV%j*WH5R__;dD# z=XOBe&(Vm0z8n1C@e(;aGV_ES<&P^{Nu`W(yIfA9Eg$MXkzhvuoPqZ-QEV(fqK%mb zc*akhb$+3(nu;|T94%=;s)ZhCS`JDJzJDB5opLjyIYhACBa;bkC3?{0xbB`geQdP- zp7<>&P&hiQJ5yb3DN)iRaW$>$(?Dq+z=SLO)Y-vtZ~!f4k-1{@UJ_U$#V{bvMPe&5 z$=r7`OAV7wSScnxdA9PIWQY*-ObD9;=UX?>TPTVH7+b?IP!t)etsHMhFX?;h{t>m9 zcp5>;2|?Hi=)2LIqQdE`YqZnPrQCh&Ete9~gCi7(TYUXW;@u2ZcE8#j8cw6g1kXm` zVzj1PI(t!Hue%P*oje}U4xbRahs#K9+H((34g_C<K`Nmar9mdrb5#5iQh0z?$nc%gUL6( zyWw#zayQE_YNMbyh}B9vz3Bu$jWF%ZOs^?Xry+m+rELrAZlSqp8KisrYie+Kv>&=F zdA3#GR%ow9ow$6gT`Gi_{gr^41Y@t(?w`8UXxUJ|wU=|shW#K+^hljvFoV3{B-N5z zf7EVQCsczZxWLcydRlPx@lj|POWYi1GUlC%#9vp}aZ|E2ji8sBLrqsKR_%{mIQj?6 z@s5zkb5cSVGRQsGF0*ncg z(T6>1*;b2w;uj&IPT2+RFk_H07k+i@Q{P}49G?ayOE1Lmolp?v6c{G}9?-6AF`~eR@iXQ*(Xc!;W940?i%=AT988Jchf8xuq=H}JkZYC z4W-){znZDM=evGkN|j|Qq<>nh*Zt`oR`!br=`ZtBdyhwuX=5q1B9>MwcJQdrlcYt` zF`@b(3s{EcZr2V0Qe>e{T5KBi383S<#~FL$hflES>vN@z7~pD<>!PY$-YzVhAOEt> zQjoGuyFxxe>J1#}6@o~+LsoJmk0e%fq7()9L$>NU@GLJ0^O7rb;}V}mv*cH+`Lk`KU+85_ju+vmiG;cz{id5 zkX3q)Wo>+PATBoIWx6c1rmSoAn(bG^A&;xap@ce=h+|m8#oeb^sT^4~{S8g>Y5U`) zgX!wvm>7h!`jDCj_99E%Y_t8a_lz#V;~Y+n^av}&8u^q7J<+Sn>A)_de+mXY`2b&b zZgTo|xs|g?IRcXHlHS+qTR^I2uC0#v44v2O3n^p=Ar8%5Ezq7QXf<>_GmC5DA>)wjt-EpEuMMWegEB50>QcSC|nPe9nU z;vB#x5MPoNo~oYbV;b^Y5=|;TYSl!oQ<03{p*>l;8|~U(OB|Wm^s!ITG_8psY(DU! z7%R@M>yp0Jx&h~@32rwis(?HnkAwm@C;Q2Fr1T})(+Io3LKi|zxc5id@*5!9$yWo; zAQ4|jyjP`^Vh^aX*x8Kp>UkypRPXrI_Si#S?jN8tMo~&uk;`kNToxSASP2~VGI?Dt z^A9K_YTnB%eX0kw1KrhSO3S*2T4h>TQ1TgIbK|RfM2!=Pemxs`CWy z;+*rlN@vc)O9oF)3&bZmAIClIjF1BYv~_4aF1c{Cw4YP5zbRSp|Sm`|0B1i z;Qm58T2(m@C3P@PlBJ=IQ1NvxDx(7xI}B|Nf>SE4Kj`;s`pZ#3L|7`hBg(K*#=(q? zDuV1UVt8heG#RG&+c}{vL#cj2-}?^-9psfMX#rBuH{(&aZZ2_s94C)CX1kw5DTc5qzI!!Q=o;Uj_cUs5o=yK*zZO znyowXOh_M)?6l@@v6g>Pc};c7Zj49&{dS*6|2?JgE>)(I|BDqnobp3I@GtORpBCep z0R~L`!-GQW3jv%BN%(CU^MjtQ-@J0vVrzIuqVpB-wHb9LExoM4IWx=MrGfsV4#D@z zp#P8W|DKJnFZDBR7PHSN^(HD(YoyHEpegyJ;WZ3V2J6se`(@JYJw)P+PM5x_5Nk0A-M|yfs-?L18+OFZewt zkmh+_8P~ZM{#-qhf4f>(!fg zHFqqht>1iiEAO4S?qF@t6W)3?Frr?0eg&JGJ`SaOQP!AYw0E)DDef5=ul<p67(*LOf60ZudmTNyADP6t)_IB^=J&eWpj|zeo zwY}V=%mPrtsfK!Vr&%JI()VL_*M6S9p_4aMp3DT5J~xQBvm>4Z#klUVj4Lho4OU>W zS{NB#i(CM)cUJIBmPF(az0CCeMo|m8L|(Z`+5PQm<))50;o`vzx#vznz;n9g^If+?0D7sRQ1JQ{X*fP>wwqB_ zxTKKCVkk{()me=kJr=m}{@2Q_s|K^Di32&xkrM9HMWqi_N2xJraujf8C*9j>AEq+8 zn><}~AJZoBW3Y2{zZYKpbEh3r`KM~<|BUO`|D#%$f4>NE;K0A1$oLMu|ALtH1Bd?I j^!EY&Ux6pHz5RoeOm3J}TiQR~5iv2exKnk@Ir={U1j$1O literal 0 HcmV?d00001 diff --git a/samples/bidirectional-chat/cors.png b/samples/bidirectional-chat/cors.png new file mode 100644 index 0000000000000000000000000000000000000000..9f5fca824ecad01e9b8733e427261c62debff1f8 GIT binary patch literal 104901 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 literal 0 HcmV?d00001 diff --git a/samples/bidirectional-chat/csharp/FunctionApp.csproj b/samples/bidirectional-chat/csharp/extensions.csproj similarity index 100% rename from samples/bidirectional-chat/csharp/FunctionApp.csproj rename to samples/bidirectional-chat/csharp/extensions.csproj diff --git a/samples/bidirectional-chat/getkeys.png b/samples/bidirectional-chat/getkeys.png new file mode 100644 index 0000000000000000000000000000000000000000..f9768fdcf5551a673b32f8b3eb56fac0591f0bb1 GIT binary patch literal 41124 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 I>k;*$6v$Rr7kbjF#TV!6QUFuk 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

evF%pr40Ir?(q-Hs&aJO;J8b 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 bg*(ZzUVNXi>`EN^sw8g7+)TZ$ Date: Mon, 1 Jun 2020 15:34:13 +0800 Subject: [PATCH 21/21] cleanup (#122) --- .../Bindings/SignalRInputBindings/Common/BindingBase.cs | 2 -- .../Config/SignalRWebJobsBuilderExtensions.cs | 7 ------- 2 files changed, 9 deletions(-) diff --git a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/Common/BindingBase.cs b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/Common/BindingBase.cs index bf1e26b3..2b298b81 100644 --- a/src/SignalRServiceExtension/Bindings/SignalRInputBindings/Common/BindingBase.cs +++ b/src/SignalRServiceExtension/Bindings/SignalRInputBindings/Common/BindingBase.cs @@ -11,7 +11,6 @@ namespace Microsoft.Azure.WebJobs.Extensions.SignalRService { - // todo: extend to implement ITriggerBinding // Helper class for implementing IBinding with the attribute resolver pattern. internal abstract class BindingBase : IBinding where TAttribute : Attribute @@ -52,7 +51,6 @@ public async Task BindAsync(BindingContext context) public Task BindAsync(object value, ValueBindingContext context) { - //todo [wanl]: figure out what will trigger it throw new NotImplementedException(); } diff --git a/src/SignalRServiceExtension/Config/SignalRWebJobsBuilderExtensions.cs b/src/SignalRServiceExtension/Config/SignalRWebJobsBuilderExtensions.cs index ec064f79..ebe5a79e 100644 --- a/src/SignalRServiceExtension/Config/SignalRWebJobsBuilderExtensions.cs +++ b/src/SignalRServiceExtension/Config/SignalRWebJobsBuilderExtensions.cs @@ -7,7 +7,6 @@ namespace Microsoft.Azure.WebJobs.Extensions.SignalRService { - // todo [wanl]: remove hardcode key = hubName, HubName in attribute already marked as [AutoResolve], its behavior should be [AutoResolve]. // Then all resolve jobs are put in resolvers, we can also remove the SignalROption after we apply resolve jobs inside bindings. ///

@@ -40,12 +39,6 @@ private static void ApplyConfiguration(IConfiguration config, SignalROptions opt } config.Bind(options); - - var hubName = config.GetValue("hubName"); - if (!string.IsNullOrEmpty(hubName)) - { - options.HubName = hubName; - } } } } \ No newline at end of file