From de0f076ef9ff546c9a90513259ad6c42cd2224b3 Mon Sep 17 00:00:00 2001 From: TrueDoctor Date: Sat, 29 Sep 2018 16:51:26 +0200 Subject: added firebase api --- FireBase/ExceptionEventArgs.cs | 28 ++ FireBase/Extensions/ObservableExtensions.cs | 40 ++ FireBase/Extensions/TaskExtensions.cs | 23 ++ FireBase/FireBase.csproj | 13 + FireBase/FirebaseClient.cs | 57 +++ FireBase/FirebaseException.cs | 63 +++ FireBase/FirebaseKeyGenerator.cs | 92 +++++ FireBase/FirebaseObject.cs | 31 ++ FireBase/FirebaseOptions.cs | 76 ++++ FireBase/Http/HttpClientExtensions.cs | 90 +++++ FireBase/Http/PostResult.cs | 17 + FireBase/ObservableExtensions.cs | 44 +++ FireBase/Offline/ConcurrentOfflineDatabase.cs | 207 ++++++++++ FireBase/Offline/DatabaseExtensions.cs | 195 +++++++++ FireBase/Offline/ISetHandler.cs | 11 + FireBase/Offline/InitialPullStrategy.cs | 23 ++ FireBase/Offline/Internals/MemberAccessVisitor.cs | 51 +++ FireBase/Offline/OfflineCacheAdapter.cs | 165 ++++++++ FireBase/Offline/OfflineDatabase.cs | 201 ++++++++++ FireBase/Offline/OfflineEntry.cs | 116 ++++++ FireBase/Offline/RealtimeDatabase.cs | 459 ++++++++++++++++++++++ FireBase/Offline/SetHandler.cs | 24 ++ FireBase/Offline/StreamingOptions.cs | 21 + FireBase/Offline/SyncOptions.cs | 28 ++ FireBase/Query/AuthQuery.cs | 33 ++ FireBase/Query/ChildQuery.cs | 56 +++ FireBase/Query/FilterQuery.cs | 81 ++++ FireBase/Query/FirebaseQuery.cs | 314 +++++++++++++++ FireBase/Query/IFirebaseQuery.cs | 43 ++ FireBase/Query/OrderQuery.cs | 34 ++ FireBase/Query/ParameterQuery.cs | 43 ++ FireBase/Query/QueryExtensions.cs | 207 ++++++++++ FireBase/Query/QueryFactoryExtensions.cs | 176 +++++++++ FireBase/Query/SilentQuery.cs | 18 + FireBase/Settings.StyleCop | 77 ++++ FireBase/Streaming/FirebaseCache.cs | 192 +++++++++ FireBase/Streaming/FirebaseEvent.cs | 40 ++ FireBase/Streaming/FirebaseEventSource.cs | 38 ++ FireBase/Streaming/FirebaseEventType.cs | 18 + FireBase/Streaming/FirebaseServerEventType.cs | 15 + FireBase/Streaming/FirebaseSubscription.cs | 221 +++++++++++ FireBase/Streaming/NonBlockingStreamReader.cs | 60 +++ 42 files changed, 3741 insertions(+) create mode 100644 FireBase/ExceptionEventArgs.cs create mode 100644 FireBase/Extensions/ObservableExtensions.cs create mode 100644 FireBase/Extensions/TaskExtensions.cs create mode 100644 FireBase/FireBase.csproj create mode 100644 FireBase/FirebaseClient.cs create mode 100644 FireBase/FirebaseException.cs create mode 100644 FireBase/FirebaseKeyGenerator.cs create mode 100644 FireBase/FirebaseObject.cs create mode 100644 FireBase/FirebaseOptions.cs create mode 100644 FireBase/Http/HttpClientExtensions.cs create mode 100644 FireBase/Http/PostResult.cs create mode 100644 FireBase/ObservableExtensions.cs create mode 100644 FireBase/Offline/ConcurrentOfflineDatabase.cs create mode 100644 FireBase/Offline/DatabaseExtensions.cs create mode 100644 FireBase/Offline/ISetHandler.cs create mode 100644 FireBase/Offline/InitialPullStrategy.cs create mode 100644 FireBase/Offline/Internals/MemberAccessVisitor.cs create mode 100644 FireBase/Offline/OfflineCacheAdapter.cs create mode 100644 FireBase/Offline/OfflineDatabase.cs create mode 100644 FireBase/Offline/OfflineEntry.cs create mode 100644 FireBase/Offline/RealtimeDatabase.cs create mode 100644 FireBase/Offline/SetHandler.cs create mode 100644 FireBase/Offline/StreamingOptions.cs create mode 100644 FireBase/Offline/SyncOptions.cs create mode 100644 FireBase/Query/AuthQuery.cs create mode 100644 FireBase/Query/ChildQuery.cs create mode 100644 FireBase/Query/FilterQuery.cs create mode 100644 FireBase/Query/FirebaseQuery.cs create mode 100644 FireBase/Query/IFirebaseQuery.cs create mode 100644 FireBase/Query/OrderQuery.cs create mode 100644 FireBase/Query/ParameterQuery.cs create mode 100644 FireBase/Query/QueryExtensions.cs create mode 100644 FireBase/Query/QueryFactoryExtensions.cs create mode 100644 FireBase/Query/SilentQuery.cs create mode 100644 FireBase/Settings.StyleCop create mode 100644 FireBase/Streaming/FirebaseCache.cs create mode 100644 FireBase/Streaming/FirebaseEvent.cs create mode 100644 FireBase/Streaming/FirebaseEventSource.cs create mode 100644 FireBase/Streaming/FirebaseEventType.cs create mode 100644 FireBase/Streaming/FirebaseServerEventType.cs create mode 100644 FireBase/Streaming/FirebaseSubscription.cs create mode 100644 FireBase/Streaming/NonBlockingStreamReader.cs (limited to 'FireBase') diff --git a/FireBase/ExceptionEventArgs.cs b/FireBase/ExceptionEventArgs.cs new file mode 100644 index 0000000..f1c7fac --- /dev/null +++ b/FireBase/ExceptionEventArgs.cs @@ -0,0 +1,28 @@ +namespace Firebase.Database +{ + using System; + + /// + /// Event args holding the object. + /// + public class ExceptionEventArgs : EventArgs where T : Exception + { + public readonly T Exception; + + /// + /// Initializes a new instance of the class. + /// + /// The exception. + public ExceptionEventArgs(T exception) + { + this.Exception = exception; + } + } + + public class ExceptionEventArgs : ExceptionEventArgs + { + public ExceptionEventArgs(Exception exception) : base(exception) + { + } + } +} diff --git a/FireBase/Extensions/ObservableExtensions.cs b/FireBase/Extensions/ObservableExtensions.cs new file mode 100644 index 0000000..12cd5f3 --- /dev/null +++ b/FireBase/Extensions/ObservableExtensions.cs @@ -0,0 +1,40 @@ +namespace Firebase.Database.Extensions +{ + using System; + using System.Reactive.Linq; + + public static class ObservableExtensions + { + /// + /// Returns a cold observable which retries (re-subscribes to) the source observable on error until it successfully terminates. + /// + /// The source observable. + /// How long to wait between attempts. + /// A predicate determining for which exceptions to retry. Defaults to all + /// + /// A cold observable which retries (re-subscribes to) the source observable on error up to the + /// specified number of times or until it successfully terminates. + /// + public static IObservable RetryAfterDelay( + this IObservable source, + TimeSpan dueTime, + Func retryOnError) + where TException: Exception + { + int attempt = 0; + + return Observable.Defer(() => + { + return ((++attempt == 1) ? source : source.DelaySubscription(dueTime)) + .Select(item => new Tuple(true, item, null)) + .Catch, TException>(e => retryOnError(e) + ? Observable.Throw>(e) + : Observable.Return(new Tuple(false, default(T), e))); + }) + .Retry() + .SelectMany(t => t.Item1 + ? Observable.Return(t.Item2) + : Observable.Throw(t.Item3)); + } + } +} diff --git a/FireBase/Extensions/TaskExtensions.cs b/FireBase/Extensions/TaskExtensions.cs new file mode 100644 index 0000000..26bbde6 --- /dev/null +++ b/FireBase/Extensions/TaskExtensions.cs @@ -0,0 +1,23 @@ +namespace Firebase.Database.Extensions +{ + using System; + using System.Threading.Tasks; + + public static class TaskExtensions + { + /// + /// Instead of unwrapping it throws it as it is. + /// + public static async Task WithAggregateException(this Task source) + { + try + { + await source.ConfigureAwait(false); + } + catch (Exception ex) + { + throw source.Exception ?? ex; + } + } + } +} diff --git a/FireBase/FireBase.csproj b/FireBase/FireBase.csproj new file mode 100644 index 0000000..889c32f --- /dev/null +++ b/FireBase/FireBase.csproj @@ -0,0 +1,13 @@ + + + + netcoreapp2.1 + + + + + + + + + diff --git a/FireBase/FirebaseClient.cs b/FireBase/FirebaseClient.cs new file mode 100644 index 0000000..a237c8d --- /dev/null +++ b/FireBase/FirebaseClient.cs @@ -0,0 +1,57 @@ +using System.Net.Http; + +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Firebase.Database.Tests")] + +namespace Firebase.Database +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + + using Firebase.Database.Offline; + using Firebase.Database.Query; + + /// + /// Firebase client which acts as an entry point to the online database. + /// + public class FirebaseClient : IDisposable + { + internal readonly HttpClient HttpClient; + internal readonly FirebaseOptions Options; + + private readonly string baseUrl; + + /// + /// Initializes a new instance of the class. + /// + /// The base url. + /// Offline database. + public FirebaseClient(string baseUrl, FirebaseOptions options = null) + { + this.HttpClient = new HttpClient(); + this.Options = options ?? new FirebaseOptions(); + + this.baseUrl = baseUrl; + + if (!this.baseUrl.EndsWith("/")) + { + this.baseUrl += "/"; + } + } + + /// + /// Queries for a child of the data root. + /// + /// Name of the child. + /// . + public ChildQuery Child(string resourceName) + { + return new ChildQuery(this, () => this.baseUrl + resourceName); + } + + public void Dispose() + { + HttpClient?.Dispose(); + } + } +} diff --git a/FireBase/FirebaseException.cs b/FireBase/FirebaseException.cs new file mode 100644 index 0000000..e4b782b --- /dev/null +++ b/FireBase/FirebaseException.cs @@ -0,0 +1,63 @@ +namespace Firebase.Database +{ + using System; + using System.Net; + + public class FirebaseException : Exception + { + public FirebaseException(string requestUrl, string requestData, string responseData, HttpStatusCode statusCode) + : base(GenerateExceptionMessage(requestUrl, requestData, responseData)) + { + this.RequestUrl = requestUrl; + this.RequestData = requestData; + this.ResponseData = responseData; + this.StatusCode = statusCode; + } + + public FirebaseException(string requestUrl, string requestData, string responseData, HttpStatusCode statusCode, Exception innerException) + : base(GenerateExceptionMessage(requestUrl, requestData, responseData), innerException) + { + this.RequestUrl = requestUrl; + this.RequestData = requestData; + this.ResponseData = responseData; + this.StatusCode = statusCode; + } + + /// + /// Post data passed to the authentication service. + /// + public string RequestData + { + get; + } + + /// + /// Original url of the request. + /// + public string RequestUrl + { + get; + } + + /// + /// Response from the authentication service. + /// + public string ResponseData + { + get; + } + + /// + /// Status code of the response. + /// + public HttpStatusCode StatusCode + { + get; + } + + private static string GenerateExceptionMessage(string requestUrl, string requestData, string responseData) + { + return $"Exception occured while processing the request.\nUrl: {requestUrl}\nRequest Data: {requestData}\nResponse: {responseData}"; + } + } +} diff --git a/FireBase/FirebaseKeyGenerator.cs b/FireBase/FirebaseKeyGenerator.cs new file mode 100644 index 0000000..acad399 --- /dev/null +++ b/FireBase/FirebaseKeyGenerator.cs @@ -0,0 +1,92 @@ +namespace Firebase.Database +{ + using System; + using System.Text; + + /// + /// Offline key generator which mimics the official Firebase generators. + /// Credit: https://github.com/bubbafat/FirebaseSharp/blob/master/src/FirebaseSharp.Portable/FireBasePushIdGenerator.cs + /// + public class FirebaseKeyGenerator + { + // Modeled after base64 web-safe chars, but ordered by ASCII. + private const string PushCharsString = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz"; + private static readonly char[] PushChars; + private static readonly DateTimeOffset Epoch = new DateTimeOffset(1970, 1, 1, 0, 0, 0, 0, TimeSpan.Zero); + + private static readonly Random random = new Random(); + private static readonly byte[] lastRandChars = new byte[12]; + + // Timestamp of last push, used to prevent local collisions if you push twice in one ms. + private static long lastPushTime; + + static FirebaseKeyGenerator() + { + PushChars = Encoding.UTF8.GetChars(Encoding.UTF8.GetBytes(PushCharsString)); + } + + /// + /// Returns next firebase key based on current time. + /// + /// + /// The . + public static string Next() + { + // We generate 72-bits of randomness which get turned into 12 characters and + // appended to the timestamp to prevent collisions with other clients. We store the last + // characters we generated because in the event of a collision, we'll use those same + // characters except "incremented" by one. + var id = new StringBuilder(20); + var now = (long)(DateTimeOffset.Now - Epoch).TotalMilliseconds; + var duplicateTime = now == lastPushTime; + lastPushTime = now; + + var timeStampChars = new char[8]; + for (int i = 7; i >= 0; i--) + { + var index = (int)(now % PushChars.Length); + timeStampChars[i] = PushChars[index]; + now = (long)Math.Floor((double)now / PushChars.Length); + } + + if (now != 0) + { + throw new Exception("We should have converted the entire timestamp."); + } + + id.Append(timeStampChars); + + if (!duplicateTime) + { + for (int i = 0; i < 12; i++) + { + lastRandChars[i] = (byte)random.Next(0, PushChars.Length); + } + } + else + { + // If the timestamp hasn't changed since last push, use the same random number, + // except incremented by 1. + var lastIndex = 11; + for (; lastIndex >= 0 && lastRandChars[lastIndex] == PushChars.Length - 1; lastIndex--) + { + lastRandChars[lastIndex] = 0; + } + + lastRandChars[lastIndex]++; + } + + for (int i = 0; i < 12; i++) + { + id.Append(PushChars[lastRandChars[i]]); + } + + if (id.Length != 20) + { + throw new Exception("Length should be 20."); + } + + return id.ToString(); + } + } +} diff --git a/FireBase/FirebaseObject.cs b/FireBase/FirebaseObject.cs new file mode 100644 index 0000000..ea61893 --- /dev/null +++ b/FireBase/FirebaseObject.cs @@ -0,0 +1,31 @@ +namespace Firebase.Database +{ + /// + /// Holds the object of type along with its key. + /// + /// Type of the underlying object. + public class FirebaseObject + { + internal FirebaseObject(string key, T obj) + { + this.Key = key; + this.Object = obj; + } + + /// + /// Gets the key of . + /// + public string Key + { + get; + } + + /// + /// Gets the underlying object. + /// + public T Object + { + get; + } + } +} diff --git a/FireBase/FirebaseOptions.cs b/FireBase/FirebaseOptions.cs new file mode 100644 index 0000000..9905956 --- /dev/null +++ b/FireBase/FirebaseOptions.cs @@ -0,0 +1,76 @@ +namespace Firebase.Database +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Threading.Tasks; + + using Firebase.Database.Offline; + + using Newtonsoft.Json; + + public class FirebaseOptions + { + public FirebaseOptions() + { + this.OfflineDatabaseFactory = (t, s) => new Dictionary(); + this.SubscriptionStreamReaderFactory = s => new StreamReader(s); + this.JsonSerializerSettings = new JsonSerializerSettings(); + this.SyncPeriod = TimeSpan.FromSeconds(10); + } + + /// + /// Gets or sets the factory for Firebase offline database. Default is in-memory dictionary. + /// + public Func> OfflineDatabaseFactory + { + get; + set; + } + + /// + /// Gets or sets the method for retrieving auth tokens. Default is null. + /// + public Func> AuthTokenAsyncFactory + { + get; + set; + } + + /// + /// Gets or sets the factory for used for reading online streams. Default is . + /// + public Func SubscriptionStreamReaderFactory + { + get; + set; + } + + /// + /// Gets or sets the json serializer settings. + /// + public JsonSerializerSettings JsonSerializerSettings + { + get; + set; + } + + /// + /// Gets or sets the time between synchronization attempts for pulling and pushing offline entities. Default is 10 seconds. + /// + public TimeSpan SyncPeriod + { + get; + set; + } + + /// + /// Specify if token returned by factory will be used as "auth" url parameter or "access_token". + /// + public bool AsAccessToken + { + get; + set; + } + } +} diff --git a/FireBase/Http/HttpClientExtensions.cs b/FireBase/Http/HttpClientExtensions.cs new file mode 100644 index 0000000..444145b --- /dev/null +++ b/FireBase/Http/HttpClientExtensions.cs @@ -0,0 +1,90 @@ +namespace Firebase.Database.Http +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.Linq; + using System.Net.Http; + using System.Threading.Tasks; + + using Newtonsoft.Json; + using System.Net; + + /// + /// The http client extensions for object deserializations. + /// + internal static class HttpClientExtensions + { + /// + /// The get object collection async. + /// + /// The client. + /// The request uri. + /// The specific JSON Serializer Settings. + /// The type of entities the collection should contain. + /// The . + public static async Task>> GetObjectCollectionAsync(this HttpClient client, string requestUri, + JsonSerializerSettings jsonSerializerSettings) + { + var responseData = string.Empty; + var statusCode = HttpStatusCode.OK; + + try + { + var response = await client.GetAsync(requestUri).ConfigureAwait(false); + statusCode = response.StatusCode; + responseData = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + var dictionary = JsonConvert.DeserializeObject>(responseData, jsonSerializerSettings); + + if (dictionary == null) + { + return new FirebaseObject[0]; + } + + return dictionary.Select(item => new FirebaseObject(item.Key, item.Value)).ToList(); + } + catch (Exception ex) + { + throw new FirebaseException(requestUri, string.Empty, responseData, statusCode, ex); + } + } + + /// + /// The get object collection async. + /// + /// The json data. + /// The type of entities the collection should contain. + /// The . + public static IEnumerable> GetObjectCollection(this string data, Type elementType) + { + var dictionaryType = typeof(Dictionary<,>).MakeGenericType(typeof(string), elementType); + IDictionary dictionary = null; + + if (data.StartsWith("[")) + { + var listType = typeof(List<>).MakeGenericType(elementType); + var list = JsonConvert.DeserializeObject(data, listType) as IList; + dictionary = Activator.CreateInstance(dictionaryType) as IDictionary; + var index = 0; + foreach (var item in list) dictionary.Add(index++.ToString(), item); + } + else + { + dictionary = JsonConvert.DeserializeObject(data, dictionaryType) as IDictionary; + } + + if (dictionary == null) + { + yield break; + } + + foreach (DictionaryEntry item in dictionary) + { + yield return new FirebaseObject((string)item.Key, item.Value); + } + } + } +} diff --git a/FireBase/Http/PostResult.cs b/FireBase/Http/PostResult.cs new file mode 100644 index 0000000..3f010d4 --- /dev/null +++ b/FireBase/Http/PostResult.cs @@ -0,0 +1,17 @@ +namespace Firebase.Database.Http +{ + /// + /// Represents data returned after a successful POST to firebase server. + /// + public class PostResult + { + /// + /// Gets or sets the generated key after a successful post. + /// + public string Name + { + get; + set; + } + } +} diff --git a/FireBase/ObservableExtensions.cs b/FireBase/ObservableExtensions.cs new file mode 100644 index 0000000..37c3ef7 --- /dev/null +++ b/FireBase/ObservableExtensions.cs @@ -0,0 +1,44 @@ +namespace Firebase.Database +{ + using System; + using System.Collections.ObjectModel; + + using Firebase.Database.Streaming; + + /// + /// Extensions for . + /// + public static class ObservableExtensions + { + /// + /// Starts observing on given firebase observable and propagates event into an . + /// + /// The observable. + /// Type of entity. + /// The . + public static ObservableCollection AsObservableCollection(this IObservable> observable) + { + var collection = new ObservableCollection(); + + observable.Subscribe(f => + { + if (f.EventType == FirebaseEventType.InsertOrUpdate) + { + var i = collection.IndexOf(f.Object); + if (i >= 0) + { + collection.RemoveAt(i); + } + + collection.Add(f.Object); + } + else + { + collection.Remove(f.Object); + } + }); + + return collection; + } + } +} diff --git a/FireBase/Offline/ConcurrentOfflineDatabase.cs b/FireBase/Offline/ConcurrentOfflineDatabase.cs new file mode 100644 index 0000000..226892d --- /dev/null +++ b/FireBase/Offline/ConcurrentOfflineDatabase.cs @@ -0,0 +1,207 @@ +namespace Firebase.Database.Offline +{ + using System; + using System.Collections; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.IO; + using System.Linq; + + using LiteDB; + + /// + /// The offline database. + /// + public class ConcurrentOfflineDatabase : IDictionary + { + private readonly LiteRepository db; + private readonly ConcurrentDictionary ccache; + + /// + /// Initializes a new instance of the class. + /// + /// The item type which is used to determine the database file name. + /// Custom string which will get appended to the file name. + public ConcurrentOfflineDatabase(Type itemType, string filenameModifier) + { + var fullName = this.GetFileName(itemType.ToString()); + if(fullName.Length > 100) + { + fullName = fullName.Substring(0, 100); + } + + BsonMapper mapper = BsonMapper.Global; + mapper.Entity().Id(o => o.Key); + + string root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + string filename = fullName + filenameModifier + ".db"; + var path = Path.Combine(root, filename); + this.db = new LiteRepository(new LiteDatabase(path, mapper)); + + var cache = db.Database + .GetCollection() + .FindAll() + .ToDictionary(o => o.Key, o => o); + + this.ccache = new ConcurrentDictionary(cache); + + } + + /// + /// Gets the number of elements contained in the . + /// + /// The number of elements contained in the . + public int Count => this.ccache.Count; + + /// + /// Gets a value indicating whether this is a read-only collection. + /// + public bool IsReadOnly => false; + + /// + /// Gets an containing the keys of the . + /// + /// An containing the keys of the object that implements . + public ICollection Keys => this.ccache.Keys; + + /// + /// Gets an containing the values in the . + /// + /// An containing the values in the object that implements . + public ICollection Values => this.ccache.Values; + + /// + /// Gets or sets the element with the specified key. + /// + /// The key of the element to get or set. + /// The element with the specified key. + public OfflineEntry this[string key] + { + get + { + return this.ccache[key]; + } + + set + { + this.ccache.AddOrUpdate(key, value, (k, existing) => value); + this.db.Upsert(value); + } + } + + /// + /// Returns an enumerator that iterates through the collection. + /// + /// An enumerator that can be used to iterate through the collection. + public IEnumerator> GetEnumerator() + { + return this.ccache.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + /// + /// Adds an item to the . + /// + /// The object to add to the . + public void Add(KeyValuePair item) + { + this.Add(item.Key, item.Value); + } + + /// + /// Removes all items from the . + /// + public void Clear() + { + this.ccache.Clear(); + this.db.Delete(Query.All()); + } + + /// + /// Determines whether the contains a specific value. + /// + /// The object to locate in the . + /// True if is found in the ; otherwise, false. + public bool Contains(KeyValuePair item) + { + return this.ContainsKey(item.Key); + } + + /// + /// Copies the elements of the to an , starting at a particular index. + /// + /// The one-dimensional that is the destination of the elements copied from . The must have zero-based indexing. + /// The zero-based index in at which copying begins. + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + this.ccache.ToList().CopyTo(array, arrayIndex); + } + + /// + /// Removes the first occurrence of a specific object from the . + /// + /// The object to remove from the . + /// True if was successfully removed from the ; otherwise, false. This method also returns false if is not found in the original . + public bool Remove(KeyValuePair item) + { + return this.Remove(item.Key); + } + + /// + /// Determines whether the contains an element with the specified key. + /// + /// The key to locate in the . + /// True if the contains an element with the key; otherwise, false. + public bool ContainsKey(string key) + { + return this.ccache.ContainsKey(key); + } + + /// + /// Adds an element with the provided key and value to the . + /// + /// The object to use as the key of the element to add. + /// The object to use as the value of the element to add. + public void Add(string key, OfflineEntry value) + { + this.ccache.AddOrUpdate(key, value, (k, existing) => value); + this.db.Upsert(value); + } + + /// + /// Removes the element with the specified key from the . + /// + /// The key of the element to remove. + /// True if the element is successfully removed; otherwise, false. This method also returns false if was not found in the original . + public bool Remove(string key) + { + this.ccache.TryRemove(key, out OfflineEntry _); + return this.db.Delete(key); + } + + /// + /// Gets the value associated with the specified key. + /// + /// The key whose value to get.When this method returns, the value associated with the specified key, if the key is found; otherwise, the default value for the type of the parameter. This parameter is passed uninitialized. + /// True if the object that implements contains an element with the specified key; otherwise, false. + public bool TryGetValue(string key, out OfflineEntry value) + { + return this.ccache.TryGetValue(key, out value); + } + + private string GetFileName(string fileName) + { + var invalidChars = new[] { '`', '[', ',', '=' }; + foreach(char c in invalidChars.Concat(System.IO.Path.GetInvalidFileNameChars()).Distinct()) + { + fileName = fileName.Replace(c, '_'); + } + + return fileName; + } + } +} diff --git a/FireBase/Offline/DatabaseExtensions.cs b/FireBase/Offline/DatabaseExtensions.cs new file mode 100644 index 0000000..4b04314 --- /dev/null +++ b/FireBase/Offline/DatabaseExtensions.cs @@ -0,0 +1,195 @@ +namespace Firebase.Database.Offline +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.Linq.Expressions; + using System.Reflection; + using Firebase.Database.Query; + + public static class DatabaseExtensions + { + /// + /// Create new instances of the . + /// + /// Type of elements. + /// Custom string which will get appended to the file name. + /// Optional custom root element of received json items. + /// Realtime streaming options. + /// Specifies what strategy should be used for initial pulling of server data. + /// Specifies whether changed items should actually be pushed to the server. It this is false, then Put / Post / Delete will not affect server data. + /// The . + public static RealtimeDatabase AsRealtimeDatabase(this ChildQuery query, string filenameModifier = "", string elementRoot = "", StreamingOptions streamingOptions = StreamingOptions.LatestOnly, InitialPullStrategy initialPullStrategy = InitialPullStrategy.MissingOnly, bool pushChanges = true) + where T : class + { + return new RealtimeDatabase(query, elementRoot, query.Client.Options.OfflineDatabaseFactory, filenameModifier, streamingOptions, initialPullStrategy, pushChanges); + } + + /// + /// Create new instances of the . + /// + /// Type of elements. + /// Type of the custom to use. + /// Custom string which will get appended to the file name. + /// Optional custom root element of received json items. + /// Realtime streaming options. + /// Specifies what strategy should be used for initial pulling of server data. + /// Specifies whether changed items should actually be pushed to the server. It this is false, then Put / Post / Delete will not affect server data. + /// The . + public static RealtimeDatabase AsRealtimeDatabase(this ChildQuery query, string filenameModifier = "", string elementRoot = "", StreamingOptions streamingOptions = StreamingOptions.LatestOnly, InitialPullStrategy initialPullStrategy = InitialPullStrategy.MissingOnly, bool pushChanges = true) + where T : class + where TSetHandler : ISetHandler, new() + { + return new RealtimeDatabase(query, elementRoot, query.Client.Options.OfflineDatabaseFactory, filenameModifier, streamingOptions, initialPullStrategy, pushChanges, Activator.CreateInstance()); + } + + /// + /// Overwrites existing object with given key leaving any missing properties intact in firebase. + /// + /// The key. + /// The object to set. + /// Indicates whether the item should be synced online. + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. + public static void Patch(this RealtimeDatabase db, string key, T obj, bool syncOnline = true, int priority = 1) + where T: class + { + db.Set(key, obj, syncOnline ? SyncOptions.Patch : SyncOptions.None, priority); + } + + /// + /// Overwrites existing object with given key. + /// + /// The key. + /// The object to set. + /// Indicates whether the item should be synced online. + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. + public static void Put(this RealtimeDatabase db, string key, T obj, bool syncOnline = true, int priority = 1) + where T: class + { + db.Set(key, obj, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// + /// Adds a new entity to the Database. + /// + /// The object to add. + /// Indicates whether the item should be synced online. + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. + /// The generated key for this object. + public static string Post(this RealtimeDatabase db, T obj, bool syncOnline = true, int priority = 1) + where T: class + { + var key = FirebaseKeyGenerator.Next(); + + db.Set(key, obj, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + + return key; + } + + /// + /// Deletes the entity with the given key. + /// + /// The key. + /// Indicates whether the item should be synced online. + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. + public static void Delete(this RealtimeDatabase db, string key, bool syncOnline = true, int priority = 1) + where T: class + { + db.Set(key, null, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// + /// Do a Put for a nested property specified by of an object with key . + /// + /// Type of the root elements. + /// Type of the property being modified + /// Database instance. + /// Key of the root element to modify. + /// Expression on the root element leading to target value to modify. + /// Value to put. + /// Indicates whether the item should be synced online. + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. + public static void Put(this RealtimeDatabase db, string key, Expression> propertyExpression, TProperty value, bool syncOnline = true, int priority = 1) + where T: class + { + db.Set(key, propertyExpression, value, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// + /// Do a Patch for a nested property specified by of an object with key . + /// + /// Type of the root elements. + /// Type of the property being modified + /// Database instance. + /// Key of the root element to modify. + /// Expression on the root element leading to target value to modify. + /// Value to patch. + /// Indicates whether the item should be synced online. + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. + public static void Patch(this RealtimeDatabase db, string key, Expression> propertyExpression, TProperty value, bool syncOnline = true, int priority = 1) + where T: class + { + db.Set(key, propertyExpression, value, syncOnline ? SyncOptions.Patch : SyncOptions.None, priority); + } + + /// + /// Delete a nested property specified by of an object with key . This basically does a Put with null value. + /// + /// Type of the root elements. + /// Type of the property being modified + /// Database instance. + /// Key of the root element to modify. + /// Expression on the root element leading to target value to modify. + /// Value to put. + /// Indicates whether the item should be synced online. + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. + public static void Delete(this RealtimeDatabase db, string key, Expression> propertyExpression, bool syncOnline = true, int priority = 1) + where T: class + where TProperty: class + { + db.Set(key, propertyExpression, null, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// + /// Post a new entity into the nested dictionary specified by of an object with key . + /// The key of the new entity is automatically generated. + /// + /// Type of the root elements. + /// Type of the dictionary being modified + /// Type of the value within the dictionary being modified + /// Database instance. + /// Key of the root element to modify. + /// Expression on the root element leading to target value to modify. + /// Value to put. + /// Indicates whether the item should be synced online. + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. + public static void Post(this RealtimeDatabase db, string key, Expression> propertyExpression, TProperty value, bool syncOnline = true, int priority = 1) + where T: class + where TSelector: IDictionary + { + var nextKey = FirebaseKeyGenerator.Next(); + var expression = Expression.Lambda>(Expression.Call(propertyExpression.Body, typeof(TSelector).GetRuntimeMethod("get_Item", new[] { typeof(string) }), Expression.Constant(nextKey)), propertyExpression.Parameters); + db.Set(key, expression, value, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + + /// + /// Delete an entity with key in the nested dictionary specified by of an object with key . + /// The key of the new entity is automatically generated. + /// + /// Type of the root elements. + /// Type of the dictionary being modified + /// Type of the value within the dictionary being modified + /// Database instance. + /// Key of the root element to modify. + /// Expression on the root element leading to target value to modify. + /// Key within the nested dictionary to delete. + /// Indicates whether the item should be synced online. + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. + public static void Delete(this RealtimeDatabase db, string key, Expression>> propertyExpression, string dictionaryKey, bool syncOnline = true, int priority = 1) + where T: class + { + var expression = Expression.Lambda>(Expression.Call(propertyExpression.Body, typeof(IDictionary).GetRuntimeMethod("get_Item", new[] { typeof(string) }), Expression.Constant(dictionaryKey)), propertyExpression.Parameters); + db.Set(key, expression, null, syncOnline ? SyncOptions.Put : SyncOptions.None, priority); + } + } +} diff --git a/FireBase/Offline/ISetHandler.cs b/FireBase/Offline/ISetHandler.cs new file mode 100644 index 0000000..477c36b --- /dev/null +++ b/FireBase/Offline/ISetHandler.cs @@ -0,0 +1,11 @@ +namespace Firebase.Database.Offline +{ + using Firebase.Database.Query; + + using System.Threading.Tasks; + + public interface ISetHandler + { + Task SetAsync(ChildQuery query, string key, OfflineEntry entry); + } +} diff --git a/FireBase/Offline/InitialPullStrategy.cs b/FireBase/Offline/InitialPullStrategy.cs new file mode 100644 index 0000000..70f6b8c --- /dev/null +++ b/FireBase/Offline/InitialPullStrategy.cs @@ -0,0 +1,23 @@ +namespace Firebase.Database.Offline +{ + /// + /// Specifies the strategy for initial pull of server data. + /// + public enum InitialPullStrategy + { + /// + /// Don't pull anything. + /// + None, + + /// + /// Pull only what isn't already stored offline. + /// + MissingOnly, + + /// + /// Pull everything that exists on the server. + /// + Everything, + } +} diff --git a/FireBase/Offline/Internals/MemberAccessVisitor.cs b/FireBase/Offline/Internals/MemberAccessVisitor.cs new file mode 100644 index 0000000..1f7cb11 --- /dev/null +++ b/FireBase/Offline/Internals/MemberAccessVisitor.cs @@ -0,0 +1,51 @@ +namespace Firebase.Database.Offline.Internals +{ + using System.Collections.Generic; + using System.Linq.Expressions; + using System.Reflection; + + using Newtonsoft.Json; + + public class MemberAccessVisitor : ExpressionVisitor + { + private readonly IList propertyNames = new List(); + + private bool wasDictionaryAccess; + + public IEnumerable PropertyNames => this.propertyNames; + + public MemberAccessVisitor() + { + } + + public override Expression Visit(Expression expr) + { + if (expr?.NodeType == ExpressionType.MemberAccess) + { + if (this.wasDictionaryAccess) + { + this.wasDictionaryAccess = false; + } + else + { + var memberExpr = (MemberExpression)expr; + var jsonAttr = memberExpr.Member.GetCustomAttribute(); + + this.propertyNames.Add(jsonAttr?.PropertyName ?? memberExpr.Member.Name); + } + } + else if (expr?.NodeType == ExpressionType.Call) + { + var callExpr = (MethodCallExpression)expr; + if (callExpr.Method.Name == "get_Item" && callExpr.Arguments.Count == 1) + { + var e = Expression.Lambda(callExpr.Arguments[0]).Compile(); + this.propertyNames.Add(e.DynamicInvoke().ToString()); + this.wasDictionaryAccess = callExpr.Arguments[0].NodeType == ExpressionType.MemberAccess; + } + } + + return base.Visit(expr); + } + } +} diff --git a/FireBase/Offline/OfflineCacheAdapter.cs b/FireBase/Offline/OfflineCacheAdapter.cs new file mode 100644 index 0000000..a3761a0 --- /dev/null +++ b/FireBase/Offline/OfflineCacheAdapter.cs @@ -0,0 +1,165 @@ +namespace Firebase.Database.Offline +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.Linq; + + internal class OfflineCacheAdapter : IDictionary, IDictionary + { + private readonly IDictionary database; + + public OfflineCacheAdapter(IDictionary database) + { + this.database = database; + } + + public void CopyTo(Array array, int index) + { + throw new NotImplementedException(); + } + + public int Count => this.database.Count; + + public bool IsSynchronized { get; } + + public object SyncRoot { get; } + + public bool IsReadOnly => this.database.IsReadOnly; + + object IDictionary.this[object key] + { + get + { + return this.database[key.ToString()].Deserialize(); + } + + set + { + var keyString = key.ToString(); + if (this.database.ContainsKey(keyString)) + { + this.database[keyString] = new OfflineEntry(keyString, value, this.database[keyString].Priority, this.database[keyString].SyncOptions); + } + else + { + this.database[keyString] = new OfflineEntry(keyString, value, 1, SyncOptions.None); + } + } + } + + public ICollection Keys => this.database.Keys; + + ICollection IDictionary.Values { get; } + + ICollection IDictionary.Keys { get; } + + public ICollection Values => this.database.Values.Select(o => o.Deserialize()).ToList(); + + public T this[string key] + { + get + { + return this.database[key].Deserialize(); + } + + set + { + if (this.database.ContainsKey(key)) + { + this.database[key] = new OfflineEntry(key, value, this.database[key].Priority, this.database[key].SyncOptions); + } + else + { + this.database[key] = new OfflineEntry(key, value, 1, SyncOptions.None); + } + } + } + + public bool Contains(object key) + { + return this.ContainsKey(key.ToString()); + } + + IDictionaryEnumerator IDictionary.GetEnumerator() + { + throw new NotImplementedException(); + } + + public void Remove(object key) + { + this.Remove(key.ToString()); + } + + public bool IsFixedSize => false; + + public IEnumerator> GetEnumerator() + { + return this.database.Select(d => new KeyValuePair(d.Key, d.Value.Deserialize())).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + public void Add(KeyValuePair item) + { + this.Add(item.Key, item.Value); + } + + public void Add(object key, object value) + { + this.Add(key.ToString(), (T)value); + } + + public void Clear() + { + this.database.Clear(); + } + + public bool Contains(KeyValuePair item) + { + return this.ContainsKey(item.Key); + } + + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + throw new NotImplementedException(); + } + + public bool Remove(KeyValuePair item) + { + return this.database.Remove(item.Key); + } + + public void Add(string key, T value) + { + this.database.Add(key, new OfflineEntry(key, value, 1, SyncOptions.None)); + } + + public bool ContainsKey(string key) + { + return this.database.ContainsKey(key); + } + + public bool Remove(string key) + { + return this.database.Remove(key); + } + + public bool TryGetValue(string key, out T value) + { + OfflineEntry val; + + if (this.database.TryGetValue(key, out val)) + { + value = val.Deserialize(); + return true; + } + + value = default(T); + return false; + } + } +} diff --git a/FireBase/Offline/OfflineDatabase.cs b/FireBase/Offline/OfflineDatabase.cs new file mode 100644 index 0000000..9cebf9c --- /dev/null +++ b/FireBase/Offline/OfflineDatabase.cs @@ -0,0 +1,201 @@ +namespace Firebase.Database.Offline +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.IO; + using System.Linq; + + using LiteDB; + + /// + /// The offline database. + /// + public class OfflineDatabase : IDictionary + { + private readonly LiteRepository db; + private readonly IDictionary cache; + + /// + /// Initializes a new instance of the class. + /// + /// The item type which is used to determine the database file name. + /// Custom string which will get appended to the file name. + public OfflineDatabase(Type itemType, string filenameModifier) + { + var fullName = this.GetFileName(itemType.ToString()); + if(fullName.Length > 100) + { + fullName = fullName.Substring(0, 100); + } + + BsonMapper mapper = BsonMapper.Global; + mapper.Entity().Id(o => o.Key); + + string root = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + string filename = fullName + filenameModifier + ".db"; + var path = Path.Combine(root, filename); + this.db = new LiteRepository(new LiteDatabase(path, mapper)); + + this.cache = db.Database.GetCollection().FindAll() + .ToDictionary(o => o.Key, o => o); + } + + /// + /// Gets the number of elements contained in the . + /// + /// The number of elements contained in the . + public int Count => this.cache.Count; + + /// + /// Gets a value indicating whether this is a read-only collection. + /// + public bool IsReadOnly => this.cache.IsReadOnly; + + /// + /// Gets an containing the keys of the . + /// + /// An containing the keys of the object that implements . + public ICollection Keys => this.cache.Keys; + + /// + /// Gets an containing the values in the . + /// + /// An containing the values in the object that implements . + public ICollection Values => this.cache.Values; + + /// + /// Gets or sets the element with the specified key. + /// + /// The key of the element to get or set. + /// The element with the specified key. + public OfflineEntry this[string key] + { + get + { + return this.cache[key]; + } + + set + { + this.cache[key] = value; + this.db.Upsert(value); + } + } + + /// + /// Returns an enumerator that iterates through the collection. + /// + /// An enumerator that can be used to iterate through the collection. + public IEnumerator> GetEnumerator() + { + return this.cache.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + /// + /// Adds an item to the . + /// + /// The object to add to the . + public void Add(KeyValuePair item) + { + this.Add(item.Key, item.Value); + } + + /// + /// Removes all items from the . + /// + public void Clear() + { + this.cache.Clear(); + this.db.Delete(Query.All()); + } + + /// + /// Determines whether the contains a specific value. + /// + /// The object to locate in the . + /// True if is found in the ; otherwise, false. + public bool Contains(KeyValuePair item) + { + return this.ContainsKey(item.Key); + } + + /// + /// Copies the elements of the to an , starting at a particular index. + /// + /// The one-dimensional that is the destination of the elements copied from . The must have zero-based indexing. + /// The zero-based index in at which copying begins. + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + this.cache.CopyTo(array, arrayIndex); + } + + /// + /// Removes the first occurrence of a specific object from the . + /// + /// The object to remove from the . + /// True if was successfully removed from the ; otherwise, false. This method also returns false if is not found in the original . + public bool Remove(KeyValuePair item) + { + return this.Remove(item.Key); + } + + /// + /// Determines whether the contains an element with the specified key. + /// + /// The key to locate in the . + /// True if the contains an element with the key; otherwise, false. + public bool ContainsKey(string key) + { + return this.cache.ContainsKey(key); + } + + /// + /// Adds an element with the provided key and value to the . + /// + /// The object to use as the key of the element to add. + /// The object to use as the value of the element to add. + public void Add(string key, OfflineEntry value) + { + this.cache.Add(key, value); + this.db.Insert(value); + } + + /// + /// Removes the element with the specified key from the . + /// + /// The key of the element to remove. + /// True if the element is successfully removed; otherwise, false. This method also returns false if was not found in the original . + public bool Remove(string key) + { + this.cache.Remove(key); + return this.db.Delete(key); + } + + /// + /// Gets the value associated with the specified key. + /// + /// The key whose value to get.When this method returns, the value associated with the specified key, if the key is found; otherwise, the default value for the type of the parameter. This parameter is passed uninitialized. + /// True if the object that implements contains an element with the specified key; otherwise, false. + public bool TryGetValue(string key, out OfflineEntry value) + { + return this.cache.TryGetValue(key, out value); + } + + private string GetFileName(string fileName) + { + var invalidChars = new[] { '`', '[', ',', '=' }; + foreach(char c in invalidChars.Concat(System.IO.Path.GetInvalidFileNameChars()).Distinct()) + { + fileName = fileName.Replace(c, '_'); + } + + return fileName; + } + } +} diff --git a/FireBase/Offline/OfflineEntry.cs b/FireBase/Offline/OfflineEntry.cs new file mode 100644 index 0000000..3b862cb --- /dev/null +++ b/FireBase/Offline/OfflineEntry.cs @@ -0,0 +1,116 @@ +namespace Firebase.Database.Offline +{ + using System; + + using Newtonsoft.Json; + + /// + /// Represents an object stored in offline storage. + /// + public class OfflineEntry + { + private object dataInstance; + + /// + /// Initializes a new instance of the class with an already serialized object. + /// + /// The key. + /// The object. + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. + /// The sync options. + public OfflineEntry(string key, object obj, string data, int priority, SyncOptions syncOptions, bool isPartial = false) + { + this.Key = key; + this.Priority = priority; + this.Data = data; + this.Timestamp = DateTime.UtcNow; + this.SyncOptions = syncOptions; + this.IsPartial = isPartial; + + this.dataInstance = obj; + } + + /// + /// Initializes a new instance of the class. + /// + /// The key. + /// The object. + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. + /// The sync options. + public OfflineEntry(string key, object obj, int priority, SyncOptions syncOptions, bool isPartial = false) + : this(key, obj, JsonConvert.SerializeObject(obj), priority, syncOptions, isPartial) + { + } + + /// + /// Initializes a new instance of the class. + /// + public OfflineEntry() + { + } + + /// + /// Gets or sets the key of this entry. + /// + public string Key + { + get; + set; + } + + /// + /// Gets or sets the priority. Objects with higher priority will be synced first. Higher number indicates higher priority. + /// + public int Priority + { + get; + set; + } + + /// + /// Gets or sets the timestamp when this entry was last touched. + /// + public DateTime Timestamp + { + get; + set; + } + + /// + /// Gets or sets the which define what sync state this entry is in. + /// + public SyncOptions SyncOptions + { + get; + set; + } + + /// + /// Gets or sets serialized JSON data. + /// + public string Data + { + get; + set; + } + + /// + /// Specifies whether this is only a partial object. + /// + public bool IsPartial + { + get; + set; + } + + /// + /// Deserializes into . The result is cached. + /// + /// Type of object to deserialize into. + /// Instance of . + public T Deserialize() + { + return (T)(this.dataInstance ?? (this.dataInstance = JsonConvert.DeserializeObject(this.Data))); + } + } +} diff --git a/FireBase/Offline/RealtimeDatabase.cs b/FireBase/Offline/RealtimeDatabase.cs new file mode 100644 index 0000000..61a7010 --- /dev/null +++ b/FireBase/Offline/RealtimeDatabase.cs @@ -0,0 +1,459 @@ +namespace Firebase.Database.Offline +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Reactive.Linq; + using System.Reactive.Subjects; + using System.Threading; + using System.Threading.Tasks; + + using Firebase.Database.Extensions; + using Firebase.Database.Query; + using Firebase.Database.Streaming; + using System.Reactive.Threading.Tasks; + using System.Linq.Expressions; + using Internals; + using Newtonsoft.Json; + using System.Reflection; + using System.Reactive.Disposables; + + /// + /// The real-time Database which synchronizes online and offline data. + /// + /// Type of entities. + public partial class RealtimeDatabase : IDisposable where T : class + { + private readonly ChildQuery childQuery; + private readonly string elementRoot; + private readonly StreamingOptions streamingOptions; + private readonly Subject> subject; + private readonly InitialPullStrategy initialPullStrategy; + private readonly bool pushChanges; + private readonly FirebaseCache firebaseCache; + + private bool isSyncRunning; + private IObservable> observable; + private FirebaseSubscription firebaseSubscription; + + /// + /// Initializes a new instance of the class. + /// + /// The child query. + /// The element Root. + /// The offline database factory. + /// Custom string which will get appended to the file name. + /// Specifies whether changes should be streamed from the server. + /// Specifies if everything should be pull from the online storage on start. It only makes sense when is set to true. + /// Specifies whether changed items should actually be pushed to the server. If this is false, then Put / Post / Delete will not affect server data. + public RealtimeDatabase(ChildQuery childQuery, string elementRoot, Func> offlineDatabaseFactory, string filenameModifier, StreamingOptions streamingOptions, InitialPullStrategy initialPullStrategy, bool pushChanges, ISetHandler setHandler = null) + { + this.childQuery = childQuery; + this.elementRoot = elementRoot; + this.streamingOptions = streamingOptions; + this.initialPullStrategy = initialPullStrategy; + this.pushChanges = pushChanges; + this.Database = offlineDatabaseFactory(typeof(T), filenameModifier); + this.firebaseCache = new FirebaseCache(new OfflineCacheAdapter(this.Database)); + this.subject = new Subject>(); + + this.PutHandler = setHandler ?? new SetHandler(); + + this.isSyncRunning = true; + Task.Factory.StartNew(this.SynchronizeThread, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + /// + /// Event raised whenever an exception is thrown in the synchronization thread. Exception thrown in there are swallowed, so this event is the only way to get to them. + /// + public event EventHandler SyncExceptionThrown; + + /// + /// Gets the backing Database. + /// + public IDictionary Database + { + get; + private set; + } + + public ISetHandler PutHandler + { + private get; + set; + } + + /// + /// Overwrites existing object with given key. + /// + /// The key. + /// The object to set. + /// Indicates whether the item should be synced online. + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. + public void Set(string key, T obj, SyncOptions syncOptions, int priority = 1) + { + this.SetAndRaise(key, new OfflineEntry(key, obj, priority, syncOptions)); + } + + public void Set(string key, Expression> propertyExpression, object value, SyncOptions syncOptions, int priority = 1) + { + var fullKey = this.GenerateFullKey(key, propertyExpression, syncOptions); + var serializedObject = JsonConvert.SerializeObject(value).Trim('"', '\\'); + + if (fullKey.Item3) + { + if (typeof(TProperty) != typeof(string) || value == null) + { + // don't escape non-string primitives and null; + serializedObject = $"{{ \"{fullKey.Item2}\" : {serializedObject} }}"; + } + else + { + serializedObject = $"{{ \"{fullKey.Item2}\" : \"{serializedObject}\" }}"; + } + } + + var setObject = this.firebaseCache.PushData("/" + fullKey.Item1, serializedObject).First(); + + if (!this.Database.ContainsKey(key) || this.Database[key].SyncOptions != SyncOptions.Patch && this.Database[key].SyncOptions != SyncOptions.Put) + { + this.Database[fullKey.Item1] = new OfflineEntry(fullKey.Item1, value, serializedObject, priority, syncOptions, true); + } + + this.subject.OnNext(new FirebaseEvent(key, setObject.Object, setObject == null ? FirebaseEventType.Delete : FirebaseEventType.InsertOrUpdate, FirebaseEventSource.Offline)); + } + + /// + /// Fetches an object with the given key and adds it to the Database. + /// + /// The key. + /// The priority. Objects with higher priority will be synced first. Higher number indicates higher priority. + public void Pull(string key, int priority = 1) + { + if (!this.Database.ContainsKey(key)) + { + this.Database[key] = new OfflineEntry(key, null, priority, SyncOptions.Pull); + } + else if (this.Database[key].SyncOptions == SyncOptions.None) + { + // pull only if push isn't pending + this.Database[key].SyncOptions = SyncOptions.Pull; + } + } + + /// + /// Fetches everything from the remote database. + /// + public async Task PullAsync() + { + var existingEntries = await this.childQuery + .OnceAsync() + .ToObservable() + .RetryAfterDelay>, FirebaseException>( + this.childQuery.Client.Options.SyncPeriod, + ex => ex.StatusCode == System.Net.HttpStatusCode.OK) // OK implies the request couldn't complete due to network error. + .Select(e => this.ResetDatabaseFromInitial(e, false)) + .SelectMany(e => e) + .Do(e => + { + this.Database[e.Key] = new OfflineEntry(e.Key, e.Object, 1, SyncOptions.None); + this.subject.OnNext(new FirebaseEvent(e.Key, e.Object, FirebaseEventType.InsertOrUpdate, FirebaseEventSource.OnlinePull)); + }) + .ToList(); + + // Remove items not stored online + foreach (var item in this.Database.Keys.Except(existingEntries.Select(f => f.Key)).ToList()) + { + this.Database.Remove(item); + this.subject.OnNext(new FirebaseEvent(item, null, FirebaseEventType.Delete, FirebaseEventSource.OnlinePull)); + } + } + + /// + /// Retrieves all offline items currently stored in local database. + /// + public IEnumerable> Once() + { + return this.Database + .Where(kvp => !string.IsNullOrEmpty(kvp.Value.Data) && kvp.Value.Data != "null" && !kvp.Value.IsPartial) + .Select(kvp => new FirebaseObject(kvp.Key, kvp.Value.Deserialize())) + .ToList(); + } + + /// + /// Starts observing the real-time Database. Events will be fired both when change is done locally and remotely. + /// + /// Stream of . + public IObservable> AsObservable() + { + if (!this.isSyncRunning) + { + this.isSyncRunning = true; + Task.Factory.StartNew(this.SynchronizeThread, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + if (this.observable == null) + { + var initialData = Observable.Return(FirebaseEvent.Empty(FirebaseEventSource.Offline)); + if(this.Database.TryGetValue(this.elementRoot, out OfflineEntry oe)) + { + initialData = Observable.Return(oe) + .Where(offlineEntry => !string.IsNullOrEmpty(offlineEntry.Data) && offlineEntry.Data != "null" && !offlineEntry.IsPartial) + .Select(offlineEntry => new FirebaseEvent(offlineEntry.Key, offlineEntry.Deserialize(), FirebaseEventType.InsertOrUpdate, FirebaseEventSource.Offline)); + } + else if(this.Database.Count > 0) + { + initialData = this.Database + .Where(kvp => !string.IsNullOrEmpty(kvp.Value.Data) && kvp.Value.Data != "null" && !kvp.Value.IsPartial) + .Select(kvp => new FirebaseEvent(kvp.Key, kvp.Value.Deserialize(), FirebaseEventType.InsertOrUpdate, FirebaseEventSource.Offline)) + .ToList() + .ToObservable(); + } + + this.observable = initialData + .Merge(this.subject) + .Merge(this.GetInitialPullObservable() + .RetryAfterDelay>, FirebaseException>( + this.childQuery.Client.Options.SyncPeriod, + ex => ex.StatusCode == System.Net.HttpStatusCode.OK) // OK implies the request couldn't complete due to network error. + .Select(e => this.ResetDatabaseFromInitial(e)) + .SelectMany(e => e) + .Do(this.SetObjectFromInitialPull) + .Select(e => new FirebaseEvent(e.Key, e.Object, e.Object == null ? FirebaseEventType.Delete : FirebaseEventType.InsertOrUpdate, FirebaseEventSource.OnlineInitial)) + .Concat(Observable.Create>(observer => this.InitializeStreamingSubscription(observer)))) + .Do(next => { }, e => this.observable = null, () => this.observable = null) + .Replay() + .RefCount(); + } + + return this.observable; + } + + public void Dispose() + { + this.subject.OnCompleted(); + this.firebaseSubscription?.Dispose(); + } + + private IReadOnlyCollection> ResetDatabaseFromInitial(IReadOnlyCollection> collection, bool onlyWhenInitialEverything = true) + { + if (onlyWhenInitialEverything && this.initialPullStrategy != InitialPullStrategy.Everything) + { + return collection; + } + + // items which are in local db, but not in the online collection + var extra = this.Once() + .Select(f => f.Key) + .Except(collection.Select(c => c.Key)) + .Select(k => new FirebaseObject(k, null)); + + return collection.Concat(extra).ToList(); + } + + private void SetObjectFromInitialPull(FirebaseObject e) + { + // set object with no sync only if it doesn't exist yet + // and the InitialPullStrategy != Everything + // this attempts to deal with scenario when you are offline, have local changes and go online + // in this case having the InitialPullStrategy set to everything would basically purge all local changes + if (!this.Database.ContainsKey(e.Key) || this.Database[e.Key].SyncOptions == SyncOptions.None || this.Database[e.Key].SyncOptions == SyncOptions.Pull || this.initialPullStrategy != InitialPullStrategy.Everything) + { + this.Database[e.Key] = new OfflineEntry(e.Key, e.Object, 1, SyncOptions.None); + } + } + + private IObservable>> GetInitialPullObservable() + { + FirebaseQuery query; + switch (this.initialPullStrategy) + { + case InitialPullStrategy.MissingOnly: + query = this.childQuery.OrderByKey().StartAt(() => this.GetLatestKey()); + break; + case InitialPullStrategy.Everything: + query = this.childQuery; + break; + case InitialPullStrategy.None: + default: + return Observable.Empty>>(); + } + + if (string.IsNullOrWhiteSpace(this.elementRoot)) + { + return Observable.Defer(() => query.OnceAsync().ToObservable()); + } + + // there is an element root, which indicates the target location is not a collection but a single element + return Observable.Defer(async () => Observable.Return(await query.OnceSingleAsync()).Select(e => new[] { new FirebaseObject(this.elementRoot, e) })); + } + + private IDisposable InitializeStreamingSubscription(IObserver> observer) + { + var completeDisposable = Disposable.Create(() => this.isSyncRunning = false); + + switch (this.streamingOptions) + { + case StreamingOptions.LatestOnly: + // stream since the latest key + var queryLatest = this.childQuery.OrderByKey().StartAt(() => this.GetLatestKey()); + this.firebaseSubscription = new FirebaseSubscription(observer, queryLatest, this.elementRoot, this.firebaseCache); + this.firebaseSubscription.ExceptionThrown += this.StreamingExceptionThrown; + + return new CompositeDisposable(this.firebaseSubscription.Run(), completeDisposable); + case StreamingOptions.Everything: + // stream everything + var queryAll = this.childQuery; + this.firebaseSubscription = new FirebaseSubscription(observer, queryAll, this.elementRoot, this.firebaseCache); + this.firebaseSubscription.ExceptionThrown += this.StreamingExceptionThrown; + + return new CompositeDisposable(this.firebaseSubscription.Run(), completeDisposable); + default: + break; + } + + return completeDisposable; + } + + private void SetAndRaise(string key, OfflineEntry obj, FirebaseEventSource eventSource = FirebaseEventSource.Offline) + { + this.Database[key] = obj; + this.subject.OnNext(new FirebaseEvent(key, obj?.Deserialize(), string.IsNullOrEmpty(obj?.Data) || obj?.Data == "null" ? FirebaseEventType.Delete : FirebaseEventType.InsertOrUpdate, eventSource)); + } + + private async void SynchronizeThread() + { + while (this.isSyncRunning) + { + try + { + var validEntries = this.Database.Where(e => e.Value != null); + await this.PullEntriesAsync(validEntries.Where(kvp => kvp.Value.SyncOptions == SyncOptions.Pull)); + + if (this.pushChanges) + { + await this.PushEntriesAsync(validEntries.Where(kvp => kvp.Value.SyncOptions == SyncOptions.Put || kvp.Value.SyncOptions == SyncOptions.Patch)); + } + } + catch (Exception ex) + { + this.SyncExceptionThrown?.Invoke(this, new ExceptionEventArgs(ex)); + } + + await Task.Delay(this.childQuery.Client.Options.SyncPeriod); + } + } + + private string GetLatestKey() + { + var key = this.Database.OrderBy(o => o.Key, StringComparer.Ordinal).LastOrDefault().Key ?? string.Empty; + + if (!string.IsNullOrWhiteSpace(key)) + { + key = key.Substring(0, key.Length - 1) + (char)(key[key.Length - 1] + 1); + } + + return key; + } + + private async Task PushEntriesAsync(IEnumerable> pushEntries) + { + var groups = pushEntries.GroupBy(pair => pair.Value.Priority).OrderByDescending(kvp => kvp.Key).ToList(); + + foreach (var group in groups) + { + var tasks = group.OrderBy(kvp => kvp.Value.IsPartial).Select(kvp => + kvp.Value.IsPartial ? + this.ResetSyncAfterPush(this.PutHandler.SetAsync(this.childQuery, kvp.Key, kvp.Value), kvp.Key) : + this.ResetSyncAfterPush(this.PutHandler.SetAsync(this.childQuery, kvp.Key, kvp.Value), kvp.Key, kvp.Value.Deserialize())); + + try + { + await Task.WhenAll(tasks).WithAggregateException(); + } + catch (Exception ex) + { + this.SyncExceptionThrown?.Invoke(this, new ExceptionEventArgs(ex)); + } + } + } + + private async Task PullEntriesAsync(IEnumerable> pullEntries) + { + var taskGroups = pullEntries.GroupBy(pair => pair.Value.Priority).OrderByDescending(kvp => kvp.Key); + + foreach (var group in taskGroups) + { + var tasks = group.Select(pair => this.ResetAfterPull(this.childQuery.Child(pair.Key == this.elementRoot ? string.Empty : pair.Key).OnceSingleAsync(), pair.Key, pair.Value)); + + try + { + await Task.WhenAll(tasks).WithAggregateException(); + } + catch (Exception ex) + { + this.SyncExceptionThrown?.Invoke(this, new ExceptionEventArgs(ex)); + } + } + } + + private async Task ResetAfterPull(Task task, string key, OfflineEntry entry) + { + await task; + this.SetAndRaise(key, new OfflineEntry(key, task.Result, entry.Priority, SyncOptions.None), FirebaseEventSource.OnlinePull); + } + + private async Task ResetSyncAfterPush(Task task, string key, T obj) + { + await this.ResetSyncAfterPush(task, key); + + if (this.streamingOptions == StreamingOptions.None) + { + this.subject.OnNext(new FirebaseEvent(key, obj, obj == null ? FirebaseEventType.Delete : FirebaseEventType.InsertOrUpdate, FirebaseEventSource.OnlinePush)); + } + } + + private async Task ResetSyncAfterPush(Task task, string key) + { + await task; + this.ResetSyncOptions(key); + } + + private void ResetSyncOptions(string key) + { + var item = this.Database[key]; + + if (item.IsPartial) + { + this.Database.Remove(key); + } + else + { + item.SyncOptions = SyncOptions.None; + this.Database[key] = item; + } + } + + private void StreamingExceptionThrown(object sender, ExceptionEventArgs e) + { + this.SyncExceptionThrown?.Invoke(this, new ExceptionEventArgs(e.Exception)); + } + + private Tuple GenerateFullKey(string key, Expression> propertyGetter, SyncOptions syncOptions) + { + var visitor = new MemberAccessVisitor(); + visitor.Visit(propertyGetter); + var propertyType = typeof(TProperty).GetTypeInfo(); + var prefix = key == string.Empty ? string.Empty : key + "/"; + + // primitive types + if (syncOptions == SyncOptions.Patch && (propertyType.IsPrimitive || Nullable.GetUnderlyingType(typeof(TProperty)) != null || typeof(TProperty) == typeof(string))) + { + return Tuple.Create(prefix + string.Join("/", visitor.PropertyNames.Skip(1).Reverse()), visitor.PropertyNames.First(), true); + } + + return Tuple.Create(prefix + string.Join("/", visitor.PropertyNames.Reverse()), visitor.PropertyNames.First(), false); + } + + } +} diff --git a/FireBase/Offline/SetHandler.cs b/FireBase/Offline/SetHandler.cs new file mode 100644 index 0000000..1efa7b6 --- /dev/null +++ b/FireBase/Offline/SetHandler.cs @@ -0,0 +1,24 @@ +namespace Firebase.Database.Offline +{ + using Firebase.Database.Query; + + using System.Threading.Tasks; + + public class SetHandler : ISetHandler + { + public virtual async Task SetAsync(ChildQuery query, string key, OfflineEntry entry) + { + using (var child = query.Child(key)) + { + if (entry.SyncOptions == SyncOptions.Put) + { + await child.PutAsync(entry.Data); + } + else + { + await child.PatchAsync(entry.Data); + } + } + } + } +} diff --git a/FireBase/Offline/StreamingOptions.cs b/FireBase/Offline/StreamingOptions.cs new file mode 100644 index 0000000..9ed4e54 --- /dev/null +++ b/FireBase/Offline/StreamingOptions.cs @@ -0,0 +1,21 @@ +namespace Firebase.Database.Offline +{ + public enum StreamingOptions + { + /// + /// No realtime streaming. + /// + None, + + /// + /// Streaming of only new items - not the existing ones. + /// + LatestOnly, + + /// + /// Streaming of all items. This will also pull all existing items on start, so be mindful about the number of items in your DB. + /// When used, consider not setting the to because you would pointlessly pull everything twice. + /// + Everything + } +} diff --git a/FireBase/Offline/SyncOptions.cs b/FireBase/Offline/SyncOptions.cs new file mode 100644 index 0000000..b2f382a --- /dev/null +++ b/FireBase/Offline/SyncOptions.cs @@ -0,0 +1,28 @@ +namespace Firebase.Database.Offline +{ + /// + /// Specifies type of sync requested for given data. + /// + public enum SyncOptions + { + /// + /// No sync needed for given data. + /// + None, + + /// + /// Data should be pulled from firebase. + /// + Pull, + + /// + /// Data should be put to firebase. + /// + Put, + + /// + /// Data should be patched in firebase. + /// + Patch + } +} diff --git a/FireBase/Query/AuthQuery.cs b/FireBase/Query/AuthQuery.cs new file mode 100644 index 0000000..8a8d3e8 --- /dev/null +++ b/FireBase/Query/AuthQuery.cs @@ -0,0 +1,33 @@ +namespace Firebase.Database.Query +{ + using System; + + /// + /// Represents an auth parameter in firebase query, e.g. "?auth=xyz". + /// + public class AuthQuery : ParameterQuery + { + private readonly Func tokenFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The parent. + /// The authentication token factory. + /// The owner. + public AuthQuery(FirebaseQuery parent, Func tokenFactory, FirebaseClient client) : base(parent, () => client.Options.AsAccessToken ? "access_token" : "auth", client) + { + this.tokenFactory = tokenFactory; + } + + /// + /// Build the url parameter value of this child. + /// + /// The child of this child. + /// The . + protected override string BuildUrlParameter(FirebaseQuery child) + { + return this.tokenFactory(); + } + } +} diff --git a/FireBase/Query/ChildQuery.cs b/FireBase/Query/ChildQuery.cs new file mode 100644 index 0000000..1696ea8 --- /dev/null +++ b/FireBase/Query/ChildQuery.cs @@ -0,0 +1,56 @@ +namespace Firebase.Database.Query +{ + using System; + + /// + /// Firebase query which references the child of current node. + /// + public class ChildQuery : FirebaseQuery + { + private readonly Func pathFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The parent. + /// The path to the child node. + /// The owner. + public ChildQuery(FirebaseQuery parent, Func pathFactory, FirebaseClient client) + : base(parent, client) + { + this.pathFactory = pathFactory; + } + + /// + /// Initializes a new instance of the class. + /// + /// The client. + /// The path to the child node. + public ChildQuery(FirebaseClient client, Func pathFactory) + : this(null, pathFactory, client) + { + } + + /// + /// Build the url segment of this child. + /// + /// The child of this child. + /// The . + protected override string BuildUrlSegment(FirebaseQuery child) + { + var s = this.pathFactory(); + + if (s != string.Empty && !s.EndsWith("/")) + { + s += '/'; + } + + if (!(child is ChildQuery)) + { + return s + ".json"; + } + + return s; + } + } +} diff --git a/FireBase/Query/FilterQuery.cs b/FireBase/Query/FilterQuery.cs new file mode 100644 index 0000000..f9f6271 --- /dev/null +++ b/FireBase/Query/FilterQuery.cs @@ -0,0 +1,81 @@ +namespace Firebase.Database.Query +{ + using System; + using System.Globalization; + + /// + /// Represents a firebase filtering query, e.g. "?LimitToLast=10". + /// + public class FilterQuery : ParameterQuery + { + private readonly Func valueFactory; + private readonly Func doubleValueFactory; + private readonly Func boolValueFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The parent. + /// The filter. + /// The value for filter. + /// The owning client. + public FilterQuery(FirebaseQuery parent, Func filterFactory, Func valueFactory, FirebaseClient client) + : base(parent, filterFactory, client) + { + this.valueFactory = valueFactory; + } + + /// + /// Initializes a new instance of the class. + /// + /// The parent. + /// The filter. + /// The value for filter. + /// The owning client. + public FilterQuery(FirebaseQuery parent, Func filterFactory, Func valueFactory, FirebaseClient client) + : base(parent, filterFactory, client) + { + this.doubleValueFactory = valueFactory; + } + + /// + /// Initializes a new instance of the class. + /// + /// The parent. + /// The filter. + /// The value for filter. + /// The owning client. + public FilterQuery(FirebaseQuery parent, Func filterFactory, Func valueFactory, FirebaseClient client) + : base(parent, filterFactory, client) + { + this.boolValueFactory = valueFactory; + } + + /// + /// The build url parameter. + /// + /// The child. + /// Url parameter part of the resulting path. + protected override string BuildUrlParameter(FirebaseQuery child) + { + if (this.valueFactory != null) + { + if(this.valueFactory() == null) + { + return $"null"; + } + return $"\"{this.valueFactory()}\""; + } + else if (this.doubleValueFactory != null) + { + return this.doubleValueFactory().ToString(CultureInfo.InvariantCulture); + } + else if (this.boolValueFactory != null) + { + return $"{this.boolValueFactory().ToString().ToLower()}"; + } + + return string.Empty; + } + } +} diff --git a/FireBase/Query/FirebaseQuery.cs b/FireBase/Query/FirebaseQuery.cs new file mode 100644 index 0000000..0e1b84a --- /dev/null +++ b/FireBase/Query/FirebaseQuery.cs @@ -0,0 +1,314 @@ +namespace Firebase.Database.Query +{ + using System; + using System.Collections.Generic; + using System.Net.Http; + using System.Reactive.Linq; + using System.Threading.Tasks; + + using Firebase.Database.Http; + using Firebase.Database.Offline; + using Firebase.Database.Streaming; + + using Newtonsoft.Json; + using System.Net; + + /// + /// Represents a firebase query. + /// + public abstract class FirebaseQuery : IFirebaseQuery, IDisposable + { + protected TimeSpan DEFAULT_HTTP_CLIENT_TIMEOUT = new TimeSpan(0, 0, 180); + + protected readonly FirebaseQuery Parent; + + private HttpClient client; + + /// + /// Initializes a new instance of the class. + /// + /// The parent of this query. + /// The owning client. + protected FirebaseQuery(FirebaseQuery parent, FirebaseClient client) + { + this.Client = client; + this.Parent = parent; + } + + /// + /// Gets the client. + /// + public FirebaseClient Client + { + get; + } + + /// + /// Queries the firebase server once returning collection of items. + /// + /// Optional timeout value. + /// Type of elements. + /// Collection of holding the entities returned by server. + public async Task>> OnceAsync(TimeSpan? timeout = null) + { + var url = string.Empty; + + try + { + url = await this.BuildUrlAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + throw new FirebaseException("Couldn't build the url", string.Empty, string.Empty, HttpStatusCode.OK, ex); + } + + return await this.GetClient(timeout).GetObjectCollectionAsync(url, Client.Options.JsonSerializerSettings) + .ConfigureAwait(false); + } + + + /// + /// Assumes given query is pointing to a single object of type and retrieves it. + /// + /// Optional timeout value. + /// Type of elements. + /// Single object of type . + public async Task OnceSingleAsync(TimeSpan? timeout = null) + { + var responseData = string.Empty; + var statusCode = HttpStatusCode.OK; + var url = string.Empty; + + try + { + url = await this.BuildUrlAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + throw new FirebaseException("Couldn't build the url", string.Empty, responseData, statusCode, ex); + } + + try + { + var response = await this.GetClient(timeout).GetAsync(url).ConfigureAwait(false); + statusCode = response.StatusCode; + responseData = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + response.Dispose(); + + return JsonConvert.DeserializeObject(responseData, Client.Options.JsonSerializerSettings); + } + catch (Exception ex) + { + throw new FirebaseException(url, string.Empty, responseData, statusCode, ex); + } + } + + /// + /// Starts observing this query watching for changes real time sent by the server. + /// + /// Type of elements. + /// Optional custom root element of received json items. + /// Observable stream of . + public IObservable> AsObservable(EventHandler> exceptionHandler = null, string elementRoot = "") + { + return Observable.Create>(observer => + { + var sub = new FirebaseSubscription(observer, this, elementRoot, new FirebaseCache()); + sub.ExceptionThrown += exceptionHandler; + return sub.Run(); + }); + } + + /// + /// Builds the actual URL of this query. + /// + /// The . + public async Task BuildUrlAsync() + { + // if token factory is present on the parent then use it to generate auth token + if (this.Client.Options.AuthTokenAsyncFactory != null) + { + var token = await this.Client.Options.AuthTokenAsyncFactory().ConfigureAwait(false); + return this.WithAuth(token).BuildUrl(null); + } + + return this.BuildUrl(null); + } + + /// + /// Posts given object to repository. + /// + /// The object. + /// Specifies whether the key should be generated offline instead of online. + /// Optional timeout value. + /// Type of + /// Resulting firebase object with populated key. + public async Task> PostAsync(string data, bool generateKeyOffline = true, TimeSpan? timeout = null) + { + // post generates a new key server-side, while put can be used with an already generated local key + if (generateKeyOffline) + { + var key = FirebaseKeyGenerator.Next(); + await new ChildQuery(this, () => key, this.Client).PutAsync(data).ConfigureAwait(false); + + return new FirebaseObject(key, data); + } + else + { + var c = this.GetClient(timeout); + var sendData = await this.SendAsync(c, data, HttpMethod.Post).ConfigureAwait(false); + var result = JsonConvert.DeserializeObject(sendData, Client.Options.JsonSerializerSettings); + + return new FirebaseObject(result.Name, data); + } + } + + /// + /// Patches data at given location instead of overwriting them. + /// + /// The object. + /// Optional timeout value. + /// Type of + /// The . + public async Task PatchAsync(string data, TimeSpan? timeout = null) + { + var c = this.GetClient(timeout); + + await this.Silent().SendAsync(c, data, new HttpMethod("PATCH")).ConfigureAwait(false); + } + + /// + /// Sets or overwrites data at given location. + /// + /// The object. + /// Optional timeout value. + /// Type of + /// The . + public async Task PutAsync(string data, TimeSpan? timeout = null) + { + var c = this.GetClient(timeout); + + await this.Silent().SendAsync(c, data, HttpMethod.Put).ConfigureAwait(false); + } + + /// + /// Deletes data from given location. + /// + /// Optional timeout value. + /// The . + public async Task DeleteAsync(TimeSpan? timeout = null) + { + var c = this.GetClient(timeout); + var url = string.Empty; + var responseData = string.Empty; + var statusCode = HttpStatusCode.OK; + + try + { + url = await this.BuildUrlAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + throw new FirebaseException("Couldn't build the url", string.Empty, responseData, statusCode, ex); + } + + try + { + var result = await c.DeleteAsync(url).ConfigureAwait(false); + statusCode = result.StatusCode; + responseData = await result.Content.ReadAsStringAsync().ConfigureAwait(false); + + result.EnsureSuccessStatusCode(); + } + catch (Exception ex) + { + throw new FirebaseException(url, string.Empty, responseData, statusCode, ex); + } + } + + /// + /// Disposes this instance. + /// + public void Dispose() + { + this.client?.Dispose(); + } + + /// + /// Build the url segment of this child. + /// + /// The child of this query. + /// The . + protected abstract string BuildUrlSegment(FirebaseQuery child); + + private string BuildUrl(FirebaseQuery child) + { + var url = this.BuildUrlSegment(child); + + if (this.Parent != null) + { + url = this.Parent.BuildUrl(this) + url; + } + + return url; + } + + private HttpClient GetClient(TimeSpan? timeout = null) + { + if (this.client == null) + { + this.client = new HttpClient(); + } + + if (!timeout.HasValue) + { + this.client.Timeout = DEFAULT_HTTP_CLIENT_TIMEOUT; + } + else + { + this.client.Timeout = timeout.Value; + } + + return this.client; + } + + private async Task SendAsync(HttpClient client, string data, HttpMethod method) + { + var responseData = string.Empty; + var statusCode = HttpStatusCode.OK; + var requestData = data; + var url = string.Empty; + + try + { + url = await this.BuildUrlAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + throw new FirebaseException("Couldn't build the url", requestData, responseData, statusCode, ex); + } + + var message = new HttpRequestMessage(method, url) + { + Content = new StringContent(requestData) + }; + + try + { + var result = await client.SendAsync(message).ConfigureAwait(false); + statusCode = result.StatusCode; + responseData = await result.Content.ReadAsStringAsync().ConfigureAwait(false); + + result.EnsureSuccessStatusCode(); + + return responseData; + } + catch (Exception ex) + { + throw new FirebaseException(url, requestData, responseData, statusCode, ex); + } + } + } +} diff --git a/FireBase/Query/IFirebaseQuery.cs b/FireBase/Query/IFirebaseQuery.cs new file mode 100644 index 0000000..2e8c671 --- /dev/null +++ b/FireBase/Query/IFirebaseQuery.cs @@ -0,0 +1,43 @@ +namespace Firebase.Database.Query +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + + using Firebase.Database.Streaming; + + /// + /// The FirebaseQuery interface. + /// + public interface IFirebaseQuery + { + /// + /// Gets the owning client of this query. + /// + FirebaseClient Client + { + get; + } + + /// + /// Retrieves items which exist on the location specified by this query instance. + /// + /// Optional timeout value. + /// Type of the items. + /// Collection of . + Task>> OnceAsync(TimeSpan? timeout = null); + + /// + /// Returns current location as an observable which allows to real-time listening to events from the firebase server. + /// + /// Type of the items. + /// Cold observable of . + IObservable> AsObservable(EventHandler> exceptionHandler, string elementRoot = ""); + + /// + /// Builds the actual url of this query. + /// + /// The . + Task BuildUrlAsync(); + } +} diff --git a/FireBase/Query/OrderQuery.cs b/FireBase/Query/OrderQuery.cs new file mode 100644 index 0000000..46ebd2c --- /dev/null +++ b/FireBase/Query/OrderQuery.cs @@ -0,0 +1,34 @@ +namespace Firebase.Database.Query +{ + using System; + + /// + /// Represents a firebase ordering query, e.g. "?OrderBy=Foo". + /// + public class OrderQuery : ParameterQuery + { + private readonly Func propertyNameFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The query parent. + /// The property name. + /// The owning client. + public OrderQuery(ChildQuery parent, Func propertyNameFactory, FirebaseClient client) + : base(parent, () => "orderBy", client) + { + this.propertyNameFactory = propertyNameFactory; + } + + /// + /// The build url parameter. + /// + /// The child. + /// The . + protected override string BuildUrlParameter(FirebaseQuery child) + { + return $"\"{this.propertyNameFactory()}\""; + } + } +} diff --git a/FireBase/Query/ParameterQuery.cs b/FireBase/Query/ParameterQuery.cs new file mode 100644 index 0000000..e3d9717 --- /dev/null +++ b/FireBase/Query/ParameterQuery.cs @@ -0,0 +1,43 @@ +namespace Firebase.Database.Query +{ + using System; + + /// + /// Represents a parameter in firebase query, e.g. "?data=foo". + /// + public abstract class ParameterQuery : FirebaseQuery + { + private readonly Func parameterFactory; + private readonly string separator; + + /// + /// Initializes a new instance of the class. + /// + /// The parent of this query. + /// The parameter. + /// The owning client. + protected ParameterQuery(FirebaseQuery parent, Func parameterFactory, FirebaseClient client) + : base(parent, client) + { + this.parameterFactory = parameterFactory; + this.separator = (this.Parent is ChildQuery) ? "?" : "&"; + } + + /// + /// Build the url segment represented by this query. + /// + /// The child. + /// The . + protected override string BuildUrlSegment(FirebaseQuery child) + { + return $"{this.separator}{this.parameterFactory()}={this.BuildUrlParameter(child)}"; + } + + /// + /// The build url parameter. + /// + /// The child. + /// The . + protected abstract string BuildUrlParameter(FirebaseQuery child); + } +} diff --git a/FireBase/Query/QueryExtensions.cs b/FireBase/Query/QueryExtensions.cs new file mode 100644 index 0000000..77db644 --- /dev/null +++ b/FireBase/Query/QueryExtensions.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace Firebase.Database.Query +{ + /// + /// Query extensions providing linq like syntax for firebase server methods. + /// + public static class QueryExtensions + { + /// + /// Adds an auth parameter to the query. + /// + /// The child. + /// The auth token. + /// The . + internal static AuthQuery WithAuth(this FirebaseQuery node, string token) + { + return node.WithAuth(() => token); + } + + /// + /// Appends print=silent to save bandwidth. + /// + /// The child. + /// The . + internal static SilentQuery Silent(this FirebaseQuery node) + { + return new SilentQuery(node, node.Client); + } + + /// + /// References a sub child of the existing node. + /// + /// The child. + /// The path of sub child. + /// The . + public static ChildQuery Child(this ChildQuery node, string path) + { + return node.Child(() => path); + } + + /// + /// Order data by given . Note that this is used mainly for following filtering queries and due to firebase implementation + /// the data may actually not be ordered. + /// + /// The child. + /// The property name. + /// The . + public static OrderQuery OrderBy(this ChildQuery child, string propertyName) + { + return child.OrderBy(() => propertyName); + } + + /// + /// Instructs firebase to send data greater or equal to the . This must be preceded by an OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery StartAt(this ParameterQuery child, string value) + { + return child.StartAt(() => value); + } + + /// + /// Instructs firebase to send data lower or equal to the . This must be preceded by an OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EndAt(this ParameterQuery child, string value) + { + return child.EndAt(() => value); + } + + /// + /// Instructs firebase to send data equal to the . This must be preceded by an OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EqualTo(this ParameterQuery child, string value) + { + return child.EqualTo(() => value); + } + + /// + /// Instructs firebase to send data greater or equal to the . This must be preceded by an OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery StartAt(this ParameterQuery child, double value) + { + return child.StartAt(() => value); + } + + /// + /// Instructs firebase to send data lower or equal to the . This must be preceded by an OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EndAt(this ParameterQuery child, double value) + { + return child.EndAt(() => value); + } + + /// + /// Instructs firebase to send data equal to the . This must be preceded by an OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EqualTo(this ParameterQuery child, double value) + { + return child.EqualTo(() => value); + } + + /// + /// Instructs firebase to send data equal to the . This must be preceded by an OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EqualTo(this ParameterQuery child, bool value) + { + return child.EqualTo(() => value); + } + + /// + /// Instructs firebase to send data equal to null. This must be preceded by an OrderBy query. + /// + /// Current node. + /// The . + public static FilterQuery EqualTo(this ParameterQuery child) + { + return child.EqualTo(() => null); + } + + /// + /// Limits the result to first items. + /// + /// Current node. + /// Number of elements. + /// The . + public static FilterQuery LimitToFirst(this ParameterQuery child, int count) + { + return child.LimitToFirst(() => count); + } + + /// + /// Limits the result to last items. + /// + /// Current node. + /// Number of elements. + /// The . + public static FilterQuery LimitToLast(this ParameterQuery child, int count) + { + return child.LimitToLast(() => count); + } + + public static Task PutAsync(this FirebaseQuery query, T obj) + { + return query.PutAsync(JsonConvert.SerializeObject(obj, query.Client.Options.JsonSerializerSettings)); + } + + public static Task PatchAsync(this FirebaseQuery query, T obj) + { + return query.PatchAsync(JsonConvert.SerializeObject(obj, query.Client.Options.JsonSerializerSettings)); + } + + public static async Task> PostAsync(this FirebaseQuery query, T obj, bool generateKeyOffline = true) + { + var result = await query.PostAsync(JsonConvert.SerializeObject(obj, query.Client.Options.JsonSerializerSettings), generateKeyOffline); + + return new FirebaseObject(result.Key, obj); + } + + /// + /// Fan out given item to multiple locations at once. See https://firebase.googleblog.com/2015/10/client-side-fan-out-for-data-consistency_73.html for details. + /// + /// Type of object to fan out. + /// Current node. + /// Object to fan out. + /// Locations where to store the item. + public static async Task FanOut(this ChildQuery child, T item, params string[] relativePaths) + { + if (relativePaths == null) + { + throw new ArgumentNullException(nameof(relativePaths)); + } + + var fanoutObject = new Dictionary(relativePaths.Length); + + foreach (var path in relativePaths) + { + fanoutObject.Add(path, item); + } + + await child.PatchAsync(fanoutObject); + } + } +} diff --git a/FireBase/Query/QueryFactoryExtensions.cs b/FireBase/Query/QueryFactoryExtensions.cs new file mode 100644 index 0000000..b36e74a --- /dev/null +++ b/FireBase/Query/QueryFactoryExtensions.cs @@ -0,0 +1,176 @@ +namespace Firebase.Database.Query +{ + using System; + + /// + /// Query extensions providing linq like syntax for firebase server methods. + /// + public static class QueryFactoryExtensions + { + /// + /// Adds an auth parameter to the query. + /// + /// The child. + /// The auth token. + /// The . + internal static AuthQuery WithAuth(this FirebaseQuery node, Func tokenFactory) + { + return new AuthQuery(node, tokenFactory, node.Client); + } + + /// + /// References a sub child of the existing node. + /// + /// The child. + /// The path of sub child. + /// The . + public static ChildQuery Child(this ChildQuery node, Func pathFactory) + { + return new ChildQuery(node, pathFactory, node.Client); + } + + /// + /// Order data by given . Note that this is used mainly for following filtering queries and due to firebase implementation + /// the data may actually not be ordered. + /// + /// The child. + /// The property name. + /// The . + public static OrderQuery OrderBy(this ChildQuery child, Func propertyNameFactory) + { + return new OrderQuery(child, propertyNameFactory, child.Client); + } + + /// + /// Order data by $key. Note that this is used mainly for following filtering queries and due to firebase implementation + /// the data may actually not be ordered. + /// + /// The child. + /// The . + public static OrderQuery OrderByKey(this ChildQuery child) + { + return child.OrderBy("$key"); + } + + /// + /// Order data by $value. Note that this is used mainly for following filtering queries and due to firebase implementation + /// the data may actually not be ordered. + /// + /// The child. + /// The . + public static OrderQuery OrderByValue(this ChildQuery child) + { + return child.OrderBy("$value"); + } + + /// + /// Order data by $priority. Note that this is used mainly for following filtering queries and due to firebase implementation + /// the data may actually not be ordered. + /// + /// The child. + /// The . + public static OrderQuery OrderByPriority(this ChildQuery child) + { + return child.OrderBy("$priority"); + } + + /// + /// Instructs firebase to send data greater or equal to the . This must be preceded by an OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery StartAt(this ParameterQuery child, Func valueFactory) + { + return new FilterQuery(child, () => "startAt", valueFactory, child.Client); + } + + /// + /// Instructs firebase to send data lower or equal to the . This must be preceded by an OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EndAt(this ParameterQuery child, Func valueFactory) + { + return new FilterQuery(child, () => "endAt", valueFactory, child.Client); + } + + /// + /// Instructs firebase to send data equal to the . This must be preceded by an OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EqualTo(this ParameterQuery child, Func valueFactory) + { + return new FilterQuery(child, () => "equalTo", valueFactory, child.Client); + } + + /// + /// Instructs firebase to send data greater or equal to the . This must be preceded by an OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery StartAt(this ParameterQuery child, Func valueFactory) + { + return new FilterQuery(child, () => "startAt", valueFactory, child.Client); + } + + /// + /// Instructs firebase to send data lower or equal to the . This must be preceded by an OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EndAt(this ParameterQuery child, Func valueFactory) + { + return new FilterQuery(child, () => "endAt", valueFactory, child.Client); + } + + /// + /// Instructs firebase to send data equal to the . This must be preceded by an OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EqualTo(this ParameterQuery child, Func valueFactory) + { + return new FilterQuery(child, () => "equalTo", valueFactory, child.Client); + } + + /// + /// Instructs firebase to send data equal to the . This must be preceded by an OrderBy query. + /// + /// Current node. + /// Value to start at. + /// The . + public static FilterQuery EqualTo(this ParameterQuery child, Func valueFactory) + { + return new FilterQuery(child, () => "equalTo", valueFactory, child.Client); + } + + /// + /// Limits the result to first items. + /// + /// Current node. + /// Number of elements. + /// The . + public static FilterQuery LimitToFirst(this ParameterQuery child, Func countFactory) + { + return new FilterQuery(child, () => "limitToFirst", () => countFactory(), child.Client); + } + + /// + /// Limits the result to last items. + /// + /// Current node. + /// Number of elements. + /// The . + public static FilterQuery LimitToLast(this ParameterQuery child, Func countFactory) + { + return new FilterQuery(child, () => "limitToLast", () => countFactory(), child.Client); + } + } +} diff --git a/FireBase/Query/SilentQuery.cs b/FireBase/Query/SilentQuery.cs new file mode 100644 index 0000000..15584f6 --- /dev/null +++ b/FireBase/Query/SilentQuery.cs @@ -0,0 +1,18 @@ +namespace Firebase.Database.Query +{ + /// + /// Appends print=silent to the url. + /// + public class SilentQuery : ParameterQuery + { + public SilentQuery(FirebaseQuery parent, FirebaseClient client) + : base(parent, () => "print", client) + { + } + + protected override string BuildUrlParameter(FirebaseQuery child) + { + return "silent"; + } + } +} diff --git a/FireBase/Settings.StyleCop b/FireBase/Settings.StyleCop new file mode 100644 index 0000000..833aa39 --- /dev/null +++ b/FireBase/Settings.StyleCop @@ -0,0 +1,77 @@ + + + + auth + firebase + json + linq + oauth + + + + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + False + + + + + True + + + + + True + + + + + True + True + False + + + + + + + True + + + + + + + \ No newline at end of file diff --git a/FireBase/Streaming/FirebaseCache.cs b/FireBase/Streaming/FirebaseCache.cs new file mode 100644 index 0000000..ba7990b --- /dev/null +++ b/FireBase/Streaming/FirebaseCache.cs @@ -0,0 +1,192 @@ +namespace Firebase.Database.Streaming +{ + using System; + using System.Collections; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + + using Firebase.Database.Http; + + using Newtonsoft.Json; + + /// + /// The firebase cache. + /// + /// Type of top-level entities in the cache. + public class FirebaseCache : IEnumerable> + { + private readonly IDictionary dictionary; + private readonly bool isDictionaryType; + private readonly JsonSerializerSettings serializerSettings = new JsonSerializerSettings() + { + ObjectCreationHandling = ObjectCreationHandling.Replace + }; + + /// + /// Initializes a new instance of the class. + /// + public FirebaseCache() + : this(new Dictionary()) + { + } + + /// + /// Initializes a new instance of the class and populates it with existing data. + /// + /// The existing items. + public FirebaseCache(IDictionary existingItems) + { + this.dictionary = existingItems; + this.isDictionaryType = typeof(IDictionary).GetTypeInfo().IsAssignableFrom(typeof(T).GetTypeInfo()); + } + + /// + /// The push data. + /// + /// The path of incoming data, separated by slash. + /// The data in json format as returned by firebase. + /// Collection of top-level entities which were affected by the push. + public IEnumerable> PushData(string path, string data, bool removeEmptyEntries = true) + { + object obj = this.dictionary; + Action primitiveObjSetter = null; + Action objDeleter = null; + + var pathElements = path.Split(new[] { "/" }, removeEmptyEntries ? StringSplitOptions.RemoveEmptyEntries : StringSplitOptions.None); + + // first find where we should insert the data to + foreach (var element in pathElements) + { + if (obj is IDictionary) + { + // if it's a dictionary, then it's just a matter of inserting into it / accessing existing object by key + var dictionary = obj as IDictionary; + var valueType = obj.GetType().GenericTypeArguments[1]; + + primitiveObjSetter = (d) => dictionary[element] = d; + objDeleter = () => dictionary.Remove(element); + + if (dictionary.Contains(element)) + { + obj = dictionary[element]; + } + else + { + dictionary[element] = this.CreateInstance(valueType); + obj = dictionary[element]; + } + } + else + { + // if it's not a dictionary, try to find the property of current object with the matching name + var objParent = obj; + var property = objParent + .GetType() + .GetRuntimeProperties() + .First(p => p.Name.Equals(element, StringComparison.OrdinalIgnoreCase) || element == p.GetCustomAttribute()?.PropertyName); + + objDeleter = () => property.SetValue(objParent, null); + primitiveObjSetter = (d) => property.SetValue(objParent, d); + obj = property.GetValue(obj); + if (obj == null) + { + obj = this.CreateInstance(property.PropertyType); + property.SetValue(objParent, obj); + } + } + } + + // if data is null (=empty string) delete it + if (string.IsNullOrWhiteSpace(data) || data == "null") + { + var key = pathElements[0]; + var target = this.dictionary[key]; + + objDeleter(); + + yield return new FirebaseObject(key, target); + yield break; + } + + // now insert the data + if (obj is IDictionary && !this.isDictionaryType) + { + // insert data into dictionary and return it as a collection of FirebaseObject + var dictionary = obj as IDictionary; + var valueType = obj.GetType().GenericTypeArguments[1]; + var objectCollection = data.GetObjectCollection(valueType); + + foreach (var item in objectCollection) + { + dictionary[item.Key] = item.Object; + + // top level dictionary changed + if (!pathElements.Any()) + { + yield return new FirebaseObject(item.Key, (T)item.Object); + } + } + + // nested dictionary changed + if (pathElements.Any()) + { + this.dictionary[pathElements[0]] = this.dictionary[pathElements[0]]; + yield return new FirebaseObject(pathElements[0], this.dictionary[pathElements[0]]); + } + } + else + { + // set the data on a property of the given object + var valueType = obj.GetType(); + + // firebase sends strings without double quotes + var targetObject = valueType == typeof(string) ? data.ToString() : JsonConvert.DeserializeObject(data, valueType); + + if ((valueType.GetTypeInfo().IsPrimitive || valueType == typeof(string)) && primitiveObjSetter != null) + { + // handle primitive (value) types separately + primitiveObjSetter(targetObject); + } + else + { + JsonConvert.PopulateObject(data, obj, this.serializerSettings); + } + + this.dictionary[pathElements[0]] = this.dictionary[pathElements[0]]; + yield return new FirebaseObject(pathElements[0], this.dictionary[pathElements[0]]); + } + } + + public bool Contains(string key) + { + return this.dictionary.Keys.Contains(key); + } + + private object CreateInstance(Type type) + { + if (type == typeof(string)) + { + return string.Empty; + } + else + { + return Activator.CreateInstance(type); + } + } + + #region IEnumerable + + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + public IEnumerator> GetEnumerator() + { + return this.dictionary.Select(p => new FirebaseObject(p.Key, p.Value)).GetEnumerator(); + } + + #endregion + } +} diff --git a/FireBase/Streaming/FirebaseEvent.cs b/FireBase/Streaming/FirebaseEvent.cs new file mode 100644 index 0000000..c2338ca --- /dev/null +++ b/FireBase/Streaming/FirebaseEvent.cs @@ -0,0 +1,40 @@ +namespace Firebase.Database.Streaming +{ + /// + /// Firebase event which hold and the object affected by the event. + /// + /// Type of object affected by the event. + public class FirebaseEvent : FirebaseObject + { + /// + /// Initializes a new instance of the class. + /// + /// The key of the object. + /// The object. + /// The event type. + public FirebaseEvent(string key, T obj, FirebaseEventType eventType, FirebaseEventSource eventSource) + : base(key, obj) + { + this.EventType = eventType; + this.EventSource = eventSource; + } + + /// + /// Gets the source of the event. + /// + public FirebaseEventSource EventSource + { + get; + } + + /// + /// Gets the event type. + /// + public FirebaseEventType EventType + { + get; + } + + public static FirebaseEvent Empty(FirebaseEventSource source) => new FirebaseEvent(string.Empty, default(T), FirebaseEventType.InsertOrUpdate, source); + } +} diff --git a/FireBase/Streaming/FirebaseEventSource.cs b/FireBase/Streaming/FirebaseEventSource.cs new file mode 100644 index 0000000..98df977 --- /dev/null +++ b/FireBase/Streaming/FirebaseEventSource.cs @@ -0,0 +1,38 @@ +namespace Firebase.Database.Streaming +{ + /// + /// Specifies the origin of given + /// + public enum FirebaseEventSource + { + /// + /// Event comes from an offline source. + /// + Offline, + + /// + /// Event comes from online source fetched during initial pull (valid only for RealtimeDatabase). + /// + OnlineInitial, + + /// + /// Event comes from online source received thru active stream. + /// + OnlineStream, + + /// + /// Event comes from online source being fetched manually. + /// + OnlinePull, + + /// + /// Event raised after successful online push (valid only for RealtimeDatabase which isn't streaming). + /// + OnlinePush, + + /// + /// Event comes from an online source. + /// + Online = OnlineInitial | OnlinePull | OnlinePush | OnlineStream + } +} diff --git a/FireBase/Streaming/FirebaseEventType.cs b/FireBase/Streaming/FirebaseEventType.cs new file mode 100644 index 0000000..5fb21ef --- /dev/null +++ b/FireBase/Streaming/FirebaseEventType.cs @@ -0,0 +1,18 @@ +namespace Firebase.Database.Streaming +{ + /// + /// The type of event. + /// + public enum FirebaseEventType + { + /// + /// Item was inserted or updated. + /// + InsertOrUpdate, + + /// + /// Item was deleted. + /// + Delete + } +} diff --git a/FireBase/Streaming/FirebaseServerEventType.cs b/FireBase/Streaming/FirebaseServerEventType.cs new file mode 100644 index 0000000..1f10bc8 --- /dev/null +++ b/FireBase/Streaming/FirebaseServerEventType.cs @@ -0,0 +1,15 @@ +namespace Firebase.Database.Streaming +{ + internal enum FirebaseServerEventType + { + Put, + + Patch, + + KeepAlive, + + Cancel, + + AuthRevoked + } +} diff --git a/FireBase/Streaming/FirebaseSubscription.cs b/FireBase/Streaming/FirebaseSubscription.cs new file mode 100644 index 0000000..4b5e643 --- /dev/null +++ b/FireBase/Streaming/FirebaseSubscription.cs @@ -0,0 +1,221 @@ +namespace Firebase.Database.Streaming +{ + using System; + using System.Diagnostics; + using System.Linq; + using System.Net.Http; + using System.Net.Http.Headers; + using System.Threading; + using System.Threading.Tasks; + + using Firebase.Database.Query; + + using Newtonsoft.Json.Linq; + using System.Net; + + /// + /// The firebase subscription. + /// + /// Type of object to be streaming back to the called. + internal class FirebaseSubscription : IDisposable + { + private readonly CancellationTokenSource cancel; + private readonly IObserver> observer; + private readonly IFirebaseQuery query; + private readonly FirebaseCache cache; + private readonly string elementRoot; + private readonly FirebaseClient client; + + private static HttpClient http; + + static FirebaseSubscription() + { + var handler = new HttpClientHandler + { + AllowAutoRedirect = true, + MaxAutomaticRedirections = 10, + CookieContainer = new CookieContainer() + }; + + var httpClient = new HttpClient(handler, true); + + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + + http = httpClient; + } + + /// + /// Initializes a new instance of the class. + /// + /// The observer. + /// The query. + /// The cache. + public FirebaseSubscription(IObserver> observer, IFirebaseQuery query, string elementRoot, FirebaseCache cache) + { + this.observer = observer; + this.query = query; + this.elementRoot = elementRoot; + this.cancel = new CancellationTokenSource(); + this.cache = cache; + this.client = query.Client; + } + + public event EventHandler> ExceptionThrown; + + public void Dispose() + { + this.cancel.Cancel(); + } + + public IDisposable Run() + { + Task.Run(() => this.ReceiveThread()); + + return this; + } + + private async void ReceiveThread() + { + while (true) + { + var url = string.Empty; + var line = string.Empty; + var statusCode = HttpStatusCode.OK; + + try + { + this.cancel.Token.ThrowIfCancellationRequested(); + + // initialize network connection + url = await this.query.BuildUrlAsync().ConfigureAwait(false); + var request = new HttpRequestMessage(HttpMethod.Get, url); + var serverEvent = FirebaseServerEventType.KeepAlive; + + var client = this.GetHttpClient(); + var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, this.cancel.Token).ConfigureAwait(false); + + statusCode = response.StatusCode; + response.EnsureSuccessStatusCode(); + + using (var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) + using (var reader = this.client.Options.SubscriptionStreamReaderFactory(stream)) + { + while (true) + { + this.cancel.Token.ThrowIfCancellationRequested(); + + line = reader.ReadLine()?.Trim(); + + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var tuple = line.Split(new[] { ':' }, 2, StringSplitOptions.RemoveEmptyEntries).Select(s => s.Trim()).ToArray(); + + switch (tuple[0].ToLower()) + { + case "event": + serverEvent = this.ParseServerEvent(serverEvent, tuple[1]); + break; + case "data": + this.ProcessServerData(url, serverEvent, tuple[1]); + break; + } + + if (serverEvent == FirebaseServerEventType.AuthRevoked) + { + // auth token no longer valid, reconnect + break; + } + } + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) when (statusCode != HttpStatusCode.OK) + { + this.observer.OnError(new FirebaseException(url, string.Empty, line, statusCode, ex)); + this.Dispose(); + break; + } + catch (Exception ex) + { + this.ExceptionThrown?.Invoke(this, new ExceptionEventArgs(new FirebaseException(url, string.Empty, line, statusCode, ex))); + + await Task.Delay(2000).ConfigureAwait(false); + } + } + } + + private FirebaseServerEventType ParseServerEvent(FirebaseServerEventType serverEvent, string eventName) + { + switch (eventName) + { + case "put": + serverEvent = FirebaseServerEventType.Put; + break; + case "patch": + serverEvent = FirebaseServerEventType.Patch; + break; + case "keep-alive": + serverEvent = FirebaseServerEventType.KeepAlive; + break; + case "cancel": + serverEvent = FirebaseServerEventType.Cancel; + break; + case "auth_revoked": + serverEvent = FirebaseServerEventType.AuthRevoked; + break; + } + + return serverEvent; + } + + private void ProcessServerData(string url, FirebaseServerEventType serverEvent, string serverData) + { + switch (serverEvent) + { + case FirebaseServerEventType.Put: + case FirebaseServerEventType.Patch: + var result = JObject.Parse(serverData); + var path = result["path"].ToString(); + var data = result["data"].ToString(); + + // If an elementRoot parameter is provided, but it's not in the cache, it was already deleted. So we can return an empty object. + if(string.IsNullOrWhiteSpace(this.elementRoot) || !this.cache.Contains(this.elementRoot)) + { + if(path == "/" && data == string.Empty) + { + this.observer.OnNext(FirebaseEvent.Empty(FirebaseEventSource.OnlineStream)); + return; + } + } + + var eventType = string.IsNullOrWhiteSpace(data) ? FirebaseEventType.Delete : FirebaseEventType.InsertOrUpdate; + + var items = this.cache.PushData(this.elementRoot + path, data); + + foreach (var i in items.ToList()) + { + this.observer.OnNext(new FirebaseEvent(i.Key, i.Object, eventType, FirebaseEventSource.OnlineStream)); + } + + break; + case FirebaseServerEventType.KeepAlive: + break; + case FirebaseServerEventType.Cancel: + this.observer.OnError(new FirebaseException(url, string.Empty, serverData, HttpStatusCode.Unauthorized)); + this.Dispose(); + break; + } + } + + private HttpClient GetHttpClient() + { + return http; + } + } +} diff --git a/FireBase/Streaming/NonBlockingStreamReader.cs b/FireBase/Streaming/NonBlockingStreamReader.cs new file mode 100644 index 0000000..2ac83fd --- /dev/null +++ b/FireBase/Streaming/NonBlockingStreamReader.cs @@ -0,0 +1,60 @@ +namespace Firebase.Database.Streaming +{ + using System.IO; + using System.Text; + + /// + /// When a regular is used in a UWP app its method tends to take a long + /// time for data larger then 2 KB. This extremly simple implementation of can be used instead to boost performance + /// in your UWP app. Use to inject an instance of this class into your . + /// + public class NonBlockingStreamReader : TextReader + { + private const int DefaultBufferSize = 16000; + + private readonly Stream stream; + private readonly byte[] buffer; + private readonly int bufferSize; + + private string cachedData; + + public NonBlockingStreamReader(Stream stream, int bufferSize = DefaultBufferSize) + { + this.stream = stream; + this.bufferSize = bufferSize; + this.buffer = new byte[bufferSize]; + + this.cachedData = string.Empty; + } + + public override string ReadLine() + { + var currentString = this.TryGetNewLine(); + + while (currentString == null) + { + var read = this.stream.Read(this.buffer, 0, this.bufferSize); + var str = Encoding.UTF8.GetString(buffer, 0, read); + + cachedData += str; + currentString = this.TryGetNewLine(); + } + + return currentString; + } + + private string TryGetNewLine() + { + var newLine = cachedData.IndexOf('\n'); + + if (newLine >= 0) + { + var r = cachedData.Substring(0, newLine + 1); + this.cachedData = cachedData.Remove(0, r.Length); + return r.Trim(); + } + + return null; + } + } +} -- cgit v1.2.3-54-g00ecf