diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8c2b60b..02b87e1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @ferhatelmas @peterdeme +* @ferhatelmas diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 66e149d..6aff037 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,7 +16,7 @@ jobs: env: DOTNET_CLI_TELEMETRY_OPTOUT: "true" steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 @@ -28,7 +28,7 @@ jobs: dotnet-version: 6.0.x - name: Dependency cache - uses: actions/cache@v2 + uses: actions/cache@v3 id: cache with: path: ~/.nuget/packages diff --git a/.github/workflows/initiate_release.yaml b/.github/workflows/initiate_release.yaml index 2525e6e..88c7db7 100755 --- a/.github/workflows/initiate_release.yaml +++ b/.github/workflows/initiate_release.yaml @@ -11,7 +11,7 @@ jobs: init_release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 # gives the changelog generator access to all previous commits @@ -19,7 +19,7 @@ jobs: run: scripts/create_release_branch.sh "${{ github.event.inputs.version }}" - name: Get changelog diff - uses: actions/github-script@v5 + uses: actions/github-script@v6 with: script: | const get_change_log_diff = require('./scripts/get_changelog_diff.js') diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index cb10856..aab6f3e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -13,11 +13,11 @@ jobs: env: DOTNET_CLI_TELEMETRY_OPTOUT: "true" steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: actions/github-script@v5 + - uses: actions/github-script@v6 with: script: | const get_change_log_diff = require('./scripts/get_changelog_diff.js') diff --git a/.github/workflows/reviewdog.yml b/.github/workflows/reviewdog.yml index 2461d6f..7be0117 100644 --- a/.github/workflows/reviewdog.yml +++ b/.github/workflows/reviewdog.yml @@ -10,14 +10,14 @@ jobs: reviewdog: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: reviewdog/action-setup@v1 with: reviewdog_version: latest - name: Setup dotnet - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v2 with: dotnet-version: 6.0.x diff --git a/src/IReactions.cs b/src/IReactions.cs index f8abfd7..01d5668 100644 --- a/src/IReactions.cs +++ b/src/IReactions.cs @@ -13,10 +13,18 @@ namespace Stream /// https://getstream.io/activity-feeds/docs/dotnet-csharp/reactions_introduction/?language=csharp public interface IReactions { + /// Posts a new reaciton. + /// https://getstream.io/activity-feeds/docs/dotnet-csharp/reactions_introduction/?language=csharp + Task AddAsync(string reactionId, string kind, string activityId, string userId, IDictionary data = null, IEnumerable targetFeeds = null); + /// Posts a new reaciton. /// https://getstream.io/activity-feeds/docs/dotnet-csharp/reactions_introduction/?language=csharp Task AddAsync(string kind, string activityId, string userId, IDictionary data = null, IEnumerable targetFeeds = null); + /// Adds a new child reaction. + /// https://getstream.io/activity-feeds/docs/dotnet-csharp/reactions_introduction/?language=csharp + Task AddChildAsync(Reaction parent, string reactionId, string kind, string userId, IDictionary data = null, IEnumerable targetFeeds = null); + /// Adds a new child reaction. /// https://getstream.io/activity-feeds/docs/dotnet-csharp/reactions_introduction/?language=csharp Task AddChildAsync(Reaction parent, string kind, string userId, IDictionary data = null, IEnumerable targetFeeds = null); diff --git a/src/Reactions.cs b/src/Reactions.cs index 0c9602b..129c3bf 100644 --- a/src/Reactions.cs +++ b/src/Reactions.cs @@ -20,9 +20,16 @@ internal Reactions(StreamClient client) public async Task AddAsync(string kind, string activityId, string userId, IDictionary data = null, IEnumerable targetFeeds = null) + { + return await AddAsync(null, kind, activityId, userId, data, targetFeeds); + } + + public async Task AddAsync(string reactionId, string kind, string activityId, string userId, + IDictionary data = null, IEnumerable targetFeeds = null) { var r = new Reaction { + Id = reactionId, Kind = kind, ActivityId = activityId, UserId = userId, @@ -35,9 +42,16 @@ public async Task AddAsync(string kind, string activityId, string user public async Task AddChildAsync(Reaction parent, string kind, string userId, IDictionary data = null, IEnumerable targetFeeds = null) + { + return await AddChildAsync(parent, null, kind, userId, data, targetFeeds); + } + + public async Task AddChildAsync(Reaction parent, string reactionId, string kind, string userId, + IDictionary data = null, IEnumerable targetFeeds = null) { var r = new Reaction() { + Id = reactionId, Kind = kind, UserId = userId, Data = data, diff --git a/src/Rest/RestClient.cs b/src/Rest/RestClient.cs index 5df8e99..ec9a853 100644 --- a/src/Rest/RestClient.cs +++ b/src/Rest/RestClient.cs @@ -66,16 +66,16 @@ private HttpContent CreateFileStream(RestRequest request) private Uri BuildUri(RestRequest request) { var queryStringBuilder = new StringBuilder(); - request.QueryParameters.ForEach((p) => + request.QueryParameters.ForEach(p => { queryStringBuilder.Append(queryStringBuilder.Length == 0 ? "?" : "&"); - queryStringBuilder.Append($"{p.Key}={Uri.EscapeDataString(p.Value.ToString())}"); + queryStringBuilder.Append($"{p.Key}={Uri.EscapeDataString(p.Value)}"); }); return new Uri(_baseUrl, request.Resource + queryStringBuilder.ToString()); } - public async Task ExecuteHttpRequestAsync(RestRequest request) + internal async Task ExecuteHttpRequestAsync(RestRequest request) { var uri = BuildUri(request); diff --git a/src/Rest/RestRequest.cs b/src/Rest/RestRequest.cs index deb5815..9f3f5ae 100644 --- a/src/Rest/RestRequest.cs +++ b/src/Rest/RestRequest.cs @@ -11,22 +11,22 @@ internal RestRequest(string resource, HttpMethod method) Resource = resource; } - public Dictionary QueryParameters { get; private set; } = new Dictionary(); - public Dictionary Headers { get; private set; } = new Dictionary(); - public HttpMethod Method { get; private set; } - public string Resource { get; private set; } - public string JsonBody { get; private set; } - public System.IO.Stream FileStream { get; private set; } - public string FileStreamContentType { get; private set; } - public string FileStreamName { get; private set; } + internal Dictionary QueryParameters { get; private set; } = new Dictionary(); + internal Dictionary Headers { get; private set; } = new Dictionary(); + internal HttpMethod Method { get; private set; } + internal string Resource { get; private set; } + internal string JsonBody { get; private set; } + internal System.IO.Stream FileStream { get; private set; } + internal string FileStreamContentType { get; private set; } + internal string FileStreamName { get; private set; } - public void AddHeader(string name, string value) => Headers[name] = value; + internal void AddHeader(string name, string value) => Headers[name] = value; - public void AddQueryParameter(string name, string value) => QueryParameters[name] = value; + internal void AddQueryParameter(string name, string value) => QueryParameters[name] = value; - public void SetJsonBody(string json) => JsonBody = json; + internal void SetJsonBody(string json) => JsonBody = json; - public void SetFileStream(System.IO.Stream stream, string name, string contentType) + internal void SetFileStream(System.IO.Stream stream, string name, string contentType) { FileStream = stream; FileStreamName = name; diff --git a/src/Rest/RestResponse.cs b/src/Rest/RestResponse.cs index d4e3d4c..89f26f6 100644 --- a/src/Rest/RestResponse.cs +++ b/src/Rest/RestResponse.cs @@ -1,5 +1,4 @@ -using System; -using System.Net; +using System.Net; using System.Net.Http; using System.Threading.Tasks; @@ -7,9 +6,9 @@ namespace Stream.Rest { internal class RestResponse { - public HttpStatusCode StatusCode { get; set; } + internal HttpStatusCode StatusCode { get; set; } - public string Content { get; set; } + internal string Content { get; set; } internal static async Task FromResponseMessage(HttpResponseMessage message) { diff --git a/src/Utils/ActivityIdGenerator.cs b/src/Utils/ActivityIdGenerator.cs new file mode 100644 index 0000000..b590d7f --- /dev/null +++ b/src/Utils/ActivityIdGenerator.cs @@ -0,0 +1,130 @@ +using System; +using System.IO; +using System.Text; + +namespace Stream.Utils +{ + /// Utility class to generate a unique activity id. + public static class ActivityIdGenerator + { + private static DateTime Epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static long EpochTicks = Epoch.Ticks; + + // Difference in 100-nanosecond intervals between + // UUID epoch (October 15, 1582) and Unix epoch (January 1, 1970) + private static long UuidEpochDifference = 122192928000000000; + + /// Generates an Activity ID for the given epoch timestamp and foreign ID. + public static Guid GenerateId(int epoch, string foreignId) + { + return GenerateId(Epoch.AddSeconds(epoch), foreignId); + } + + /// Generates an Activity ID for the given timestamp and foreign ID. + public static Guid GenerateId(DateTime timestamp, string foreignId) + { + var unixNano = timestamp.Ticks - EpochTicks; + var t = (ulong)(UuidEpochDifference + unixNano); + + long signedDigest; + using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(foreignId))) + { + var hashAsInt = Murmur3.Hash(stream); + + if (hashAsInt > int.MaxValue) + signedDigest = (long)hashAsInt - 4294967296; + else + signedDigest = hashAsInt; + } + + long signMask; + if (signedDigest > 0) + { + signMask = 0x100000000; + } + else + { + signedDigest *= -1; + signMask = 0x000000000; + } + + signedDigest = (signedDigest | signMask) | 0x800000000000; + + var nodeBytes = PutUint64((ulong)signedDigest); + + var finalBytes = new byte[16]; + Array.Copy(PutUint32(TrimUlongToUint(t)), 0, finalBytes, 0, 4); + Array.Copy(PutUint16(TrimUlongToUshort(t >> 32)), 0, finalBytes, 4, 2); + Array.Copy(PutUint16((ushort)(0x1000 | TrimUlongToUshort(t >> 48))), 0, finalBytes, 6, 2); + Array.Copy(PutUint16(TrimUlongToUshort(0x8080)), 0, finalBytes, 8, 2); + + // Now to the final + Array.Copy(nodeBytes, 2, finalBytes, 10, 6); + + return new Guid(BytesToGuidString(finalBytes)); + } + + private static byte[] PutUint64(ulong v) + { + var b = new byte[8]; + b[0] = (byte)((v >> 56) & 0xFF); + b[1] = (byte)((v >> 48) & 0xFF); + b[2] = (byte)((v >> 40) & 0xFF); + b[3] = (byte)((v >> 32) & 0xFF); + b[4] = (byte)((v >> 24) & 0xFF); + b[5] = (byte)((v >> 16) & 0xFF); + b[6] = (byte)((v >> 8) & 0xFF); + b[7] = (byte)(v & 0xFF); + + return b; + } + + private static byte[] PutUint32(uint v) + { + var b = new byte[4]; + b[0] = (byte)((v >> 24) & 0xFF); + b[1] = (byte)((v >> 16) & 0xFF); + b[2] = (byte)((v >> 8) & 0xFF); + b[3] = (byte)(v & 0xFF); + + return b; + } + + private static byte[] PutUint16(ushort v) + { + var b = new byte[2]; + b[0] = (byte)((v >> 8) & 0xFF); + b[1] = (byte)(v & 0xFF); + + return b; + } + + private static uint TrimUlongToUint(ulong l) + { + return (uint)(l & 0xFFFFFFFF); + } + + private static ushort TrimUlongToUshort(ulong l) + { + return (ushort)(l & 0xFFFF); + } + + private static string BytesToGuidString(byte[] b) + { + var offsets = new int[16] { 0, 2, 4, 6, 9, 11, 14, 16, 19, 21, 24, 26, 28, 30, 32, 34 }; + var hexString = "0123456789abcdef"; + var retVal = new byte[36]; + for (var i = 0; i < b.Length; i++) + { + var value = b[i]; + retVal[offsets[i]] = (byte)hexString[value >> 4]; + retVal[offsets[i] + 1] = (byte)hexString[value & 0xF]; + } + + const int dash = 45; // The dash character '-' + retVal[8] = retVal[13] = retVal[18] = retVal[23] = dash; + + return Encoding.UTF8.GetString(retVal); + } + } +} \ No newline at end of file diff --git a/src/Utils/Extensions.cs b/src/Utils/Extensions.cs index 1e3aebb..5b5ab57 100644 --- a/src/Utils/Extensions.cs +++ b/src/Utils/Extensions.cs @@ -8,7 +8,7 @@ internal static class Extensions { internal static void ForEach(this IEnumerable items, Action action) { - if ((items == null) || (action == null)) + if (items == null || action == null) return; foreach (var item in items) diff --git a/src/Utils/Murmur3.cs b/src/Utils/Murmur3.cs new file mode 100644 index 0000000..db1815f --- /dev/null +++ b/src/Utils/Murmur3.cs @@ -0,0 +1,98 @@ +using System.IO; +using IoStream = System.IO.Stream; + +namespace Stream.Utils +{ + /* + Copied from https://gist.githubusercontent.com/automatonic/3725443/raw/c2ffc51ed8e9ee3c89e8016c062672d3d52ef999/MurMurHash3.cs + The only change is that we set the Seed value to zero to match the backend Go implementation. + */ + internal static class Murmur3 + { + // Change to suit your needs + private const uint Seed = 0; + + internal static int Hash(IoStream stream) + { + const uint c1 = 0xcc9e2d51; + const uint c2 = 0x1b873593; + + uint h1 = Seed; + uint k1 = 0; + uint streamLength = 0; + + using (BinaryReader reader = new BinaryReader(stream)) + { + byte[] chunk = reader.ReadBytes(4); + while (chunk.Length > 0) + { + streamLength += (uint)chunk.Length; + switch (chunk.Length) + { + case 4: + /* Get four bytes from the input into an uint */ + k1 = (uint)(chunk[0] | chunk[1] << 8 | chunk[2] << 16 | chunk[3] << 24); + + /* bitmagic hash */ + k1 *= c1; + k1 = Rotl32(k1, 15); + k1 *= c2; + + h1 ^= k1; + h1 = Rotl32(h1, 13); + h1 = (h1 * 5) + 0xe6546b64; + break; + case 3: + k1 = (uint)(chunk[0] | chunk[1] << 8 | chunk[2] << 16); + k1 *= c1; + k1 = Rotl32(k1, 15); + k1 *= c2; + h1 ^= k1; + break; + case 2: + k1 = (uint)(chunk[0] | chunk[1] << 8); + k1 *= c1; + k1 = Rotl32(k1, 15); + k1 *= c2; + h1 ^= k1; + break; + case 1: + k1 = chunk[0]; + k1 *= c1; + k1 = Rotl32(k1, 15); + k1 *= c2; + h1 ^= k1; + break; + } + + chunk = reader.ReadBytes(4); + } + } + + // finalization, magic chants to wrap it all up + h1 ^= streamLength; + h1 = Fmix(h1); + + // ignore overflow + unchecked + { + return (int)h1; + } + } + + private static uint Rotl32(uint x, byte r) + { + return (x << r) | (x >> (32 - r)); + } + + private static uint Fmix(uint h) + { + h ^= h >> 16; + h *= 0x85ebca6b; + h ^= h >> 13; + h *= 0xc2b2ae35; + h ^= h >> 16; + return h; + } + } +} \ No newline at end of file diff --git a/tests/Credentials.cs b/tests/Credentials.cs new file mode 100644 index 0000000..ee151a3 --- /dev/null +++ b/tests/Credentials.cs @@ -0,0 +1,23 @@ +using Stream; +using System; + +namespace StreamNetTests +{ + public class Credentials + { + internal Credentials() + { + Client = new StreamClient( + Environment.GetEnvironmentVariable("STREAM_API_KEY"), + Environment.GetEnvironmentVariable("STREAM_API_SECRET"), + new StreamClientOptions + { + Location = StreamApiLocation.USEast, + Timeout = 16000, + }); + } + + public static Credentials Instance { get; } = new Credentials(); + public StreamClient Client { get; } + } +} \ No newline at end of file diff --git a/tests/ReactionTests.cs b/tests/ReactionTests.cs index 81981a7..aaffcde 100644 --- a/tests/ReactionTests.cs +++ b/tests/ReactionTests.cs @@ -125,7 +125,7 @@ public async Task TestReactionPagination() var r3 = await Client.Reactions.AddAsync("like", activity.Id, "bob", data); var r4 = await Client.Reactions.AddChildAsync(r3, "upvote", "tom", data); - var r5 = await Client.Reactions.AddChildAsync(r3, "upvote", "mary", data); + var r5 = await Client.Reactions.AddChildAsync(r3, Guid.NewGuid().ToString(), "upvote", "mary", data); // activity id var filter = ReactionFiltering.Default; diff --git a/tests/UtilsTests.cs b/tests/UtilsTests.cs index 31ae4ad..d9f1da2 100644 --- a/tests/UtilsTests.cs +++ b/tests/UtilsTests.cs @@ -1,23 +1,54 @@ -using Stream; +using NUnit.Framework; +using Stream.Models; +using Stream.Utils; using System; +using System.Threading.Tasks; namespace StreamNetTests { - public class Credentials + [TestFixture] + public class UtilsTests : TestBase { - internal Credentials() + [Test] + public void TestIdGenerator() { - Client = new StreamClient( - Environment.GetEnvironmentVariable("STREAM_API_KEY"), - Environment.GetEnvironmentVariable("STREAM_API_SECRET"), - new StreamClientOptions - { - Location = StreamApiLocation.USEast, - Timeout = 16000, - }); + // All these test cases are copied from the backend Go implementation + var first = ActivityIdGenerator.GenerateId(1451260800, "123sdsd333}}}"); + Assert.AreEqual("f01c0000-acf5-11e5-8080-80006a8b5bc2", first.ToString()); + + var second = ActivityIdGenerator.GenerateId(1452862482, "6621934"); + Assert.AreEqual("24f9d500-bb87-11e5-8080-80012ce3cc51", second.ToString()); + + var third = ActivityIdGenerator.GenerateId(1452862489, "6621938"); + Assert.AreEqual("2925f280-bb87-11e5-8080-8001597d791f", third.ToString()); + + var fourth = ActivityIdGenerator.GenerateId(1452862492, "6621941"); + Assert.AreEqual("2aefb600-bb87-11e5-8080-8001226fe2f2", fourth.ToString()); + + var fifth = ActivityIdGenerator.GenerateId(1452862496, "6621945"); + Assert.AreEqual("2d521000-bb87-11e5-8080-800026259780", fifth.ToString()); + + var sixth = ActivityIdGenerator.GenerateId(1463914800, "top_issues_summary_557dc1d9e46fea0a4c000002"); + Assert.AreEqual("53dbf800-200c-11e6-8080-800023ec2877", sixth.ToString()); + + var seventh = ActivityIdGenerator.GenerateId(1452866055, "6625387"); + Assert.AreEqual("76a65d80-bb8f-11e5-8080-8000530672db", seventh.ToString()); + + var eight = ActivityIdGenerator.GenerateId(1480174117, "UserPrediction:11127278"); + Assert.AreEqual("00000080-b3ed-11e6-8080-8000444ef374", eight.ToString()); + + var nineth = ActivityIdGenerator.GenerateId(1481475921, "467791-42-follow"); + Assert.AreEqual("ffa65e80-bfc3-11e6-8080-800027086507", nineth.ToString()); } - public static Credentials Instance { get; } = new Credentials(); - public StreamClient Client { get; } + [Test] + public async Task TestActivityIdSameAsBackend() + { + var foreignId = Guid.NewGuid().ToString(); + var inputAct = new Activity("1", "test", "1") { Time = DateTime.UtcNow, ForeignId = foreignId }; + var activity = await this.UserFeed.AddActivityAsync(inputAct); + + Assert.AreEqual(ActivityIdGenerator.GenerateId(activity.Time.Value, foreignId).ToString(), activity.Id); + } } }