diff --git a/.gitignore b/.gitignore index 41ceba8..9904d9c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,9 +16,6 @@ assets/ # App Settings appsettings.json -# Experimental -Triggers/ - # Private Keys *.pfx diff --git a/src/Converters/CoordinatesArrayJsonConverter.cs b/src/Converters/CoordinatesArrayJsonConverter.cs new file mode 100644 index 0000000..5ade645 --- /dev/null +++ b/src/Converters/CoordinatesArrayJsonConverter.cs @@ -0,0 +1,25 @@ +namespace OpenWeatherMap.Converters +{ + using System; + using Newtonsoft.Json; + using OpenWeatherMap.Triggers; + + internal sealed class CoordinatesArrayJsonConverter : JsonConverter + { + /// + public override bool CanConvert(Type objectType) => true; + + /// + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var coordinates = serializer.Deserialize(reader); + return new Coordinates(coordinates); + } + + /// + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + serializer.Serialize(writer, ((Coordinates)value).AsArray()); + } + } +} \ No newline at end of file diff --git a/src/Converters/DefaultJsonConverter.cs b/src/Converters/DefaultJsonConverter.cs new file mode 100644 index 0000000..42c4284 --- /dev/null +++ b/src/Converters/DefaultJsonConverter.cs @@ -0,0 +1,29 @@ +namespace OpenWeatherMap.Converters +{ + using System; + using Newtonsoft.Json; + + internal sealed class DefaultJsonConverter : JsonConverter + { + /// + public override bool CanRead => false; + + /// + public override bool CanWrite => false; + + /// + public override bool CanConvert(Type objectType) => true; + + /// + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + throw new NotSupportedException(); + } + + /// + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new NotSupportedException(); + } + } +} \ No newline at end of file diff --git a/src/Converters/GeoAreaJsonConverter.cs b/src/Converters/GeoAreaJsonConverter.cs new file mode 100644 index 0000000..bdfce77 --- /dev/null +++ b/src/Converters/GeoAreaJsonConverter.cs @@ -0,0 +1,51 @@ +namespace OpenWeatherMap.Converters +{ + using System; + using Newtonsoft.Json; + using Newtonsoft.Json.Linq; + using OpenWeatherMap.Triggers.Area; + + internal sealed class GeoAreaJsonConverter : JsonConverter + { + /// + public override bool CanWrite => false; + + /// + public override bool CanConvert(Type objectType) => true; + + /// + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var data = JObject.Load(reader); + var type = data["type"].ToObject(serializer); + var area = FromType(type); + + using (var tokenReader = data.CreateReader()) + { + serializer.Populate(tokenReader, area); + } + + return area; + } + + /// + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new NotSupportedException(); + } + + private static IGeoArea FromType(GeoAreaType type) + { + switch (type) + { + case GeoAreaType.Point: return new Point(); + case GeoAreaType.MultiPoint: return new MultiPoint(); + case GeoAreaType.Polygon: return new Polygon(); + case GeoAreaType.MultiPolygon: return new MultiPolygon(); + } + + throw new ArgumentOutOfRangeException(nameof(type), type, + "The specified type is not defined in the GeoAreaType enumeration."); + } + } +} \ No newline at end of file diff --git a/src/Entities/WeatherForecast.cs b/src/Entities/WeatherForecast.cs index 2ca017c..dc4e3a9 100644 --- a/src/Entities/WeatherForecast.cs +++ b/src/Entities/WeatherForecast.cs @@ -2,7 +2,6 @@ { using System.Collections.Generic; using Newtonsoft.Json; - using OpenWeatherMap.Entities; /// /// Represents the response object of a weather forecast request. diff --git a/src/OpenWeatherMap4NET.csproj b/src/OpenWeatherMap4NET.csproj index 5383869..5fd494e 100644 --- a/src/OpenWeatherMap4NET.csproj +++ b/src/OpenWeatherMap4NET.csproj @@ -8,7 +8,7 @@ A wrapper for the OpenWeatherMap.org RESTful api service. Angelo Breuer - 1.0.4 + 1.0.5 MIT https://github.com/angelobreuer/OpenWeatherMap4NET openweathermap, weather, wrapper, OpenWeatherMap4NET @@ -26,17 +26,6 @@ OpenWeatherMap4NET.xml - - - - - - - - - - - diff --git a/src/OpenWeatherMap4NET.xml b/src/OpenWeatherMap4NET.xml index 5d14e4a..5f9c43f 100644 --- a/src/OpenWeatherMap4NET.xml +++ b/src/OpenWeatherMap4NET.xml @@ -4,6 +4,42 @@ OpenWeatherMap4NET + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + The strongly-typed representation of a JSON-object providing additional weather @@ -227,50 +263,81 @@ The different weather condition types. + https://openweathermap.org/weather-conditions - + - Thunderstorm weather condition + Denotes that the weather condition type is "Ash". + + + + + Denotes that the weather condition type is "Clear". + + + + + Denotes that the weather condition type is "Clouds". - Drizzle weather condition + Denotes that the weather condition type is "Drizzle". + + + + + Denotes that the weather condition type is "Dust". + + + + + Denotes that the weather condition type is "Fog". + + + + + Denotes that the weather condition type is "Haze". + + + + + Denotes that the weather condition type is "Mist". - Rain weather condition + Denotes that the weather condition type is "Rain". - + - Snow weather condition + Denotes that the weather condition type is "Sand". - + - Atmosphere weather condition + Denotes that the weather condition type is "Smoke". - + - Clear weather condition + Denotes that the weather condition type is "Snow". - + - Clouds weather condition + Denotes that the weather condition type is "Squall". - + - Haze weather condition + Denotes that the weather condition type is "Thunderstorm". - + - Mist weather condition + Denotes that the weather condition type is "Tornado". @@ -660,6 +727,27 @@ a task that represents the asynchronous operation + + + + + + + + + + + + + + + + + + + + + A set of supported caching modes. @@ -706,6 +794,60 @@ Daily weather forecast for 16 days. + + + Represents a HTTP content which holds JSON-encoded data. + + + + + The media type for JSON-encoded HTTP content ("application/json"). + + + + + Initializes a new instance of the class. + + the JSON object to write + + the encoding to use when writing the JSON data; or to use + + + + thrown if the specified is . + + + + + Initializes a new instance of the class. + + the JSON token to write + + the encoding to use when writing the JSON data; or to use + + + + thrown if the specified is . + + + + + Gets the encoding to use when writing the JSON data. + + the encoding to use when writing the JSON data + + + + Gets the JSON token to write. + + the JSON token to write + + + + + + + Request options for . diff --git a/src/OpenWeatherMapOptions.cs b/src/OpenWeatherMapOptions.cs index bf82995..06ed658 100644 --- a/src/OpenWeatherMapOptions.cs +++ b/src/OpenWeatherMapOptions.cs @@ -17,6 +17,8 @@ public class OpenWeatherMapOptions /// public Uri BaseAddress { get; set; } = new Uri("https://api.openweathermap.org/data/"); + public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(40); + /// /// Validates the open weather map options. /// diff --git a/src/OpenWeatherMapService.cs b/src/OpenWeatherMapService.cs index 9972a8a..4316614 100644 --- a/src/OpenWeatherMapService.cs +++ b/src/OpenWeatherMapService.cs @@ -14,6 +14,10 @@ using System.Text; using System.Globalization; using System.Net; + using OpenWeatherMap.Triggers; + using Newtonsoft.Json.Linq; + using System.IO; + using System.Threading; /// /// The service for getting weather information. @@ -26,6 +30,8 @@ private static readonly DateTimeOffset _uvHistoryStartTime private readonly MemoryCache _cache; private readonly HttpClient _httpClient; private readonly OpenWeatherMapOptions _options; + private readonly ISet _triggerSubscriptions; + private Timer _pollTimer; /// /// Initializes a new instance of the class. @@ -38,6 +44,31 @@ public OpenWeatherMapService(OpenWeatherMapOptions options) _options = options ?? throw new ArgumentNullException(nameof(options)); _options.Validate(); + + _triggerSubscriptions = new HashSet(StringComparer.OrdinalIgnoreCase); + } + + public event EventHandler AlertReceived; + + public async Task CreateTriggerAsync(Trigger trigger, RequestOptions requestOptions = null) + { + requestOptions = requestOptions ?? RequestOptions.Default; + requestOptions.CancellationToken.ThrowIfCancellationRequested(); + + using (var content = new JsonContent(trigger)) + { + return await RequestAsync( + "triggers", requestOptions: requestOptions, doCache: false, method: HttpMethod.Post, + httpContent: content); + } + } + + public Task DeleteTriggerAsync(string id, RequestOptions requestOptions = null) + { + requestOptions = requestOptions ?? RequestOptions.Default; + requestOptions.CancellationToken.ThrowIfCancellationRequested(); + + return RequestAsync($"triggers/{id}", requestOptions: requestOptions, method: HttpMethod.Delete); } /// @@ -47,6 +78,9 @@ public virtual void Dispose() { _httpClient.Dispose(); _cache.Dispose(); + + _pollTimer?.Dispose(); + _pollTimer = null; } /// @@ -228,6 +262,14 @@ public Task GetCurrentWeatherAsync(int zipCode, string country = "us", return RequestAsync("weather", parameters, requestOptions); } + public Task GetTriggerAsync(string id, RequestOptions requestOptions = null) + { + requestOptions = requestOptions ?? RequestOptions.Default; + requestOptions.CancellationToken.ThrowIfCancellationRequested(); + + return RequestAsync($"triggers/{id}", requestOptions: requestOptions); + } + /// /// Gets the forecast UV index for the specified coordinates asynchronously. /// @@ -396,71 +438,53 @@ public Task GetWeatherForecastAsync(int zipCode, string country return RequestAsync(GetEndpoint(forecastType), parameters, requestOptions); } - private string BuildCityName(string city, string countryCode = null) - => countryCode == null ? city : city + "-" + countryCode; - - private string BuildRequestCacheKey(string uri, NameValueCollection parameters) + public async Task PollAsync(CancellationToken cancellationToken = default) { - var query = string.Join(";", parameters.Keys - .Cast().Select(s => s + ":" + parameters[s])); + cancellationToken.ThrowIfCancellationRequested(); - return $"req-{uri}-{{{query}}}"; - } - - private Uri BuildRequestUri(string uri, NameValueCollection queryParameters = null, RequestOptions requestOptions = default, string version = "2.5") - { - // create a new query string collection - var parameters = HttpUtility.ParseQueryString(string.Empty); + var requestOptions = new RequestOptions { CancellationToken = cancellationToken }; - // add query parameters if specified - if (queryParameters != null) + foreach (var subscription in _triggerSubscriptions) { - parameters.Add(queryParameters); - } + var trigger = await GetTriggerAsync(subscription, requestOptions); - // add the api key to the query parameter list - parameters.Add("APPID", _options.ApiKey); - - // add the specific unit if it is not the default (Kelvin) - if (requestOptions.Unit != UnitType.Default) - { - parameters.Add("units", requestOptions.Unit == UnitType.Imperial - ? "imperial" : "metric"); + if (trigger.Alerts.Count > 0) + { + var eventArgs = new AlertEventArgs(trigger, trigger.Alerts); + AlertReceived?.Invoke(this, eventArgs); + } } + } - // add the specific language when specified - if (!string.IsNullOrWhiteSpace(requestOptions.Language)) + public void Subscribe(string triggerId) + { + _triggerSubscriptions.Add(triggerId); + + if (_pollTimer is null) { - parameters.Add("lang", requestOptions.Language); + _pollTimer = new Timer(PollCallback, null, _options.PollInterval, _options.PollInterval); } - - // build request uri - var query = parameters.ToString(); - var requestUri = new Uri(_options.BaseAddress, version + "/" + uri); - var builder = new UriBuilder(requestUri) { Query = query }; - return builder.Uri; } - private string GetEndpoint(ForecastType forecastType) + public bool Unsubscribe(string triggerId) { - switch (forecastType) + if (_triggerSubscriptions.Remove(triggerId)) { - case ForecastType.ThreeHour: - return "forecast"; - - case ForecastType.Hourly: - return "forecast/hourly"; - - case ForecastType.Daily: - return "forecast/daily"; + if (_triggerSubscriptions.Count == 0) + { + _pollTimer?.Dispose(); + _pollTimer = null; + } - default: - throw new ArgumentException("Unknown forecast type.", nameof(forecastType)); + return true; } + + return false; } - private async Task RequestAsync(string uri, NameValueCollection queryParameters = null, RequestOptions requestOptions = default, - string version = "2.5", bool doCache = true, HttpMethod method = null, HttpContent httpContent = null) + protected async Task RequestAsync( + string uri, NameValueCollection queryParameters = null, RequestOptions requestOptions = default, + bool doCache = true, HttpMethod method = null, HttpContent httpContent = null) { method = method ?? HttpMethod.Get; requestOptions = requestOptions ?? RequestOptions.Default; @@ -488,47 +512,72 @@ private async Task RequestAsync(string uri, NameValueCollectio throw new InvalidOperationException("The request was not retrieved from cache."); } - // send request - var requestUri = BuildRequestUri(uri, queryParameters, requestOptions, version); - var result = await SendAsync(method, requestUri, httpContent); - - // resource not found - if (result == null) + using (var response = await SendRequestAsync(uri, queryParameters, requestOptions, method, httpContent)) { - return result; + // resource not found + if (response.StatusCode == HttpStatusCode.NotFound) + { + return default; + } + + using (var responseStream = await response.Content.ReadAsStreamAsync()) + using (var streamReader = new StreamReader(responseStream)) + using (var jsonTextReader = new JsonTextReader(streamReader)) + { + var data = await JToken.LoadAsync(jsonTextReader, requestOptions.CancellationToken); + var result = data.ToObject(); + + // add the result to the cache for 10 minutes + if (doCache) + { + _cache.Add(cacheKey, result, DateTimeOffset.UtcNow + TimeSpan.FromMinutes(10)); + } + + return result; + } } + } + + protected async Task RequestAsync( + string uri, NameValueCollection queryParameters = null, RequestOptions requestOptions = default, + HttpMethod method = null, HttpContent httpContent = null) + { + method = method ?? HttpMethod.Get; + requestOptions = requestOptions ?? RequestOptions.Default; + requestOptions.CancellationToken.ThrowIfCancellationRequested(); - // add the result to the cache for 10 minutes - if (doCache) + using (var response = await SendRequestAsync(uri, queryParameters, requestOptions, method, httpContent)) { - _cache.Add(cacheKey, result, DateTimeOffset.UtcNow + TimeSpan.FromMinutes(10)); + response.EnsureSuccessStatusCode(); } - - return result; } - private async Task SendAsync(HttpMethod httpMethod, Uri uri, HttpContent httpContent = null) + protected async Task SendRequestAsync( + string uri, NameValueCollection queryParameters = null, RequestOptions requestOptions = default, + HttpMethod method = null, HttpContent httpContent = null) { - // send request to the api - using (var request = new HttpRequestMessage(httpMethod, uri) { Content = httpContent }) - using (var response = await _httpClient.SendAsync(request)) - { - // read the result as a string - var content = await response.Content.ReadAsStringAsync(); + method = method ?? HttpMethod.Get; + requestOptions = requestOptions ?? RequestOptions.Default; + requestOptions.CancellationToken.ThrowIfCancellationRequested(); - // the resource was not found - if (response.StatusCode == HttpStatusCode.NotFound) - { - return default; - } + // send request + var requestUri = BuildRequestUri(uri, queryParameters, requestOptions); + + // send request to the API + using (var request = new HttpRequestMessage(method, requestUri) { Content = httpContent }) + { + var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, requestOptions.CancellationToken); // something went wrong if (!response.IsSuccessStatusCode) { + // read the result as a string + var content = await response.Content.ReadAsStringAsync(); + var messageBuilder = new StringBuilder(); messageBuilder.Append("OpenWeatherMap Request Error:"); messageBuilder.AppendFormat("\n- Got status {0} ({1}), expected: 2xx.", response.StatusCode, (int)response.StatusCode); - messageBuilder.AppendFormat("\n- Request Status Line: {0} {1}.", httpMethod.Method, uri); + messageBuilder.AppendFormat("\n- Request Status Line: {0} {1}.", method.Method, uri); messageBuilder.Append("\n- Response Headers:"); foreach (var header in response.Headers) @@ -540,8 +589,81 @@ private async Task SendAsync(HttpMethod httpMethod, Uri uri, H throw new InvalidOperationException(messageBuilder.ToString()); } - // deserialize the response - return JsonConvert.DeserializeObject(content); + return response; + } + } + + private string BuildCityName(string city, string countryCode = null) + => countryCode == null ? city : city + "-" + countryCode; + + private string BuildRequestCacheKey(string uri, NameValueCollection parameters) + { + var query = string.Join(";", parameters.Keys + .Cast().Select(s => s + ":" + parameters[s])); + + return $"req-{uri}-{{{query}}}"; + } + + private Uri BuildRequestUri(string uri, NameValueCollection queryParameters = null, RequestOptions requestOptions = default, string version = "3.0") + { + // create a new query string collection + var parameters = HttpUtility.ParseQueryString(string.Empty); + + // add query parameters if specified + if (queryParameters != null) + { + parameters.Add(queryParameters); + } + + // add the api key to the query parameter list + parameters.Add("APPID", _options.ApiKey); + + // add the specific unit if it is not the default (Kelvin) + if (requestOptions.Unit != UnitType.Default) + { + parameters.Add("units", requestOptions.Unit == UnitType.Imperial ? "imperial" : "metric"); + } + + // add the specific language when specified + if (!string.IsNullOrWhiteSpace(requestOptions.Language)) + { + parameters.Add("lang", requestOptions.Language); + } + + // build request uri + var query = parameters.ToString(); + var requestUri = new Uri(_options.BaseAddress, version + "/" + uri); + var builder = new UriBuilder(requestUri) { Query = query }; + return builder.Uri; + } + + private string GetEndpoint(ForecastType forecastType) + { + switch (forecastType) + { + case ForecastType.ThreeHour: + return "forecast"; + + case ForecastType.Hourly: + return "forecast/hourly"; + + case ForecastType.Daily: + return "forecast/daily"; + + default: + throw new ArgumentException("Unknown forecast type.", nameof(forecastType)); + } + } + + private void PollCallback(object state) + { + try + { + PollAsync().GetAwaiter().GetResult(); + } + catch (Exception) + { + // ignore exceptions, we do not throw exceptions on timers.. } } } diff --git a/src/OpenWeatherMapTriggerOptions.cs b/src/OpenWeatherMapTriggerOptions.cs deleted file mode 100644 index 141a699..0000000 --- a/src/OpenWeatherMapTriggerOptions.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace OpenWeatherMap -{ - using System; - - public class OpenWeatherMapTriggerOptions : OpenWeatherMapOptions - { - public TimeSpan PollInterval { get; internal set; } = TimeSpan.FromMinutes(2); - } -} \ No newline at end of file diff --git a/src/OpenWeatherMapTriggerService.cs b/src/OpenWeatherMapTriggerService.cs deleted file mode 100644 index 32a4ed7..0000000 --- a/src/OpenWeatherMapTriggerService.cs +++ /dev/null @@ -1,134 +0,0 @@ -namespace OpenWeatherMap -{ - using System; - using System.Collections.Generic; - using System.Linq; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - using OpenWeatherMap.Triggers; - using OpenWeatherMap.Util; - - public class OpenWeatherMapTriggerService : OpenWeatherMapService, IDisposable - { - private readonly OpenWeatherMapTriggerOptions _options; - private readonly CancellationTokenSource _cancellationTokenSource; - - public event Func AlertReceived; - - public override void Dispose() - { - base.Dispose(); - _cancellationTokenSource.Cancel(); - } - - protected virtual Task OnAlertReceived(AlertTrigger trigger, Alert alert) - => AlertReceived == null ? Task.CompletedTask : Task.WhenAll(AlertReceived.GetInvocationList() - .Select(s => s.DynamicInvoke(trigger, alert)).Cast()); - - public OpenWeatherMapTriggerService(OpenWeatherMapTriggerOptions options) : base(options) - { - _options = options ?? throw new ArgumentNullException(nameof(options)); - _cancellationTokenSource = new CancellationTokenSource(); - _ = GetUpdateTask(); - } - - public async Task ForcePullAlertsAsync(RequestOptions requestOptions = default) - { - Console.WriteLine("polling..."); - var triggers = await GetAllTriggersAsync(requestOptions); - var tasks = triggers.SelectMany(s => s.Alerts.Select(j => OnAlertReceived(s, j.Value))).ToArray(); - await Task.WhenAll(tasks); - Console.WriteLine("polling finished: " + tasks.Length); - return tasks.Length; - } - - public Task> GetAllTriggersAsync(RequestOptions requestOptions = default) - { - requestOptions = requestOptions ?? RequestOptions.Default; - requestOptions.CancellationToken.ThrowIfCancellationRequested(); - - return RequestAsync>("triggers", doCache: false, requestOptions: requestOptions, version: "3.0"); - } - - public async Task UpdateTriggerAsync(string id, Trigger trigger, RequestOptions requestOptions = default) - { - requestOptions = requestOptions ?? RequestOptions.Default; - requestOptions.CancellationToken.ThrowIfCancellationRequested(); - - using (var content = new JsonContent(JsonConvert.SerializeObject(trigger))) - { - return await RequestAsync($"trigger/{id}", null, requestOptions, "3.0", false, HttpMethod.Put, content); - } - } - - public Task GetHistoryAlertAsync(string triggerId, string alertId, RequestOptions requestOptions = default) - { - requestOptions = requestOptions ?? RequestOptions.Default; - requestOptions.CancellationToken.ThrowIfCancellationRequested(); - - return RequestAsync($"triggers/{triggerId}/history/{alertId}", null, requestOptions, version: "3.0", doCache: false); - } - - public Task GetTriggerAsync(string id, RequestOptions requestOptions = default) - { - requestOptions = requestOptions ?? RequestOptions.Default; - requestOptions.CancellationToken.ThrowIfCancellationRequested(); - - return RequestAsync($"triggers/{id}", null, requestOptions, version: "3.0", doCache: false); - } - - public Task> GetTriggerHistoryAsync(string id, RequestOptions requestOptions = default) - { - requestOptions = requestOptions ?? RequestOptions.Default; - requestOptions.CancellationToken.ThrowIfCancellationRequested(); - - return RequestAsync>($"triggers/{id}/history", null, requestOptions, "3.0", false); - } - - public async Task CreateTriggerAsync(Trigger trigger, RequestOptions requestOptions = default) - { - requestOptions = requestOptions ?? RequestOptions.Default; - requestOptions.CancellationToken.ThrowIfCancellationRequested(); - - using (var content = new JsonContent(JsonConvert.SerializeObject(trigger))) - { - return await RequestAsync("triggers", null, requestOptions, "3.0", false, HttpMethod.Post, content); - } - } - - public Task DeleteHistoryAsync(string triggerId, RequestOptions requestOptions = default) - { - requestOptions = requestOptions ?? RequestOptions.Default; - requestOptions.CancellationToken.ThrowIfCancellationRequested(); - - return RequestAsync($"triggers/{triggerId}/history", null, requestOptions, version: "3.0", doCache: false, HttpMethod.Delete); - } - - public Task DeleteTriggerAsync(string id, RequestOptions requestOptions = default) - { - requestOptions = requestOptions ?? RequestOptions.Default; - requestOptions.CancellationToken.ThrowIfCancellationRequested(); - - return RequestAsync($"triggers/{id}", null, requestOptions, - version: "3.0", doCache: false, method: HttpMethod.Delete); - } - - public Task DeleteTriggerHistorysync(string triggerId, string alertId, RequestOptions requestOptions = default) - { - requestOptions = requestOptions ?? RequestOptions.Default; - requestOptions.CancellationToken.ThrowIfCancellationRequested(); - - return RequestAsync($"triggers/{triggerId}/history/{alertId}", null, requestOptions, version: "3.0", doCache: false, HttpMethod.Delete); - } - - private async Task GetUpdateTask() - { - while (!_cancellationTokenSource.IsCancellationRequested) - { - var pullTask = ForcePullAlertsAsync(); - await Task.WhenAll(pullTask, Task.Delay(_options.PollInterval)); - } - } - } -} \ No newline at end of file diff --git a/src/Triggers/Alert.cs b/src/Triggers/Alert.cs new file mode 100644 index 0000000..92e972f --- /dev/null +++ b/src/Triggers/Alert.cs @@ -0,0 +1,23 @@ +namespace OpenWeatherMap.Triggers +{ + using System; + using System.Collections.Generic; + using Newtonsoft.Json; + using OpenWeatherMap.Converters; + + public sealed class Alert + { + [JsonRequired, JsonProperty("conditions")] + public IReadOnlyCollection Conditions { get; internal set; } + + [JsonRequired, JsonProperty("coordinates")] + [JsonConverter(typeof(DefaultJsonConverter))] + public Coordinates Coordinates { get; internal set; } + + [JsonRequired, JsonProperty("data")] + public DateTimeOffset Date { get; internal set; } + + [JsonRequired, JsonProperty("last_update")] + public DateTimeOffset LastUpdate { get; internal set; } + } +} \ No newline at end of file diff --git a/src/Triggers/AlertEventArgs.cs b/src/Triggers/AlertEventArgs.cs new file mode 100644 index 0000000..97fcf81 --- /dev/null +++ b/src/Triggers/AlertEventArgs.cs @@ -0,0 +1,17 @@ +namespace OpenWeatherMap.Triggers +{ + using System; + using System.Collections.Generic; + + public sealed class AlertEventArgs : EventArgs + { + public AlertEventArgs(Trigger trigger, IReadOnlyDictionary alerts) + { + Trigger = trigger ?? throw new ArgumentNullException(nameof(trigger)); + Alerts = alerts ?? throw new ArgumentNullException(nameof(alerts)); + } + + public IReadOnlyDictionary Alerts { get; } + public Trigger Trigger { get; } + } +} \ No newline at end of file diff --git a/src/Triggers/Area/GeoAreaType.cs b/src/Triggers/Area/GeoAreaType.cs new file mode 100644 index 0000000..f7a3332 --- /dev/null +++ b/src/Triggers/Area/GeoAreaType.cs @@ -0,0 +1,14 @@ +namespace OpenWeatherMap.Triggers.Area +{ + using Newtonsoft.Json; + using Newtonsoft.Json.Converters; + + [JsonConverter(typeof(StringEnumConverter))] + public enum GeoAreaType : byte + { + Point, + MultiPoint, + Polygon, + MultiPolygon + } +} \ No newline at end of file diff --git a/src/Triggers/Area/IGeoArea.cs b/src/Triggers/Area/IGeoArea.cs new file mode 100644 index 0000000..396e440 --- /dev/null +++ b/src/Triggers/Area/IGeoArea.cs @@ -0,0 +1,11 @@ +namespace OpenWeatherMap.Triggers.Area +{ + using Newtonsoft.Json; + using OpenWeatherMap.Converters; + + [JsonConverter(typeof(GeoAreaJsonConverter))] + public interface IGeoArea + { + GeoAreaType Type { get; } + } +} \ No newline at end of file diff --git a/src/Triggers/Area/MultiPoint.cs b/src/Triggers/Area/MultiPoint.cs new file mode 100644 index 0000000..2c5331b --- /dev/null +++ b/src/Triggers/Area/MultiPoint.cs @@ -0,0 +1,33 @@ +namespace OpenWeatherMap.Triggers.Area +{ + using System; + using System.Collections.Generic; + using Newtonsoft.Json; + using OpenWeatherMap.Converters; + + public sealed class MultiPoint : IGeoArea + { + public MultiPoint(IReadOnlyCollection coordinates) + { + Coordinates = coordinates ?? throw new ArgumentNullException(nameof(coordinates)); + } + + public MultiPoint(params Coordinates[] coordinates) + { + Coordinates = coordinates ?? throw new ArgumentNullException(nameof(coordinates)); + } + + [JsonConstructor] + internal MultiPoint() + { + } + + /// + [JsonRequired, JsonProperty("coordinates")] + public IReadOnlyCollection Coordinates { get; internal set; } + + /// + [JsonRequired, JsonProperty("type")] + public GeoAreaType Type => GeoAreaType.MultiPoint; + } +} \ No newline at end of file diff --git a/src/Triggers/Area/MultiPolygon.cs b/src/Triggers/Area/MultiPolygon.cs new file mode 100644 index 0000000..87b9e08 --- /dev/null +++ b/src/Triggers/Area/MultiPolygon.cs @@ -0,0 +1,32 @@ +namespace OpenWeatherMap.Triggers.Area +{ + using System; + using System.Collections.Generic; + using Newtonsoft.Json; + + public sealed class MultiPolygon : IGeoArea + { + public MultiPolygon(IReadOnlyCollection>> coordinates) + { + Coordinates = coordinates ?? throw new ArgumentNullException(nameof(coordinates)); + } + + public MultiPolygon(params Coordinates[][][] coordinates) + { + Coordinates = coordinates ?? throw new ArgumentNullException(nameof(coordinates)); + } + + [JsonConstructor] + internal MultiPolygon() + { + } + + /// + [JsonRequired, JsonProperty("coordinates")] + public IReadOnlyCollection>> Coordinates { get; internal set; } + + /// + [JsonRequired, JsonProperty("type")] + public GeoAreaType Type => GeoAreaType.Polygon; + } +} \ No newline at end of file diff --git a/src/Triggers/Area/Point.cs b/src/Triggers/Area/Point.cs new file mode 100644 index 0000000..8d60730 --- /dev/null +++ b/src/Triggers/Area/Point.cs @@ -0,0 +1,24 @@ +namespace OpenWeatherMap.Triggers.Area +{ + using Newtonsoft.Json; + + public sealed class Point : IGeoArea + { + public Point(Coordinates coordinates) + { + Coordinates = coordinates; + } + + [JsonConstructor] + internal Point() + { + } + + [JsonRequired, JsonProperty("coordinates")] + public Coordinates Coordinates { get; internal set; } + + /// + [JsonRequired, JsonProperty("type")] + public GeoAreaType Type => GeoAreaType.Point; + } +} \ No newline at end of file diff --git a/src/Triggers/Area/Polygon.cs b/src/Triggers/Area/Polygon.cs new file mode 100644 index 0000000..f567f5f --- /dev/null +++ b/src/Triggers/Area/Polygon.cs @@ -0,0 +1,32 @@ +namespace OpenWeatherMap.Triggers.Area +{ + using System; + using System.Collections.Generic; + using Newtonsoft.Json; + + public sealed class Polygon : IGeoArea + { + public Polygon(IReadOnlyCollection> coordinates) + { + Coordinates = coordinates ?? throw new ArgumentNullException(nameof(coordinates)); + } + + public Polygon(params Coordinates[][] coordinates) + { + Coordinates = coordinates ?? throw new ArgumentNullException(nameof(coordinates)); + } + + [JsonConstructor] + internal Polygon() + { + } + + /// + [JsonRequired, JsonProperty("coordinates")] + public IReadOnlyCollection> Coordinates { get; internal set; } + + /// + [JsonRequired, JsonProperty("type")] + public GeoAreaType Type => GeoAreaType.Polygon; + } +} \ No newline at end of file diff --git a/src/Triggers/Builders/AlertTriggerBuilder.cs b/src/Triggers/Builders/AlertTriggerBuilder.cs new file mode 100644 index 0000000..8fb40f0 --- /dev/null +++ b/src/Triggers/Builders/AlertTriggerBuilder.cs @@ -0,0 +1,60 @@ +namespace OpenWeatherMap.Triggers.Builders +{ + using System; + using System.Collections.Generic; + using System.Linq.Expressions; + using OpenWeatherMap.Triggers.Area; + using OpenWeatherMap.Triggers.Conditions; + using OpenWeatherMap.Triggers.Time; + + public sealed class AlertTriggerBuilder + { + private readonly List _areas; + private readonly List _conditions; + + private TimePeriod _period; + + public AlertTriggerBuilder() + { + _areas = new List(); + _conditions = new List(); + } + + public Trigger Build() + { + return new Trigger + { + Areas = _areas.ToArray(), + Conditions = _conditions.ToArray(), + Period = _period + }; + } + + public AlertTriggerBuilder WithArea(IGeoArea area) + { + _areas.Add(area); + return this; + } + + public AlertTriggerBuilder WithCondition(TriggerCondition condition) + { + _conditions.Add(condition); + return this; + } + + public AlertTriggerBuilder WithCondition(Expression> expression) + { + _conditions.AddRange(TriggerCondition.FromExpression(expression)); + return this; + } + + public AlertTriggerBuilder WithPeriod(TimePeriod period) + { + _period = period ?? throw new ArgumentNullException(nameof(period)); + return this; + } + + public AlertTriggerBuilder WithPeriod(DynamicTimestamp start, DynamicTimestamp end) + => WithPeriod(new TimePeriod { Start = start, End = end }); + } +} \ No newline at end of file diff --git a/src/Triggers/Conditions/TriggerCondition.cs b/src/Triggers/Conditions/TriggerCondition.cs new file mode 100644 index 0000000..795c385 --- /dev/null +++ b/src/Triggers/Conditions/TriggerCondition.cs @@ -0,0 +1,29 @@ +namespace OpenWeatherMap.Triggers.Conditions +{ + using System; + using System.Collections.Generic; + using System.Linq.Expressions; + using Newtonsoft.Json; + + public sealed class TriggerCondition + { + public TriggerCondition(WeatherTriggerName name, object value, WeatherExpressionType expression = WeatherExpressionType.Equal) + { + Name = name; + Expression = expression; + Value = value; + } + + [JsonRequired, JsonProperty("expression")] + public WeatherExpressionType Expression { get; } + + [JsonRequired, JsonProperty("name")] + public WeatherTriggerName Name { get; } + + [JsonRequired, JsonProperty("amount")] + public object Value { get; } + + public static IEnumerable FromExpression(Expression> expression) + => TriggerConditionExpressionConverter.FromExpression(expression); + } +} \ No newline at end of file diff --git a/src/Triggers/Conditions/TriggerConditionExpressionConverter.cs b/src/Triggers/Conditions/TriggerConditionExpressionConverter.cs new file mode 100644 index 0000000..cbae312 --- /dev/null +++ b/src/Triggers/Conditions/TriggerConditionExpressionConverter.cs @@ -0,0 +1,130 @@ +namespace OpenWeatherMap.Triggers.Conditions +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Linq.Expressions; + using System.Reflection; + + internal static class TriggerConditionExpressionConverter + { + public static IEnumerable FromExpression(Expression> expression) + { + if (expression.CanReduce) + { + expression = (Expression>)expression.ReduceAndCheck(); + } + + if (!(expression is LambdaExpression lambdaExpression)) + { + throw new ArgumentException("Expected lambda expression: " + expression, nameof(expression)); + } + + if (!(lambdaExpression.Body is BinaryExpression binaryExpression)) + { + throw new ArgumentException("Expected binary expression: " + lambdaExpression.Body, nameof(expression)); + } + + return VisitAndAlsoExpressionChain(binaryExpression).Select(CreateFromExpression); + } + + private static TriggerCondition CreateFromExpression(BinaryExpression expression) + { + var expressionType = ResolveExpressionType(expression); + + var leftConstantExpression = expression.Left as ConstantExpression; + var rightConstantExpression = expression.Right as ConstantExpression; + + if ((leftConstantExpression is null) == (rightConstantExpression is null)) + { + var failedExpression = leftConstantExpression is null ? expression.Right : expression.Left; + throw new InvalidOperationException("Could not convert expression: " + failedExpression); + } + + var constantValue = leftConstantExpression is null ? rightConstantExpression.Value : leftConstantExpression.Value; + var propertyExpression = (leftConstantExpression is null ? expression.Left : expression.Right) as MemberExpression; + + if (propertyExpression is null) + { + var failedExpression = leftConstantExpression is null ? expression.Left : expression.Right; + throw new InvalidOperationException("Could not convert expression: " + failedExpression); + } + + if (propertyExpression.Member.DeclaringType != typeof(WeatherTriggerCondition)) + { + throw new InvalidOperationException("Could not convert expression: " + propertyExpression); + } + + if (((PropertyInfo)propertyExpression.Member).PropertyType != constantValue.GetType()) + { + throw new InvalidOperationException("Could not convert expression: " + expression); + } + + var triggerName = ResolveTriggerName(propertyExpression.Member.Name); + return new TriggerCondition(triggerName, constantValue, expressionType); + } + + private static WeatherExpressionType ResolveExpressionType(BinaryExpression expression) + { + switch (expression.NodeType) + { + case ExpressionType.Equal: return WeatherExpressionType.Equal; + case ExpressionType.NotEqual: return WeatherExpressionType.NotEqual; + case ExpressionType.GreaterThan: return WeatherExpressionType.GreaterThan; + case ExpressionType.LessThan: return WeatherExpressionType.LessThan; + case ExpressionType.GreaterThanOrEqual: return WeatherExpressionType.GreaterThan; + case ExpressionType.LessThanOrEqual: return WeatherExpressionType.GreaterThanOrEqual; + } + + throw new ArgumentOutOfRangeException( + nameof(expression.NodeType), expression.NodeType, + "Unsupported logical expression type: " + expression); + } + + private static WeatherTriggerName ResolveTriggerName(string propertyName) + { + switch (propertyName) + { + case nameof(WeatherTriggerCondition.Clouds): return WeatherTriggerName.Clouds; + case nameof(WeatherTriggerCondition.Humidity): return WeatherTriggerName.Humidity; + case nameof(WeatherTriggerCondition.Pressure): return WeatherTriggerName.Pressure; + case nameof(WeatherTriggerCondition.Temperature): return WeatherTriggerName.Temperature; + case nameof(WeatherTriggerCondition.WindDirection): return WeatherTriggerName.WindDirection; + case nameof(WeatherTriggerCondition.WindSpeed): return WeatherTriggerName.WindSpeed; + } + + throw new ArgumentException("Failed to resolve weather trigger type.", nameof(propertyName)); + } + + private static IEnumerable VisitAndAlsoExpressionChain(BinaryExpression expression) + { + var enumerable = Enumerable.Empty(); + + if (expression.NodeType == ExpressionType.AndAlso) + { + if (!(expression.Left is BinaryExpression leftBinaryExpression)) + { + throw new InvalidOperationException("Could not convert expression: " + expression.Left); + } + + // append left children + enumerable = enumerable.Concat(VisitAndAlsoExpressionChain(leftBinaryExpression)); + + if (!(expression.Right is BinaryExpression rightBinaryExpression)) + { + throw new InvalidOperationException("Could not convert expression: " + expression.Right); + } + + // append right children + enumerable = enumerable.Concat(VisitAndAlsoExpressionChain(rightBinaryExpression)); + } + else + { + // append self + enumerable = enumerable.Append(expression); + } + + return enumerable; + } + } +} \ No newline at end of file diff --git a/src/Triggers/Conditions/WeatherExpressionType.cs b/src/Triggers/Conditions/WeatherExpressionType.cs new file mode 100644 index 0000000..a26e581 --- /dev/null +++ b/src/Triggers/Conditions/WeatherExpressionType.cs @@ -0,0 +1,28 @@ +namespace OpenWeatherMap.Triggers.Conditions +{ + using System.Runtime.Serialization; + using Newtonsoft.Json; + using Newtonsoft.Json.Converters; + + [JsonConverter(typeof(StringEnumConverter))] + public enum WeatherExpressionType : byte + { + [EnumMember(Value = "$gt")] + GreaterThan, + + [EnumMember(Value = "$lt")] + LessThan, + + [EnumMember(Value = "$gte")] + GreaterThanOrEqual, + + [EnumMember(Value = "$lte")] + LessThanOrEqual, + + [EnumMember(Value = "$eq")] + Equal, + + [EnumMember(Value = "$ne")] + NotEqual + } +} \ No newline at end of file diff --git a/src/Triggers/Conditions/WeatherTriggerCondition.cs b/src/Triggers/Conditions/WeatherTriggerCondition.cs new file mode 100644 index 0000000..63c31be --- /dev/null +++ b/src/Triggers/Conditions/WeatherTriggerCondition.cs @@ -0,0 +1,12 @@ +namespace OpenWeatherMap.Triggers.Conditions +{ + public sealed class WeatherTriggerCondition + { + public float Clouds { get; } + public float Humidity { get; } + public float Pressure { get; } + public float Temperature { get; } + public float WindDirection { get; } + public float WindSpeed { get; } + } +} \ No newline at end of file diff --git a/src/Triggers/Conditions/WeatherTriggerType.cs b/src/Triggers/Conditions/WeatherTriggerType.cs new file mode 100644 index 0000000..af61059 --- /dev/null +++ b/src/Triggers/Conditions/WeatherTriggerType.cs @@ -0,0 +1,28 @@ +namespace OpenWeatherMap.Triggers.Conditions +{ + using System.Runtime.Serialization; + using Newtonsoft.Json; + using Newtonsoft.Json.Converters; + + [JsonConverter(typeof(StringEnumConverter))] + public enum WeatherTriggerName : byte + { + [EnumMember(Value = "temp")] + Temperature, + + [EnumMember(Value = "pressure")] + Pressure, + + [EnumMember(Value = "humidity")] + Humidity, + + [EnumMember(Value = "wind_speed")] + WindSpeed, + + [EnumMember(Value = "wind_direction")] + WindDirection, + + [EnumMember(Value = "clouds")] + Clouds + } +} \ No newline at end of file diff --git a/src/Triggers/Coordinates.cs b/src/Triggers/Coordinates.cs new file mode 100644 index 0000000..bbc946b --- /dev/null +++ b/src/Triggers/Coordinates.cs @@ -0,0 +1,39 @@ +namespace OpenWeatherMap.Triggers +{ + using System; + using Newtonsoft.Json; + using OpenWeatherMap.Converters; + + [JsonConverter(typeof(CoordinatesArrayJsonConverter))] + public readonly struct Coordinates + { + public Coordinates(int[] array) + { + if (array is null) + { + throw new ArgumentNullException(nameof(array)); + } + + if (array.Length != 2) + { + throw new InvalidOperationException("Expected exactly 2 coordinates in array."); + } + + Longitude = array[0]; + Latitude = array[1]; + } + + [JsonConstructor] + public Coordinates([JsonProperty("lon")] int longitude, [JsonProperty("lat")] int latitude) + { + Longitude = longitude; + Latitude = latitude; + } + + public int Latitude { get; } + + public int Longitude { get; } + + public int[] AsArray() => new[] { Longitude, Latitude }; + } +} \ No newline at end of file diff --git a/src/Triggers/Time/DynamicTimestamp.cs b/src/Triggers/Time/DynamicTimestamp.cs new file mode 100644 index 0000000..a3d4a29 --- /dev/null +++ b/src/Triggers/Time/DynamicTimestamp.cs @@ -0,0 +1,38 @@ +namespace OpenWeatherMap.Triggers.Time +{ + using System; + using Newtonsoft.Json; + + public sealed class DynamicTimestamp + { + public DynamicTimestamp(long offset, TimestampExpression expression) + { + Offset = offset; + Expression = expression; + } + + [JsonRequired, JsonProperty("expression")] + public TimestampExpression Expression { get; internal set; } + + [JsonRequired, JsonProperty("amount")] + public long Offset { get; internal set; } + + public DateTimeOffset ToTimestamp() + { + switch (Expression) + { + case TimestampExpression.Exact: + return DateTimeOffset.FromUnixTimeMilliseconds(Offset); + + case TimestampExpression.After: + return DateTimeOffset.UtcNow + TimeSpan.FromMilliseconds(Offset); + + case TimestampExpression.Before: + return DateTimeOffset.UtcNow - TimeSpan.FromMilliseconds(Offset); + } + + throw new ArgumentOutOfRangeException(nameof(Expression), Expression, + "The specified Expression is not defined in the TimestampExpression enumeration."); + } + } +} \ No newline at end of file diff --git a/src/Triggers/Time/TimePeriod.cs b/src/Triggers/Time/TimePeriod.cs new file mode 100644 index 0000000..994cd32 --- /dev/null +++ b/src/Triggers/Time/TimePeriod.cs @@ -0,0 +1,13 @@ +namespace OpenWeatherMap.Triggers.Time +{ + using Newtonsoft.Json; + + public sealed class TimePeriod + { + [JsonRequired, JsonProperty("end")] + public DynamicTimestamp End { get; internal set; } + + [JsonRequired, JsonProperty("start")] + public DynamicTimestamp Start { get; internal set; } + } +} \ No newline at end of file diff --git a/src/Triggers/Time/TimestampExpression.cs b/src/Triggers/Time/TimestampExpression.cs new file mode 100644 index 0000000..327e9ab --- /dev/null +++ b/src/Triggers/Time/TimestampExpression.cs @@ -0,0 +1,19 @@ +namespace OpenWeatherMap.Triggers.Time +{ + using System.Runtime.Serialization; + using Newtonsoft.Json; + using Newtonsoft.Json.Converters; + + [JsonConverter(typeof(StringEnumConverter))] + public enum TimestampExpression : byte + { + [EnumMember(Value = "exact")] + Exact, + + [EnumMember(Value = "after")] + After, + + [EnumMember(Value = "before")] + Before + } +} \ No newline at end of file diff --git a/src/Triggers/Trigger.cs b/src/Triggers/Trigger.cs new file mode 100644 index 0000000..482cee3 --- /dev/null +++ b/src/Triggers/Trigger.cs @@ -0,0 +1,27 @@ +namespace OpenWeatherMap.Triggers +{ + using System; + using System.Collections.Generic; + using Newtonsoft.Json; + using OpenWeatherMap.Triggers.Area; + using OpenWeatherMap.Triggers.Conditions; + using OpenWeatherMap.Triggers.Time; + + public class Trigger + { + [JsonProperty("area")] + public IReadOnlyCollection Areas { get; internal set; } + + [JsonProperty("conditions")] + public IReadOnlyCollection Conditions { get; internal set; } + + [JsonProperty("_id", NullValueHandling = NullValueHandling.Ignore)] + public string Id { get; internal set; } + + [JsonProperty("owner", NullValueHandling = NullValueHandling.Ignore)] + public string OwnerId { get; internal set; } + + [JsonRequired, JsonProperty("time_period")] + public TimePeriod Period { get; internal set; } + } +} \ No newline at end of file diff --git a/src/Triggers/TriggerAlert.cs b/src/Triggers/TriggerAlert.cs new file mode 100644 index 0000000..3f0f70a --- /dev/null +++ b/src/Triggers/TriggerAlert.cs @@ -0,0 +1,14 @@ +namespace OpenWeatherMap.Triggers +{ + using Newtonsoft.Json; + using OpenWeatherMap.Triggers.Conditions; + + public sealed class TriggerAlert + { + [JsonRequired, JsonProperty("condition")] + public TriggerCondition Condition { get; internal set; } + + [JsonRequired, JsonProperty("current_value")] + public TriggerValue Value { get; internal set; } + } +} \ No newline at end of file diff --git a/src/Triggers/TriggerInformation.cs b/src/Triggers/TriggerInformation.cs new file mode 100644 index 0000000..55054b7 --- /dev/null +++ b/src/Triggers/TriggerInformation.cs @@ -0,0 +1,11 @@ +namespace OpenWeatherMap.Triggers +{ + using System.Collections.Generic; + using Newtonsoft.Json; + + public sealed class TriggerInformation : Trigger + { + [JsonRequired, JsonProperty("alerts")] + public IReadOnlyDictionary Alerts { get; internal set; } + } +} \ No newline at end of file diff --git a/src/Triggers/TriggerValue.cs b/src/Triggers/TriggerValue.cs new file mode 100644 index 0000000..0e3de09 --- /dev/null +++ b/src/Triggers/TriggerValue.cs @@ -0,0 +1,17 @@ +namespace OpenWeatherMap.Triggers +{ + using Newtonsoft.Json; + + public readonly struct TriggerValue + { + [JsonConstructor] + public TriggerValue([JsonProperty("min")] float min, [JsonProperty("max")] float max) + { + Min = min; + Max = max; + } + + public float Min { get; } + public float Max { get; } + } +} \ No newline at end of file diff --git a/src/Util/JsonContent.cs b/src/Util/JsonContent.cs index 9b79127..775d560 100644 --- a/src/Util/JsonContent.cs +++ b/src/Util/JsonContent.cs @@ -1,18 +1,85 @@ namespace OpenWeatherMap.Util { + using System; + using System.IO; + using System.Net; using System.Net.Http; + using System.Net.Http.Headers; using System.Text; + using System.Threading.Tasks; + using Newtonsoft.Json; + using Newtonsoft.Json.Linq; - public sealed class JsonContent : StringContent + /// + /// Represents a HTTP content which holds JSON-encoded data. + /// + public sealed class JsonContent : HttpContent { + /// + /// The media type for JSON-encoded HTTP content ("application/json"). + /// public const string MediaType = "application/json"; - public JsonContent(string content) : this(content, Encoding.UTF8) + /// + /// Initializes a new instance of the class. + /// + /// the JSON object to write + /// + /// the encoding to use when writing the JSON data; or to use + /// + /// + /// + /// thrown if the specified is . + /// + public JsonContent(object value, Encoding encoding = null) : this(JToken.FromObject(value), encoding) { } - public JsonContent(string content, Encoding encoding) : base(content, encoding, MediaType) + /// + /// Initializes a new instance of the class. + /// + /// the JSON token to write + /// + /// the encoding to use when writing the JSON data; or to use + /// + /// + /// + /// thrown if the specified is . + /// + public JsonContent(JToken token, Encoding encoding = null) { + Token = token ?? throw new ArgumentNullException(nameof(token)); + Encoding = encoding ?? Encoding.UTF8; + Headers.ContentType = new MediaTypeHeaderValue(MediaType); + } + + /// + /// Gets the encoding to use when writing the JSON data. + /// + /// the encoding to use when writing the JSON data + public Encoding Encoding { get; } + + /// + /// Gets the JSON token to write. + /// + /// the JSON token to write + public JToken Token { get; } + + /// + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context) + { + using (var streamWriter = new StreamWriter(stream, Encoding, 4096, leaveOpen: true)) + using (var jsonTextWriter = new JsonTextWriter(streamWriter)) + { + await Token.WriteToAsync(jsonTextWriter); + } + } + + /// + protected override bool TryComputeLength(out long length) + { + length = default; + return false; } } } \ No newline at end of file