diff --git a/README.md b/README.md index ec68ab5..71d0555 100644 --- a/README.md +++ b/README.md @@ -5,23 +5,17 @@ [![Gitter chat](https://badges.gitter.im/gitterHQ/gitter.png)](https://gitter.im/Tiny-RestClient/Lobby) [![StackOverflow](https://img.shields.io/badge/questions-on%20StackOverflow-orange.svg?style=flat)](http://stackoverflow.com/questions/tagged/tiny.restclient) +[Please visit the main site.](https://jgiacomini.github.io/Tiny.RestClient/) + Tiny.RestClient facilitates the dialog between your API and your application. It hides all the complexity of communication, deserialisation ... ## Platform Support -|Platform|Supported|Version|Dependencies|Feature not supported| -| ------------------- | :-----------: | :------------------: | :------------------: |:------------------: | -|.NET Standard|Yes|1.1 && 1.2|Use System.Xml.XmlSerializer and Newtonsoft.Json|Manipulate files| -|.NET Standard|Yes|1.3|Use System.Xml.XmlSerializer and Newtonsoft.Json|-| -|.NET Standard|Yes|2.0|Use Newtonsoft.Json|-| -|.NET Framework|Yes|4.5+|Use Newtonsoft.Json|-| -|.NET Framework|Yes|4.6+|Use Newtonsoft.Json|-| -|.NET Framework|Yes|4.7+|Use Newtonsoft.Json|-| - -The support of .NET Standard 1.1 to 2.0 allow you to use it in : -- .Net Framework 4.6+ -- Xamarin iOS et Android + +The support of **.NET Standard 1.1 to 2.0** allow you to use it in : +- .Net Framework 4.5+ +- Xamarin iOS, Xamarin Android - .Net Core - UWP - Windows Phone 8.1 @@ -29,32 +23,28 @@ The support of .NET Standard 1.1 to 2.0 allow you to use it in : ## Features * Modern async http client for REST API. -* Support of verbs : GET, POST , PUT, DELETE, PATCH -* Support of custom http verbs +* Support of verbs : GET, POST , PUT, DELETE, PATCH and custom http verbs +* Support of ETag +* Support of multi-part form data * Support of cancellation token on each requests +* Support of : download file and Upload file * Automatic XML and JSON serialization / deserialization * Support of custom serialisation / deserialisation * Support of camelCase, snakeCase kebabCase for json serialization -* Support of multi-part form data -* Download file -* Upload file -* Support of gzip/deflate (compression and decompression) -* Optimized http calls +* Support of compression and decompression (gzip and deflate) * Typed exceptions which are easier to interpret -* Define timeout globally or by request -* Timeout exception throwed if the request is in timeout (by default HttpClient send OperationCancelledException, so we can't make difference between a user annulation and timeout) +* Define timeout globally or per request +* Timeout exception thrown if the request is in timeout (by default HttpClient sends OperationCancelledException, so we can't distinguish between user cancellation and timeout) * Provide an easy way to log : all sending of request, failed to get response, and the time get response. * Support of export requests to postman collection -* Support of display cURL request in debug output +* Support of display cURL requests in debug output * Support of Basic Authentification * Support of OAuth2 Authentification - ## Basic usage ### Create the client -Define a global timeout for all client. (By default it's setted to 100 secondes) ```cs using Tiny.RestClient; @@ -171,8 +161,8 @@ var response = await client. ``` ### Define timeout +Define a global timeout for all client. (By default it's setted to 100 secondes) -Define global timeout ```cs client.Settings.DefaultTimeout = TimeSpan.FromSeconds(100); ``` @@ -211,7 +201,7 @@ string response = await client. // GET http://MyAPI.com/api/City/All with from url encoded content ``` -## multi-part form data +## Multi-part form data ```cs // With 2 json content @@ -257,21 +247,44 @@ var response = await client. AddFileContent(fileInfo1, "text/plain"). AddFileContent(fileInfo2, "text/plain"). ExecuteAsync(); + + +// With 2 strings content +var response = await client. + PostRequest("City/Image/Text"). + AsMultiPartFromDataRequest(). + AddString("string1", "text/plain"). + AddString("string2", "text/plain"). + ExecuteAsync(); // With mixed content -await client.PostRequest("MultiPart/Test"). +await client.PostRequest("Files/Add"). AsMultiPartFromDataRequest(). AddContent(city1, "city1", "city1.json"). AddByteArray(byteArray1, "request", "request2.bin"). AddStream(stream2, "request", "request2.bin") + AddString("string1", "text", "request.txt") ExecuteAsync(); ``` -## Streams and bytes array -You can use as content : streams or byte arrays. +## String, Streams and bytes array +You can use as content : strings, streams or byte arrays. If you use these methods no serializer will be used. +### String +```cs +// Read string response + Stream stream = await client. + GetRequest("text"). + ExecuteAsStringAsync(); + +// Post String as content +await client.PostRequest("poetry/text"). + AddStringContent(stream). + ExecuteAsync(); +``` + ### Streams ```cs // Read stream response @@ -327,6 +340,22 @@ catch (HttpException ex) when (ex.StatusCode == System.Net.HttpStatusCode.Intern throw new ServerErrorException($"{ex.Message} {ex.ReasonPhrase}"); } ``` +## ETag +The lib support the Entity tag but it's not enabled by default. + +### Define an ETagContainer globally +An implementation of IETagContainer is provided. It stores all data in multiples files. + +To enable it : +```cs +client.Settings.ETagContainer = new ETagFileContainer(@"C:\ETagFolder"); +``` + +### Define an ETagContainer for one request +You can also define the ETagContainer only on specific request. +```cs +request.WithETagContainer(eTagContainer); +``` ## Formatters diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 3ff33a2..556df90 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,4 +1,21 @@ # Release notes +## 1.6.0 +* Add support of Entity Tag (ETag) + +ETag is not enabled by default to enable it : +```cs +client.Settings.ETagContainer = new ETagFileContainer(@"C:\ETagFolder"); +``` +You can also enable on only on specific request like below : +```cs +request.WithETagContainer(eTagContainer); +``` +* Add support of string content (for mono part and multipart requests) +```cs +request.AddStringContent("myContent").ExecuteAsycnc(); +``` +* Now the assembly is strong named + ## 1.5.5 * Fix a bug on cURL listener (when body was null) the cURL request wasn't displayed diff --git a/Tests/Tiny.RestClient.ForTest.Api/Controllers/FileController.cs b/Tests/Tiny.RestClient.ForTest.Api/Controllers/FileController.cs index c93b93a..e754e73 100644 --- a/Tests/Tiny.RestClient.ForTest.Api/Controllers/FileController.cs +++ b/Tests/Tiny.RestClient.ForTest.Api/Controllers/FileController.cs @@ -21,7 +21,7 @@ public async Task One() } [HttpGet("GetPdf")] - public IActionResult Download() + public IActionResult GetPdf() { return File("pdf-sample.pdf", "application/pdf", "pdf-sample"); } diff --git a/Tests/Tiny.RestClient.ForTest.Api/Middleware/ETagMiddleware.cs b/Tests/Tiny.RestClient.ForTest.Api/Middleware/ETagMiddleware.cs new file mode 100644 index 0000000..06d28e6 --- /dev/null +++ b/Tests/Tiny.RestClient.ForTest.Api/Middleware/ETagMiddleware.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Net.Http.Headers; +using System.IO; +using System.Security.Cryptography; +using System.Threading.Tasks; + +namespace Tiny.RestClient.ForTest.Api.Middleware +{ + public class ETagMiddleware + { + private readonly RequestDelegate _next; + + public ETagMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + var response = context.Response; + using (var ms = new MemoryStream()) + { + if (IsETagSupported(response)) + { + var originalStream = response.Body; + string checksum = CalculateChecksum(ms); + + response.Headers[HeaderNames.ETag] = checksum; + + if (context.Request.Headers.TryGetValue(HeaderNames.IfNoneMatch, out var etag) && checksum == etag) + { + response.StatusCode = StatusCodes.Status304NotModified; + response.Body = ms; + await _next(context); + return; + } + + await _next(context); + } + else + { + await _next(context); + } + } + } + + private static bool IsETagSupported(HttpResponse response) + { + if (response.StatusCode != StatusCodes.Status200OK) + { + return false; + } + + if (response.Headers.ContainsKey(HeaderNames.ETag)) + { + return false; + } + + return true; + } + + private static string CalculateChecksum(MemoryStream ms) + { + string checksum = string.Empty; + + using (var algo = SHA1.Create()) + { + ms.Position = 0; + byte[] bytes = algo.ComputeHash(ms); + checksum = $"\"{WebEncoders.Base64UrlEncode(bytes)}\""; + } + + return checksum; + } + } +} diff --git a/Tests/Tiny.RestClient.ForTest.Api/Startup.cs b/Tests/Tiny.RestClient.ForTest.Api/Startup.cs index 9852c40..58c0294 100644 --- a/Tests/Tiny.RestClient.ForTest.Api/Startup.cs +++ b/Tests/Tiny.RestClient.ForTest.Api/Startup.cs @@ -49,6 +49,7 @@ public void Configure(IApplicationBuilder app, IHostingEnvironment env) } app.UseMiddleware(); + app.UseMiddleware(); app.UseResponseCompression(); app.UseMvc(); } diff --git a/Tests/Tiny.RestClient.Tests/BaseTest.cs b/Tests/Tiny.RestClient.Tests/BaseTest.cs index f6185a8..e394527 100644 --- a/Tests/Tiny.RestClient.Tests/BaseTest.cs +++ b/Tests/Tiny.RestClient.Tests/BaseTest.cs @@ -28,6 +28,14 @@ public static TinyRestClient GetClient() return _client; } + public static string ServerUrl + { + get + { + return _serverUrl; + } + } + public static TinyRestClient GetNewClient() { return GetNewClient(_serverUrl); diff --git a/Tests/Tiny.RestClient.Tests/EtagTests.cs b/Tests/Tiny.RestClient.Tests/EtagTests.cs new file mode 100644 index 0000000..5d27b4c --- /dev/null +++ b/Tests/Tiny.RestClient.Tests/EtagTests.cs @@ -0,0 +1,144 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Tiny.RestClient.Tests +{ + [TestClass] + public class ETagTests : BaseTest + { + private string _directoryPath; + + public TestContext TestContext { get; set; } + + [TestInitialize] + public void TestInitialize() + { + var tempPath = System.IO.Path.GetTempPath(); + _directoryPath = Path.Combine(tempPath, $"{nameof(ETagTests)}_{TestContext.TestName}"); + + if (!Directory.Exists(_directoryPath)) + { + Directory.CreateDirectory(_directoryPath); + } + else + { + var files = Directory.GetFiles(_directoryPath); + foreach (var file in files) + { + File.Delete(file); + } + } + } + + [TestCleanup] + public void TestCleanup() + { + if (Directory.Exists(_directoryPath)) + { + Directory.Delete(_directoryPath, true); + } + } + + [TestMethod] + public async Task ETagContainerOnClient() + { + var client = GetNewClient(); + + var etagContainer = new ETagFileContainer(_directoryPath); + client.Settings.ETagContainer = etagContainer; + var data = await client.GetRequest("GetTest/complex"). + FillResponseHeaders(out Headers headers). + ExecuteAsync(); + Assert.AreEqual(data.Length, 2); + Assert.AreEqual(data[0], "value1"); + Assert.AreEqual(data[1], "value2"); + + var actionUri = new Uri($"{ServerUrl}GetTest/complex"); + var etag = headers["ETag"].FirstOrDefault(); + var etagStored = await etagContainer.GetExistingETagAsync(actionUri, CancellationToken.None); + Assert.AreEqual(etagStored, etag); + + var fakeData = new List() { "test1", "test2" }; + + var json = client.Settings.Formatters.FirstOrDefault().Serialize>(fakeData, client.Settings.Encoding); + await etagContainer.SaveDataAsync(actionUri, etagStored, new MemoryStream(Encoding.UTF8.GetBytes(json)), CancellationToken.None); + + data = await client.GetRequest("GetTest/complex"). + ExecuteAsync(); + Assert.AreEqual(data.Length, 2); + Assert.AreEqual(data[0], "test1"); + Assert.AreEqual(data[1], "test2"); + + await etagContainer.SaveDataAsync(actionUri, "\"TEST\"", new MemoryStream(), CancellationToken.None); + + data = await client.GetRequest("GetTest/complex"). + ExecuteAsync(); + + etagStored = await etagContainer.GetExistingETagAsync(actionUri, CancellationToken.None); + Assert.AreEqual(etagStored, etag); + + Assert.AreEqual(data.Length, 2); + Assert.AreEqual(data[0], "value1"); + Assert.AreEqual(data[1], "value2"); + } + + [TestMethod] + public async Task ETagContainerOnRequest() + { + var client = GetNewClient(); + + var etagContainer = new ETagFileContainer(_directoryPath); + var data = await client.GetRequest("GetTest/complex"). + WithETagContainer(etagContainer). + FillResponseHeaders(out Headers headers). + ExecuteAsync(); + Assert.AreEqual(data.Length, 2); + Assert.AreEqual(data[0], "value1"); + Assert.AreEqual(data[1], "value2"); + + var actionUri = new Uri($"{ServerUrl}GetTest/complex"); + var etag = headers["ETag"].FirstOrDefault(); + var etagStored = await etagContainer.GetExistingETagAsync(actionUri, CancellationToken.None); + Assert.AreEqual(etagStored, etag); + + var fakeData = new List() { "test1", "test2" }; + + var json = client.Settings.Formatters.FirstOrDefault().Serialize>(fakeData, client.Settings.Encoding); + await etagContainer.SaveDataAsync(actionUri, etagStored, new MemoryStream(Encoding.UTF8.GetBytes(json)), CancellationToken.None); + + data = await client.GetRequest("GetTest/complex"). + WithETagContainer(etagContainer). + ExecuteAsync(); + Assert.AreEqual(data.Length, 2); + Assert.AreEqual(data[0], "test1"); + Assert.AreEqual(data[1], "test2"); + + await etagContainer.SaveDataAsync(actionUri, "\"TEST\"", new MemoryStream(), CancellationToken.None); + + data = await client.GetRequest("GetTest/complex"). + WithETagContainer(etagContainer). + ExecuteAsync(); + + etagStored = await etagContainer.GetExistingETagAsync(actionUri, CancellationToken.None); + Assert.AreEqual(etagStored, etag); + + Assert.AreEqual(data.Length, 2); + Assert.AreEqual(data[0], "value1"); + Assert.AreEqual(data[1], "value2"); + } + + [TestMethod] + [ExpectedException(typeof(DirectoryNotFoundException))] + public void ETagFileContainerDirectoryNotFound() + { + new ETagFileContainer(@"C:\notfound"); + Assert.Fail("It must not go here"); + } + } +} diff --git a/Tests/Tiny.RestClient.Tests/GetTests.cs b/Tests/Tiny.RestClient.Tests/GetTests.cs index ebdf67e..e66f287 100644 --- a/Tests/Tiny.RestClient.Tests/GetTests.cs +++ b/Tests/Tiny.RestClient.Tests/GetTests.cs @@ -41,13 +41,15 @@ await client. FillResponseHeaders(out Headers headersOfResponse). ExecuteAsync(); - Assert.IsTrue(headersOfResponse.Count() == 3); + // 3 custom headers + ETag + var headerFiltered = headersOfResponse.Where(h => h.Key != "ETag"); - for (int i = 0; i < headersOfResponse.Count(); i++) - { - var current = headersOfResponse.ElementAt(i); + Assert.IsTrue(headerFiltered.Count() == 3, "3 headers expected"); - Assert.IsTrue(current.Key == $"custom{i + 1}"); + for (int i = 0; i < headerFiltered.Count() - 1; i++) + { + var current = headerFiltered.ElementAt(i); + Assert.IsTrue(current.Key == $"custom{i + 1}", $"custom{i + 1} expected"); } } diff --git a/Tests/Tiny.RestClient.Tests/MultiPartTests.cs b/Tests/Tiny.RestClient.Tests/MultiPartTests.cs index 63f3dbb..cd6faa8 100644 --- a/Tests/Tiny.RestClient.Tests/MultiPartTests.cs +++ b/Tests/Tiny.RestClient.Tests/MultiPartTests.cs @@ -22,12 +22,13 @@ public async Task SendMultipleData() var data = await client. PostRequest("MultiPart/Test"). AsMultiPartFromDataRequest(). + AddContent(postRequest, "request", "request.json"). AddByteArray(new byte[42], "bytesArray", "bytesArray.bin"). AddStream(new MemoryStream(new byte[42]), "stream", "stream.bin"). - AddContent(postRequest, "request", "request.json"). + AddString("string", "string", "string.txt"). ExecuteAsync(); - Assert.AreEqual(data, "bytesArray-bytesArray.bin;stream-stream.bin;request-request.json;"); + Assert.AreEqual("request-request.json;bytesArray-bytesArray.bin;stream-stream.bin;string-string.txt;", data); } [ExpectedException(typeof(ArgumentNullException))] @@ -44,7 +45,7 @@ public async Task MultiPartAddStreamNull() var data = await client. PostRequest("MultiPart/Test"). AsMultiPartFromDataRequest(). - AddByteArray(null, "bytesArray", "bytesArray.bin"). + AddStream(null). ExecuteAsync(); } @@ -62,7 +63,7 @@ public async Task MultiPartAddByteArrayNull() var data = await client. PostRequest("MultiPart/Test"). AsMultiPartFromDataRequest(). - AddStream(null). + AddByteArray(null, "bytesArray", "bytesArray.bin"). ExecuteAsync(); } diff --git a/Tests/Tiny.RestClient.Tests/PostTests.cs b/Tests/Tiny.RestClient.Tests/PostTests.cs index ee65e55..9976df3 100644 --- a/Tests/Tiny.RestClient.Tests/PostTests.cs +++ b/Tests/Tiny.RestClient.Tests/PostTests.cs @@ -113,6 +113,29 @@ public async Task PostByteArrayData() } } + [TestMethod] + public async Task PostStringData() + { + uint size = 2048; + var client = GetClient(); + + var byteArray = GetByteArray(size); + + var data = System.Text.Encoding.Default.GetString(byteArray); + + var response = await client. + PostRequest("PostTest/Stream"). + AddStringContent(data). + ExecuteAsByteArrayAsync(); + Assert.IsNotNull(response); + Assert.AreEqual(size, (uint)response.Length); + + for (int i = 0; i < byteArray.Length; i++) + { + Assert.IsTrue(byteArray[i] == response[i], "byte array response must have same data than byte array sended"); + } + } + [TestMethod] public async Task PostStreamData() { diff --git a/Tiny.RestClient/Compression/DeflateCompression.cs b/Tiny.RestClient/Compression/DeflateCompression.cs index e646975..d42872f 100644 --- a/Tiny.RestClient/Compression/DeflateCompression.cs +++ b/Tiny.RestClient/Compression/DeflateCompression.cs @@ -47,7 +47,7 @@ public async Task DecompressAsync(Stream stream, int bufferSize, Cancell using (var decompressionStream = new DeflateStream(stream, CompressionMode.Decompress)) { await decompressionStream.CopyToAsync(decompressedStream, bufferSize, cancellationToken).ConfigureAwait(false); - await decompressionStream.FlushAsync(); + await decompressionStream.FlushAsync().ConfigureAwait(false); } return decompressedStream; diff --git a/Tiny.RestClient/ETag/ETagFileContainer.cs b/Tiny.RestClient/ETag/ETagFileContainer.cs new file mode 100644 index 0000000..eaf3aa8 --- /dev/null +++ b/Tiny.RestClient/ETag/ETagFileContainer.cs @@ -0,0 +1,116 @@ +#if !FILEINFO_NOT_SUPPORTED +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Tiny.RestClient +{ + /// + /// Class which store data of entity in a directory + /// + public class ETagFileContainer : IETagContainer + { + private const int BufferSize = 81920; + private readonly string _pathOfDirectoryContainer; + + /// + /// Initializes a new instance of the class. + /// + /// the path of the directory which will store the data + public ETagFileContainer(string pathOfDirectoryContainer) + { + _pathOfDirectoryContainer = pathOfDirectoryContainer ?? throw new ArgumentNullException(nameof(pathOfDirectoryContainer)); + + if (!Directory.Exists(_pathOfDirectoryContainer)) + { + throw new DirectoryNotFoundException($"Directory '{_pathOfDirectoryContainer}' not found"); + } + } + + /// + public Task GetExistingETagAsync(Uri uri, CancellationToken cancellationToken) + { + var url = uri.AbsoluteUri; + var key = CalculateMD5Hash(url); + var hashPath = GetETagPath(key); + if (File.Exists(hashPath)) + { + return Task.FromResult(File.ReadAllText(hashPath)); + } + + return Task.FromResult(null); + } + + /// + public Task GetDataAsync(Uri uri, CancellationToken cancellationToken) + { + var url = uri.AbsoluteUri; + var key = CalculateMD5Hash(url); + var dataPath = GetDataPath(key); + return Task.FromResult((Stream)File.OpenRead(dataPath)); + } + + /// + public async Task SaveDataAsync(Uri uri, string etag, Stream stream, CancellationToken cancellationToken) + { + var url = uri.AbsoluteUri; + var key = CalculateMD5Hash(url); + + var hashPath = GetETagPath(key); + var dataPath = GetDataPath(key); + + if (File.Exists(hashPath)) + { + File.Delete(hashPath); + } + + if (File.Exists(dataPath)) + { + File.Delete(dataPath); + } + + using (var fileStream = File.Create(dataPath)) + { + stream.Seek(0, SeekOrigin.Begin); + await stream.CopyToAsync(fileStream, BufferSize, cancellationToken).ConfigureAwait(false); + } + + var buffer = Encoding.ASCII.GetBytes(etag); + using (var fs = new FileStream(hashPath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None, buffer.Length, true)) + { + await fs.WriteAsync(buffer, 0, buffer.Length, cancellationToken); + } + } + + private string GetETagPath(string key) + { + return Path.Combine(_pathOfDirectoryContainer, $"{key}.etag"); + } + + private string GetDataPath(string key) + { + return Path.Combine(_pathOfDirectoryContainer, key); + } + + private string CalculateMD5Hash(string input) + { + using (MD5 md5 = System.Security.Cryptography.MD5.Create()) + { + var inputBytes = System.Text.Encoding.UTF8.GetBytes(input); + var hash = md5.ComputeHash(inputBytes); + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < hash.Length; i++) + { + sb.Append(hash[i].ToString("X2")); + } + + return sb.ToString(); + } + } + } +} +#endif \ No newline at end of file diff --git a/Tiny.RestClient/ETag/IETagContainer.cs b/Tiny.RestClient/ETag/IETagContainer.cs new file mode 100644 index 0000000..bdfcb9f --- /dev/null +++ b/Tiny.RestClient/ETag/IETagContainer.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Tiny.RestClient +{ + /// + /// Entity Tag container + /// + public interface IETagContainer + { + /// + /// Get the existing ETag. + /// + /// the uri + /// The cancellation token. + /// return the etag if found. If not return null. + Task GetExistingETagAsync(Uri uri, CancellationToken cancellationToken); + + /// + /// Get data of specific uri + /// + /// the uri + /// The cancellation token. + /// return the of data + Task GetDataAsync(Uri uri, CancellationToken cancellationToken); + + /// + /// S + /// + /// the uri + /// the etag of data + /// of data to store + /// The cancellation token. + /// + Task SaveDataAsync(Uri uri, string etag, Stream stream, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/Tiny.RestClient/Request/Content/StringContent.cs b/Tiny.RestClient/Request/Content/StringContent.cs new file mode 100644 index 0000000..0d63af4 --- /dev/null +++ b/Tiny.RestClient/Request/Content/StringContent.cs @@ -0,0 +1,10 @@ +namespace Tiny.RestClient +{ + internal class StringContent : BaseContent + { + public StringContent(string data, string contentType) + : base(data, contentType) + { + } + } +} \ No newline at end of file diff --git a/Tiny.RestClient/Request/Headers/Headers.cs b/Tiny.RestClient/Request/Headers/Headers.cs index ebe9871..eb4c3f0 100644 --- a/Tiny.RestClient/Request/Headers/Headers.cs +++ b/Tiny.RestClient/Request/Headers/Headers.cs @@ -90,6 +90,23 @@ internal void AddRange(IEnumerable>> ra } } + /// + /// Gets or sets header + /// + /// header name + /// return header's value + public IEnumerable this[string name] + { + get + { + return _headers[name]; + } + set + { + Add(name, value); + } + } + /// public IEnumerator>> GetEnumerator() { diff --git a/Tiny.RestClient/Request/IExecutableRequest.cs b/Tiny.RestClient/Request/IExecutableRequest.cs index a20fb4a..2d1aec9 100644 --- a/Tiny.RestClient/Request/IExecutableRequest.cs +++ b/Tiny.RestClient/Request/IExecutableRequest.cs @@ -13,7 +13,7 @@ public interface IExecutableRequest /// /// Executes the request. /// - /// The type of the t result. + /// The type of the TResult. /// The cancellation token. /// Task ExecuteAsync(CancellationToken cancellationToken = default); @@ -21,7 +21,7 @@ public interface IExecutableRequest /// /// Executes the request. /// - /// The type of the t result. + /// The type of the TResult. /// Allow to override the formatter use for the deserialization. /// The cancellation token. /// Task of TResukt diff --git a/Tiny.RestClient/Request/IRequest.cs b/Tiny.RestClient/Request/IRequest.cs index 063ec07..112c324 100644 --- a/Tiny.RestClient/Request/IRequest.cs +++ b/Tiny.RestClient/Request/IRequest.cs @@ -32,6 +32,13 @@ public interface IRequest : IExecutableRequest, IFormRequest /// The current request IRequest WithTimeout(TimeSpan timeout); + /// + /// With a specific etag container + /// + /// the eTag container + /// + IRequest WithETagContainer(IETagContainer eTagContainer); + /// /// Adds the content. /// @@ -57,6 +64,14 @@ public interface IRequest : IExecutableRequest, IFormRequest /// The content type /// The current request IParameterRequest AddStreamContent(Stream stream, string contentType = "application/octet-stream"); + + /// + /// Adds string as content (without apply any serialization) + /// + /// The content. + /// The content type + /// The current request + IParameterRequest AddStringContent(string content, string contentType = "text/plain"); #if !FILEINFO_NOT_SUPPORTED /// /// Adds file as content. diff --git a/Tiny.RestClient/Request/MultipartFormContent/IMultipartFromDataRequest.cs b/Tiny.RestClient/Request/MultipartFormContent/IMultipartFromDataRequest.cs index d15f874..0b6c153 100644 --- a/Tiny.RestClient/Request/MultipartFormContent/IMultipartFromDataRequest.cs +++ b/Tiny.RestClient/Request/MultipartFormContent/IMultipartFromDataRequest.cs @@ -18,6 +18,17 @@ public interface IMultipartFromDataRequest /// thrown when data is null IMultiPartFromDataExecutableRequest AddByteArray(byte[] data, string name = null, string fileName = null, string contentType = "application/octet-stream"); + /// + /// Adds the content. + /// + /// The content. + /// The name of the item + /// The name of the file + /// The content type of the file + /// The current request + /// thrown when data is null + IMultiPartFromDataExecutableRequest AddString(string data, string name = null, string fileName = null, string contentType = "text/plain"); + /// /// Adds the content. /// diff --git a/Tiny.RestClient/Request/MultipartFormContent/StringMultipartData.cs b/Tiny.RestClient/Request/MultipartFormContent/StringMultipartData.cs new file mode 100644 index 0000000..7ddb2d2 --- /dev/null +++ b/Tiny.RestClient/Request/MultipartFormContent/StringMultipartData.cs @@ -0,0 +1,13 @@ +namespace Tiny.RestClient +{ + internal class StringMultipartData : MultipartData, IContent + { + public StringMultipartData(string data, string name, string fileName, string contentType) + : base(name, fileName, contentType) + { + Data = data; + } + + public string Data { get; } + } +} \ No newline at end of file diff --git a/Tiny.RestClient/Request/Request.cs b/Tiny.RestClient/Request/Request.cs index 8ea6822..831c9ee 100644 --- a/Tiny.RestClient/Request/Request.cs +++ b/Tiny.RestClient/Request/Request.cs @@ -26,14 +26,16 @@ internal class Request : private IContent _content; private List> _formParameters; private MultipartContent _multiPartFormData; - private Headers _reponseHeaders; + private Headers _responseHeaders; private TimeSpan? _timeout; + private IETagContainer _eTagContainer; internal HttpMethod HttpMethod { get => _httpMethod; } internal Dictionary QueryParameters { get => _queryParameters; } internal string Route { get => _route; } internal IContent Content { get => _content; } - internal Headers ReponseHeaders { get => _reponseHeaders; } + internal IETagContainer ETagContainer { get => _eTagContainer; } + internal Headers ResponseHeaders { get => _responseHeaders; } internal Headers Headers { get => _headers; } internal TimeSpan? Timeout { get => _timeout; } @@ -70,7 +72,14 @@ public IParameterRequest AddStreamContent(Stream stream, string contentType) _content = new StreamContent(stream, contentType); return this; } - #if !FILEINFO_NOT_SUPPORTED + + public IParameterRequest AddStringContent(string content, string contentType) + { + _content = new StringContent(content, contentType); + return this; + } + +#if !FILEINFO_NOT_SUPPORTED public IParameterRequest AddFileContent(FileInfo content, string contentType) { if (content == null) @@ -86,9 +95,9 @@ public IParameterRequest AddFileContent(FileInfo content, string contentType) _content = new FileContent(content, contentType); return this; } - #endif +#endif -#endregion + #endregion #region Forms Parameters @@ -123,7 +132,7 @@ public IFormRequest AddFormParameters(IEnumerable> public IParameterRequest FillResponseHeaders(out Headers headers) { headers = new Headers(); - _reponseHeaders = headers; + _responseHeaders = headers; return this; } @@ -314,6 +323,13 @@ public IParameterRequest AddQueryParameter(string key, float? value) } #endregion + /// + public IRequest WithETagContainer(IETagContainer eTagContainer) + { + _eTagContainer = eTagContainer; + return this; + } + /// public IRequest WithTimeout(TimeSpan timeout) { @@ -364,7 +380,7 @@ public Task ExecuteAsHttpResponseMessageAsync(CancellationT return _client.ExecuteAsHttpResponseMessageResultAsync(this, cancellationToken); } - #if !FILEINFO_NOT_SUPPORTED +#if !FILEINFO_NOT_SUPPORTED /// public async Task DownloadFileAsync(string fileName, CancellationToken cancellationToken) { @@ -392,7 +408,7 @@ public async Task DownloadFileAsync(string fileName, CancellationToken return new FileInfo(fileName); } - #endif +#endif #region MultiPart @@ -417,6 +433,19 @@ IMultiPartFromDataExecutableRequest IMultipartFromDataRequest.AddByteArray(byte[ return this; } + /// + IMultiPartFromDataExecutableRequest IMultipartFromDataRequest.AddString(string data, string name, string fileName, string contentType) + { + if (data == null) + { + throw new ArgumentNullException(nameof(data)); + } + + _multiPartFormData.Add(new StringMultipartData(data, name, fileName, contentType)); + + return this; + } + /// IMultiPartFromDataExecutableRequest IMultipartFromDataRequest.AddStream(Stream data, string name, string fileName, string contentType) { @@ -442,7 +471,7 @@ IMultiPartFromDataExecutableRequest IMultipartFromDataRequest.AddContent + /// Add to all request the AcceptLanguage based on CurrentCulture of the Thread + /// + public bool AddAcceptLanguageBasedOnCurrentCulture { get; set; } + + /// + /// Get or set the ETagContainer + /// + public IETagContainer ETagContainer { get; set; } + /// /// Get or set the default timeout of each request /// @@ -60,11 +70,6 @@ public Headers DefaultHeaders /// public Listeners Listeners { get; private set; } - /// - /// Add to all request the AcceptLanguage based on CurrentCulture of the Thread - /// - public bool AddAcceptLanguageBasedOnCurrentCulture { get; set; } - /// /// Gets the list of formatter used to serialize and deserialize data /// diff --git a/Tiny.RestClient/Tiny.RestClient.csproj b/Tiny.RestClient/Tiny.RestClient.csproj index 5406860..20eb306 100644 --- a/Tiny.RestClient/Tiny.RestClient.csproj +++ b/Tiny.RestClient/Tiny.RestClient.csproj @@ -1,7 +1,7 @@  netstandard1.1;netstandard1.2;netstandard1.3;netstandard2.0;net45;net46;net47; - 1.5.5 + 1.6.0 Copyright © Jérôme Giacomini 2018 en Tiny.RestClient @@ -11,25 +11,24 @@ Features : * Modern async http client for REST API. - * Support of verbs : GET, POST , PUT, DELETE, PATCH - * Support of custom http verbs + * Support of verbs : GET, POST , PUT, DELETE, PATCH and custom http verbs + * Support of ETag + * Support of multi-part form data * Support of cancellation token on each requests + * Support of : download file and Upload file * Automatic XML and JSON serialization / deserialization * Support of custom serialisation / deserialisation * Support of camelCase, snakeCase kebabCase for json serialization - * Support of multi-part form data - * Download file - * Upload file - * Support of gzip and deflate (compression and decompression) - * Optimized http calls + * Support of compression and decompression (gzip and deflate) * Typed exceptions which are easier to interpret - * Define timeout globally or by request - * Timeout exception throwed if the request is in timeout (by default HttpClient send OperationCancelledException, so we can't make difference between a user annulation and timeout) - * Provide an easy way to log : all sending of request, failed to get response, and the time get response. + * Define timeout globally or per request + * Timeout exception thrown if the request is in timeout (by default HttpClient sends OperationCancelledException, so we can't distinguish between user cancellation and timeout) + * Provide an easy way to log : all sending of request, failed to get response, and the time get response. * Support of export requests to postman collection - * Support of display cURL request in debug output + * Support of display cURL requests in debug output * Support of Basic Authentification - * Support of OAuth2 Authentification + * Support of OAuth2 Authentification + https://github.com/jgiacomini/Tiny.RestClient/blob/master/LICENSE https://github.com/jgiacomini/Tiny.RestClient https://raw.githubusercontent.com/jgiacomini/Tiny.RestClient/master/icon.png @@ -37,7 +36,7 @@ https://github.com/jgiacomini/Tiny.RestClient.git git 3.0.3 - See release notes at https://github.com/jgiacomini/Tiny.RestClient/blob/1.5.5/RELEASE-NOTES.md + See release notes at https://github.com/jgiacomini/Tiny.RestClient/blob/1.6.0/RELEASE-NOTES.md true true $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb @@ -47,10 +46,13 @@ $(TargetDir)\Tiny.RestClient.xml + true + strong_name.snk - + + diff --git a/Tiny.RestClient/TinyRestClient.cs b/Tiny.RestClient/TinyRestClient.cs index aff8f0e..8753c3e 100644 --- a/Tiny.RestClient/TinyRestClient.cs +++ b/Tiny.RestClient/TinyRestClient.cs @@ -11,6 +11,7 @@ using System.Threading; using System.Threading.Tasks; using HttpStreamContent = System.Net.Http.StreamContent; +using HttpStringContent = System.Net.Http.StringContent; namespace Tiny.RestClient { @@ -212,12 +213,13 @@ internal async Task ExecuteAsync( using (var content = await CreateContentAsync(tinyRequest.Content, cancellationToken).ConfigureAwait(false)) { var requestUri = BuildRequestUri(tinyRequest.Route, tinyRequest.QueryParameters); + var eTagContainer = GetETagContainer(tinyRequest); cancellationToken.ThrowIfCancellationRequested(); - using (HttpResponseMessage response = await SendRequestAsync(tinyRequest.HttpMethod, requestUri, tinyRequest.Headers, content, formatter, tinyRequest.Timeout, cancellationToken).ConfigureAwait(false)) + using (HttpResponseMessage response = await SendRequestAsync(tinyRequest.HttpMethod, requestUri, tinyRequest.Headers, content, eTagContainer, formatter, tinyRequest.Timeout, cancellationToken).ConfigureAwait(false)) { - using (var stream = await ReadResponseAsync(response, tinyRequest.ReponseHeaders, cancellationToken).ConfigureAwait(false)) + using (var stream = await ReadResponseAsync(response, tinyRequest.ResponseHeaders, eTagContainer, cancellationToken).ConfigureAwait(false)) { if (stream == null || stream.CanRead == false) { @@ -277,9 +279,9 @@ internal async Task ExecuteAsync( using (var content = await CreateContentAsync(tinyRequest.Content, cancellationToken).ConfigureAwait(false)) { var requestUri = BuildRequestUri(tinyRequest.Route, tinyRequest.QueryParameters); - using (HttpResponseMessage response = await SendRequestAsync(tinyRequest.HttpMethod, requestUri, tinyRequest.Headers, content, null, tinyRequest.Timeout, cancellationToken).ConfigureAwait(false)) + using (HttpResponseMessage response = await SendRequestAsync(tinyRequest.HttpMethod, requestUri, tinyRequest.Headers, content, null, null, tinyRequest.Timeout, cancellationToken).ConfigureAwait(false)) { - await HandleResponseAsync(response, tinyRequest.ReponseHeaders, cancellationToken).ConfigureAwait(false); + await HandleResponseAsync(response, tinyRequest.ResponseHeaders, null, cancellationToken).ConfigureAwait(false); } } } @@ -291,9 +293,11 @@ internal async Task ExecuteAsByteArrayResultAsync( using (var content = await CreateContentAsync(tinyRequest.Content, cancellationToken).ConfigureAwait(false)) { var requestUri = BuildRequestUri(tinyRequest.Route, tinyRequest.QueryParameters); - using (HttpResponseMessage response = await SendRequestAsync(tinyRequest.HttpMethod, requestUri, tinyRequest.Headers, content, null, tinyRequest.Timeout, cancellationToken).ConfigureAwait(false)) + var eTagContainer = GetETagContainer(tinyRequest); + + using (HttpResponseMessage response = await SendRequestAsync(tinyRequest.HttpMethod, requestUri, tinyRequest.Headers, content, eTagContainer, null, tinyRequest.Timeout, cancellationToken).ConfigureAwait(false)) { - using (var stream = await ReadResponseAsync(response, tinyRequest.ReponseHeaders, cancellationToken).ConfigureAwait(false)) + using (var stream = await ReadResponseAsync(response, tinyRequest.ResponseHeaders, eTagContainer, cancellationToken).ConfigureAwait(false)) { if (stream == null || !stream.CanRead) { @@ -318,8 +322,9 @@ internal async Task ExecuteAsStreamResultAsync( using (var content = await CreateContentAsync(tinyRequest.Content, cancellationToken).ConfigureAwait(false)) { var requestUri = BuildRequestUri(tinyRequest.Route, tinyRequest.QueryParameters); - var response = await SendRequestAsync(tinyRequest.HttpMethod, requestUri, tinyRequest.Headers, content, null, tinyRequest.Timeout, cancellationToken).ConfigureAwait(false); - var stream = await ReadResponseAsync(response, tinyRequest.ReponseHeaders, cancellationToken).ConfigureAwait(false); + var eTagContainer = GetETagContainer(tinyRequest); + var response = await SendRequestAsync(tinyRequest.HttpMethod, requestUri, tinyRequest.Headers, content, eTagContainer, null, tinyRequest.Timeout, cancellationToken).ConfigureAwait(false); + var stream = await ReadResponseAsync(response, tinyRequest.ResponseHeaders, eTagContainer, cancellationToken).ConfigureAwait(false); if (stream == null || !stream.CanRead) { return null; @@ -336,9 +341,10 @@ internal async Task ExecuteAsStringResultAsync( using (var content = await CreateContentAsync(tinyRequest.Content, cancellationToken).ConfigureAwait(false)) { var requestUri = BuildRequestUri(tinyRequest.Route, tinyRequest.QueryParameters); - using (var response = await SendRequestAsync(tinyRequest.HttpMethod, requestUri, tinyRequest.Headers, content, null, tinyRequest.Timeout, cancellationToken).ConfigureAwait(false)) + var eTagContainer = GetETagContainer(tinyRequest); + using (var response = await SendRequestAsync(tinyRequest.HttpMethod, requestUri, tinyRequest.Headers, content, eTagContainer, null, tinyRequest.Timeout, cancellationToken).ConfigureAwait(false)) { - var stream = await ReadResponseAsync(response, tinyRequest.ReponseHeaders, cancellationToken).ConfigureAwait(false); + var stream = await ReadResponseAsync(response, tinyRequest.ResponseHeaders, eTagContainer, cancellationToken).ConfigureAwait(false); if (stream == null || !stream.CanRead) { return null; @@ -363,7 +369,7 @@ internal async Task ExecuteAsHttpResponseMessageResultAsync using (var content = await CreateContentAsync(tinyRequest.Content, cancellationToken).ConfigureAwait(false)) { var requestUri = BuildRequestUri(tinyRequest.Route, tinyRequest.QueryParameters); - return await SendRequestAsync(tinyRequest.HttpMethod, requestUri, tinyRequest.Headers, content, null, tinyRequest.Timeout, cancellationToken).ConfigureAwait(false); + return await SendRequestAsync(tinyRequest.HttpMethod, requestUri, tinyRequest.Headers, content, null, null, tinyRequest.Timeout, cancellationToken).ConfigureAwait(false); } } @@ -374,6 +380,13 @@ private async Task CreateContentAsync(IContent content, Cancellatio return null; } + if (content is StringContent stringContent) + { + var contentString = new HttpStringContent(stringContent.Data); + SetContentType(stringContent.ContentType, contentString); + return contentString; + } + if (content is StreamContent currentContent) { var contentStream = new HttpStreamContent(currentContent.Data); @@ -426,18 +439,24 @@ private async Task CreateContentAsync(IContent content, Cancellatio { var bytesMultiContent = new ByteArrayContent(currentBytesPart.Data); SetContentType(currentBytesPart.ContentType, bytesMultiContent); - AddMulitPartContent(currentPart, bytesMultiContent, multiPartContent); + AddMultiPartContent(currentPart, bytesMultiContent, multiPartContent); } else if (currentPart is StreamMultipartData currentStreamPart) { var streamContent = new HttpStreamContent(currentStreamPart.Data); SetContentType(currentStreamPart.ContentType, streamContent); - AddMulitPartContent(currentPart, streamContent, multiPartContent); + AddMultiPartContent(currentPart, streamContent, multiPartContent); + } + else if (currentPart is StringMultipartData currentStringPart) + { + var stringMultiContent = new HttpStringContent(currentStringPart.Data); + SetContentType(currentStringPart.ContentType, stringMultiContent); + AddMultiPartContent(currentPart, stringMultiContent, multiPartContent); } else if (currentPart is IToSerializeContent toSerializeMultiContent) { - var stringContent = await GetSerializedContentAsync(toSerializeMultiContent, cancellationToken).ConfigureAwait(false); - AddMulitPartContent(currentPart, stringContent, multiPartContent); + var serializedContent = await GetSerializedContentAsync(toSerializeMultiContent, cancellationToken).ConfigureAwait(false); + AddMultiPartContent(currentPart, serializedContent, multiPartContent); } #if !FILEINFO_NOT_SUPPORTED @@ -445,7 +464,7 @@ private async Task CreateContentAsync(IContent content, Cancellatio { var currentStreamContent = new HttpStreamContent(currentFileMultipartData.Data.OpenRead()); SetContentType(currentFileMultipartData.ContentType, currentStreamContent); - AddMulitPartContent(currentPart, currentStreamContent, multiPartContent); + AddMultiPartContent(currentPart, currentStreamContent, multiPartContent); } #endif else @@ -499,12 +518,12 @@ private async Task GetSerializedContentAsync(IToSerializeContent co } } - var stringContent = new StringContent(serializedString, Settings.Encoding); + var stringContent = new HttpStringContent(serializedString, Settings.Encoding); stringContent.Headers.ContentType = new MediaTypeHeaderValue(serializer.DefaultMediaType); return stringContent; } - private void AddMulitPartContent(MultipartData currentContent, HttpContent content, MultipartFormDataContent multipartFormDataContent) + private void AddMultiPartContent(MultipartData currentContent, HttpContent content, MultipartFormDataContent multipartFormDataContent) { if (string.IsNullOrWhiteSpace(currentContent.Name) && string.IsNullOrWhiteSpace(currentContent.FileName)) { @@ -553,7 +572,15 @@ private Uri BuildRequestUri(string route, Dictionary queryParame return new Uri(stringBuilder.ToString()); } - private async Task SendRequestAsync(HttpMethod httpMethod, Uri uri, Headers requestHeader, HttpContent content, IFormatter deserializer, TimeSpan? localTimeout, CancellationToken cancellationToken) + private async Task SendRequestAsync( + HttpMethod httpMethod, + Uri uri, + Headers requestHeader, + HttpContent content, + IETagContainer eTagContainer, + IFormatter deserializer, + TimeSpan? localTimeout, + CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); Stopwatch stopwatch = null; @@ -598,6 +625,19 @@ private async Task SendRequestAsync(HttpMethod httpMethod, } } + if (eTagContainer != null) + { + if (!request.Headers.IfNoneMatch.Any()) + { + var eTag = await eTagContainer.GetExistingETagAsync(uri, cancellationToken).ConfigureAwait(false); + + if (eTag != null) + { + request.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(eTag)); + } + } + } + if (content != null) { request.Content = content; @@ -676,11 +716,39 @@ private CancellationTokenSource GetCancellationTokenSourceForTimeout( } } + private IETagContainer GetETagContainer(Request request) + { + return request.ETagContainer ?? Settings.ETagContainer; + } + #region Read response - private async Task ReadResponseAsync(HttpResponseMessage response, Headers headersToFill, CancellationToken cancellationToken) + private async Task ReadResponseAsync( + HttpResponseMessage response, + Headers responseHeader, + IETagContainer eTagContainer, + CancellationToken cancellationToken) { - await HandleResponseAsync(response, headersToFill, cancellationToken).ConfigureAwait(false); - var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + await HandleResponseAsync(response, responseHeader, eTagContainer, cancellationToken).ConfigureAwait(false); + + Stream stream = null; + if (eTagContainer != null && response.StatusCode == HttpStatusCode.NotModified) + { + stream = await eTagContainer.GetDataAsync(response.RequestMessage.RequestUri, cancellationToken).ConfigureAwait(false); + } + else + { + stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + + if (eTagContainer != null) + { + var tag = response.Headers.ETag.Tag; + if (tag != null) + { + await eTagContainer.SaveDataAsync(response.RequestMessage.RequestUri, tag, stream, cancellationToken).ConfigureAwait(false); + } + } + } + cancellationToken.ThrowIfCancellationRequested(); return await DecompressAsync(response, stream, cancellationToken).ConfigureAwait(false); } @@ -704,21 +772,30 @@ private async Task DecompressAsync(HttpResponseMessage response, Stream return stream; } - private async Task HandleResponseAsync(HttpResponseMessage response, Headers headersToFill, CancellationToken cancellationToken) + private async Task HandleResponseAsync( + HttpResponseMessage response, + Headers responseHeaders, + IETagContainer eTagContainer, + CancellationToken cancellationToken) { string content = null; - if (headersToFill != null) + if (responseHeaders != null) { - headersToFill.AddRange(response.Headers); + responseHeaders.AddRange(response.Headers); if (response.Content != null && response.Content.Headers != null) { - headersToFill.AddRange(response.Content.Headers); + responseHeaders.AddRange(response.Content.Headers); } } try { + if (eTagContainer != null && response.StatusCode == HttpStatusCode.NotModified) + { + return; + } + if (!response.IsSuccessStatusCode) { content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); diff --git a/Tiny.RestClient/strong_name.snk b/Tiny.RestClient/strong_name.snk new file mode 100644 index 0000000..4c17718 Binary files /dev/null and b/Tiny.RestClient/strong_name.snk differ