diff --git a/.github/workflows/UploadFile_build_and_test_on_main.yml b/.github/workflows/UploadFile_build_and_test_on_main.yml new file mode 100644 index 0000000..3e5b6c8 --- /dev/null +++ b/.github/workflows/UploadFile_build_and_test_on_main.yml @@ -0,0 +1,17 @@ +name: UploadFile build_main + +on: + push: + branches: + - main + paths: + - "Frends.HTTP.UploadFile/**" + workflow_dispatch: + +jobs: + build: + uses: FrendsPlatform/FrendsTasks/.github/workflows/build_main.yml@main + with: + workdir: Frends.HTTP.UploadFile + secrets: + badge_service_api_key: ${{ secrets.BADGE_SERVICE_API_KEY }} diff --git a/.github/workflows/UploadFile_build_and_test_on_push.yml b/.github/workflows/UploadFile_build_and_test_on_push.yml new file mode 100644 index 0000000..0e4cbf3 --- /dev/null +++ b/.github/workflows/UploadFile_build_and_test_on_push.yml @@ -0,0 +1,18 @@ +name: UploadFile push + +on: + push: + branches-ignore: + - main + paths: + - "Frends.HTTP.UploadFile/**" + workflow_dispatch: + +jobs: + build: + uses: FrendsPlatform/FrendsTasks/.github/workflows/build_test.yml@main + with: + workdir: Frends.HTTP.UploadFile + secrets: + badge_service_api_key: ${{ secrets.BADGE_SERVICE_API_KEY }} + test_feed_api_key: ${{ secrets.TASKS_TEST_FEED_API_KEY }} diff --git a/.github/workflows/UploadFile_release.yml b/.github/workflows/UploadFile_release.yml new file mode 100644 index 0000000..2efb48b --- /dev/null +++ b/.github/workflows/UploadFile_release.yml @@ -0,0 +1,12 @@ +name: UploadFile release + +on: + workflow_dispatch: + +jobs: + build: + uses: FrendsPlatform/FrendsTasks/.github/workflows/release.yml@main + with: + workdir: Frends.HTTP.UploadFile + secrets: + feed_api_key: ${{ secrets.TASKS_FEED_API_KEY }} diff --git a/Frends.HTTP.UploadFile/CHANGELOG.md b/Frends.HTTP.UploadFile/CHANGELOG.md new file mode 100644 index 0000000..17139d1 --- /dev/null +++ b/Frends.HTTP.UploadFile/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [1.0.0] - 2025-01-09 +### Added +- Initial implementation. diff --git a/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.Tests/CertificateHandler.cs b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.Tests/CertificateHandler.cs new file mode 100644 index 0000000..d68f711 --- /dev/null +++ b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.Tests/CertificateHandler.cs @@ -0,0 +1,57 @@ +using Pluralsight.Crypto; +using System; +using System.IO; +using System.Security.Cryptography.X509Certificates; + +namespace Frends.HTTP.UploadFile.Tests; + +public static class CertificateHandler +{ + public static string Handle(string path, string password, bool cleanUp, string thumbPrint) + { + try + { + if (!cleanUp) + { + using CryptContext ctx = new(); + ctx.Open(); + + X509Certificate2 cert = ctx.CreateSelfSignedCertificate( + new SelfSignedCertProperties + { + IsPrivateKeyExportable = true, + KeyBitLength = 4096, + Name = new X500DistinguishedName("cn=localhost"), + ValidFrom = DateTime.Today.AddDays(-1), + ValidTo = DateTime.Today.AddMinutes(1), + }); + + byte[] certData = cert.Export(X509ContentType.Pfx, password); + + using (X509Store store = new(StoreName.My, StoreLocation.CurrentUser)) + { + store.Open(OpenFlags.ReadWrite); + store.Add(cert); + } + + File.WriteAllBytes(path, certData); + return cert.Thumbprint; + } + else + { + using X509Store store = new(StoreName.My, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite | OpenFlags.IncludeArchived); + X509Certificate2Collection col = store.Certificates.Find(X509FindType.FindByThumbprint, thumbPrint, false); + + foreach (var cert in col) + store.Remove(cert); + + return null; + } + } + catch (Exception ex) + { + throw new Exception(ex.Message); + } + } +} \ No newline at end of file diff --git a/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.Tests/Frends.HTTP.UploadFile.Tests.csproj b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.Tests/Frends.HTTP.UploadFile.Tests.csproj new file mode 100644 index 0000000..b7d0f03 --- /dev/null +++ b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.Tests/Frends.HTTP.UploadFile.Tests.csproj @@ -0,0 +1,32 @@ + + + + net6.0 + + false + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + \ No newline at end of file diff --git a/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.Tests/IntegrationTest.cs b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.Tests/IntegrationTest.cs new file mode 100644 index 0000000..61bb65f --- /dev/null +++ b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.Tests/IntegrationTest.cs @@ -0,0 +1,188 @@ +using Frends.HTTP.UploadFile.Definitions; +using HttpMock; +using HttpMock.Verify.NUnit; +using NUnit.Framework; +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Frends.HTTP.UploadFile.Tests; + +[TestFixture] +public class IntegrationTest +{ + private IHttpServer _stubHttp; + private static readonly string testFilePath = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "../../..", "Test_files", "test_file.txt")); + private static readonly string testCodePageName = "iso-8859-1"; + private static readonly string expectedContentType = $"text/plain; charset={testCodePageName}"; + private static readonly Header contentTypeHeader = new() { Name = "cONtENT-tYpE", Value = expectedContentType }; + + private static readonly Input defaultInput = new() + { + Method = Method.POST, + Url = "http://localhost:9191/endpoint", + Headers = new Header[1] { contentTypeHeader }, + FilePath = testFilePath + }; + + + [SetUp] + public void Setup() + { + _stubHttp = HttpMockRepository.At("http://localhost:9191"); + _stubHttp.Stub(x => x.Post("/endpoint")) + .AsContentType($"text/plain; charset={testCodePageName}") + .Return("foo ���") + .OK(); + _stubHttp.Stub(x => x.Post("/wrong-endpoint")) + .AsContentType($"text/plain") + .Return("error occured") + .NotFound(); + } + + [Test] + public async Task RequestShouldSetEncodingWithContentTypeCharsetIgnoringCase() + { + var utf8ByteArray = File.ReadAllBytes(testFilePath); + + var result = await UploadFileTask.UploadFile(defaultInput, new Options(), CancellationToken.None); + + var request = _stubHttp.AssertWasCalled(called => called.Post("/endpoint")).LastRequest(); + var requestHead = request.RequestHead; + var requestBodyByteArray = Encoding.GetEncoding(testCodePageName).GetBytes(request.Body); + var requestContentType = requestHead.Headers["cONTENT-tYpE"]; + + Assert.That(requestContentType, Is.EqualTo(expectedContentType)); + Assert.That(requestBodyByteArray, Is.EqualTo(utf8ByteArray)); + } + + [Test] + public void ThrowOnErrorResponse() + { + var input = defaultInput; + input.Url = "http://localhost:9191/wrong-endpoint"; + var options = new Options + { + ThrowExceptionOnErrorResponse = true + }; + + Assert.ThrowsAsync(() => UploadFileTask.UploadFile(input, options, CancellationToken.None)); + } + + [Test] + public async Task AddBasicAuthHeader() + { + var options = new Options + { + Authentication = Authentication.Basic, + Username = "foo", + Password = "bar", + }; + var encodedValue = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{options.Username}:{options.Password}")); + + var result = await UploadFileTask.UploadFile(defaultInput, options, CancellationToken.None); + var request = _stubHttp.AssertWasCalled(called => called.Post("/endpoint")).LastRequest(); + Assert.That(request.RequestHead.Headers["Authorization"], Is.EqualTo($"Basic {encodedValue}")); + } + + [Test] + public async Task AddOAuthHeader() + { + var options = new Options + { + Authentication = Authentication.OAuth, + Token = "foobar" + }; + + var result = await UploadFileTask.UploadFile(defaultInput, options, CancellationToken.None); + + var request = _stubHttp.AssertWasCalled(called => called.Post("/endpoint")).LastRequest(); + Assert.That(request.RequestHead.Headers["Authorization"], Is.EqualTo($"Bearer foobar")); + } + + [Test] + public async Task AddInvalidHeader() + { + Header invalidHeader = new() { Name = string.Empty, Value = "bar" }; + var input = defaultInput; + input.Headers = new Header[2] { contentTypeHeader, invalidHeader }; + + var result = await UploadFileTask.UploadFile(input, new Options(), CancellationToken.None); + var request = _stubHttp.AssertWasCalled(called => called.Post("/endpoint")).LastRequest(); + var isInvalidAdded = request.RequestHead.Headers.Any(x => x.Key == string.Empty); + Assert.IsFalse(isInvalidAdded); + } + + [Test] + public async Task AddWindowsIntegratedSecurity() + { + var options = new Options + { + Authentication = Authentication.WindowsIntegratedSecurity + }; + + var result = await UploadFileTask.UploadFile(defaultInput, options, CancellationToken.None); + + var request = _stubHttp.AssertWasCalled(called => called.Post("/endpoint")).LastRequest(); + } + + [Test] + public async Task AddWindowsAuthentication() + { + var options = new Options + { + Authentication = Authentication.WindowsAuthentication, + Username = "user\\domain" + }; + + var result = await UploadFileTask.UploadFile(defaultInput, options, CancellationToken.None); + + var request = _stubHttp.AssertWasCalled(called => called.Post("/endpoint")).LastRequest(); + } + + [Test] + public void ThrowWhenAddWindowsAuthenticationWithInvalidUsername() + { + var options = new Options + { + Authentication = Authentication.WindowsAuthentication, + Username = "userWithoutDomain" + }; + + Assert.ThrowsAsync(() => UploadFileTask.UploadFile(defaultInput, options, CancellationToken.None)); + } + + [Test] + public async Task AddClientCertiface() + { + string certPath = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "../../..", "Test_files", "certwithpk.pfx")); + string certPassword = "password"; + var thumprint = CertificateHandler.Handle(certPath, certPassword, false, null); + + var options = new Options + { + Authentication = Authentication.ClientCertificate, + CertificateThumbprint = thumprint + }; + + var result = await UploadFileTask.UploadFile(defaultInput, options, CancellationToken.None); + + var request = _stubHttp.AssertWasCalled(called => called.Post("/endpoint")).LastRequest(); + } + + [Test] + public void ThrowWhenInvalidClientCertificate() + { + var options = new Options + { + Authentication = Authentication.ClientCertificate, + CertificateThumbprint = "thumbprint" + }; + + Assert.ThrowsAsync(() => UploadFileTask.UploadFile(defaultInput, options, CancellationToken.None)); + } +} \ No newline at end of file diff --git a/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.Tests/Test_files/test_file.txt b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.Tests/Test_files/test_file.txt new file mode 100644 index 0000000..863df82 --- /dev/null +++ b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.Tests/Test_files/test_file.txt @@ -0,0 +1,2 @@ +Lorem lipsum +Sumpil merol \ No newline at end of file diff --git a/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.sln b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.sln new file mode 100644 index 0000000..8cbfa93 --- /dev/null +++ b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.33213.308 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Frends.HTTP.UploadFile", "Frends.HTTP.UploadFile\Frends.HTTP.UploadFile.csproj", "{35C305C0-8108-4A98-BB1D-AFE5C926239E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Frends.HTTP.UploadFile.Tests", "Frends.HTTP.UploadFile.Tests\Frends.HTTP.UploadFile.Tests.csproj", "{8CA92187-8E4F-4414-803B-EC899479022E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{500C38D9-EBDF-49FE-ACFB-D22A5191B8BB}" + ProjectSection(SolutionItems) = preProject + CHANGELOG.md = CHANGELOG.md + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {35C305C0-8108-4A98-BB1D-AFE5C926239E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {35C305C0-8108-4A98-BB1D-AFE5C926239E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {35C305C0-8108-4A98-BB1D-AFE5C926239E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {35C305C0-8108-4A98-BB1D-AFE5C926239E}.Release|Any CPU.Build.0 = Release|Any CPU + {8CA92187-8E4F-4414-803B-EC899479022E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8CA92187-8E4F-4414-803B-EC899479022E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8CA92187-8E4F-4414-803B-EC899479022E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8CA92187-8E4F-4414-803B-EC899479022E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {55BC6629-85C9-48D8-8CA2-B0046AF1AF4B} + EndGlobalSection +EndGlobal diff --git a/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Authentication.cs b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Authentication.cs new file mode 100644 index 0000000..0999dc4 --- /dev/null +++ b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Authentication.cs @@ -0,0 +1,37 @@ +namespace Frends.HTTP.UploadFile.Definitions; + +/// +/// Represents the authentication method to be used with the request. +/// +public enum Authentication +{ + /// + /// No authentication is used. + /// + None, + + /// + /// Basic authentication is used, where the username and password are sent in plain text. + /// + Basic, + + /// + /// Windows authentication is used, where the user's Windows login credentials are used to authenticate the request. + /// + WindowsAuthentication, + + /// + /// Windows integrated security is used, where the current Windows user's credentials are used to authenticate the request. + /// + WindowsIntegratedSecurity, + + /// + /// OAuth token-based authentication is used, where a token is obtained from an authentication server and used to authenticate the request. + /// + OAuth, + + /// + /// Client certificate-based authentication is used, where a client certificate is used to authenticate the request. + /// + ClientCertificate +} diff --git a/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Header.cs b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Header.cs new file mode 100644 index 0000000..513a54d --- /dev/null +++ b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Header.cs @@ -0,0 +1,19 @@ +namespace Frends.HTTP.UploadFile.Definitions; +/// +/// Represents an HTTP header, which consists of a name-value pair. +/// +public class Header +{ + /// + /// The name of the header. + /// + /// Example Name + public string Name { get; set; } + + /// + /// The value of the header. + /// + /// Example Value + public string Value { get; set; } + +} diff --git a/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Input.cs b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Input.cs new file mode 100644 index 0000000..d39db1e --- /dev/null +++ b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Input.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel; + +namespace Frends.HTTP.UploadFile.Definitions; + +/// +/// Represents the input data for the HTTP file upload operation. +/// +public class Input +{ + /// + /// The HTTP Method to be used with the request. + /// + /// GET + public Method Method { get; set; } + + /// + /// The URL with protocol and path. You can include query parameters directly in the url. + /// + /// https://example.org/path/to + [DefaultValue("https://example.org/path/to")] + [DisplayFormat(DataFormatString = "Text")] + public string Url { get; set; } + + /// + /// The file path to be posted + /// + /// C:\Users + public string FilePath { get; set; } + + /// + /// List of HTTP headers to be added to the request. + /// + /// Name: Header, Value: HeaderValue + public Header[] Headers { get; set; } +} + diff --git a/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Method.cs b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Method.cs new file mode 100644 index 0000000..b723e56 --- /dev/null +++ b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Method.cs @@ -0,0 +1,17 @@ +namespace Frends.HTTP.UploadFile.Definitions; + + +/// +/// Represents the HTTP method to be used with the request. +/// +public enum Method +{ + /// + /// The HTTP POST method is used to submit an entity to the specified resource, often causing a change in state or side effects on the server. + /// + POST, + /// + /// The HTTP PUT method is used to replace or update a current resource with new content. + /// + PUT +} diff --git a/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Options.cs b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Options.cs new file mode 100644 index 0000000..1251ae4 --- /dev/null +++ b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Options.cs @@ -0,0 +1,78 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel; + +namespace Frends.HTTP.UploadFile.Definitions; + +/// +/// Options for the HTTP request. +/// +public class Options +{ + /// + /// Method of authenticating request + /// + /// Basic + public Authentication Authentication { get; set; } + + /// + /// If WindowsAuthentication is selected you should use domain\username + /// + /// Domain\User + [UIHint(nameof(Definitions.Authentication), "", Authentication.WindowsAuthentication, Authentication.Basic)] + public string Username { get; set; } + + /// + /// Password for the user. + /// + /// Example Password + [PasswordPropertyText] + [UIHint(nameof(Definitions.Authentication), "", Authentication.WindowsAuthentication, Authentication.Basic)] + public string Password { get; set; } + + /// + /// Bearer token to be used for request. Token will be added as Authorization header. + /// + /// exampleOAuthToken + [PasswordPropertyText] + [UIHint(nameof(Definitions.Authentication), "", Authentication.OAuth)] + public string Token { get; set; } + + /// + /// Thumbprint for using client certificate authentication. + /// + /// exampleCertificateThumbprint + [UIHint(nameof(Definitions.Authentication), "", Authentication.ClientCertificate)] + public string CertificateThumbprint { get; set; } + + /// + /// Timeout in seconds to be used for the connection and operation. + /// + /// 30 + [DefaultValue(30)] + public int ConnectionTimeoutSeconds { get; set; } + + /// + /// If FollowRedirects is set to false, all responses with an HTTP status code from 300 to 399 is returned to the application. + /// + /// true + [DefaultValue(true)] + public bool FollowRedirects { get; set; } + + /// + /// Do not throw an exception on certificate error. + /// + /// true + public bool AllowInvalidCertificate { get; set; } + + /// + /// Some API's return faulty content-type charset header. This setting overrides the returned charset. + /// + /// false + public bool AllowInvalidResponseContentTypeCharSet { get; set; } + + /// + /// Throw exception if return code of request is not successfull + /// + /// true + public bool ThrowExceptionOnErrorResponse { get; set; } +} diff --git a/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Response.cs b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Response.cs new file mode 100644 index 0000000..bb38da6 --- /dev/null +++ b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Definitions/Response.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; + +namespace Frends.HTTP.UploadFile.Definitions; + +/// +/// Represents the response received from the HTTP server after sending a request. +/// +public class Response +{ + /// + /// The body of the response. + /// + /// + /// + /// + public string Body { get; set; } + + /// + /// The headers of the response, as a dictionary of key-value pairs. + /// + /// + /// + /// + public Dictionary Headers { get; set; } + + /// + /// The status code of the response, as an integer value. + /// + /// 200 + public int StatusCode { get; set; } +} \ No newline at end of file diff --git a/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.cs b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.cs new file mode 100644 index 0000000..b1221db --- /dev/null +++ b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Frends.HTTP.UploadFile.Definitions; + +namespace Frends.HTTP.UploadFile; + +/// +/// Represents a task that posts a file to a web API endpoint. +/// +public static class UploadFileTask +{ + /// + /// Send file using StreamContent, the file data is read from a Stream and sent as the content of an HTTP request. + /// StreamContent is a class provided by the .NET framework that allows you to send content from a Stream in an HTTP request + /// + /// The input parameters specifying the file to be sent. + /// The optional parameters controlling the file upload behavior. + /// The cancellation token that can be used to cancel the upload operation. + /// Object { string Body, Dictionary[string, string] Headers, int StatusCode } + public static async Task UploadFile([PropertyTab] Input input, [PropertyTab] Options options, CancellationToken cancellationToken) + { + using var handler = new HttpClientHandler(); + handler.SetHandleSettingsBasedOnOptions(options); + + using var httpClient = new HttpClient(handler); + var responseMessage = await GetHttpRequestResponseAsync(httpClient, input, options, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + string body = await responseMessage.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + HttpHeaders contentHeaders = responseMessage.Content.Headers; + HttpHeaders headers = responseMessage.Headers; + + var responseHeaders = CombineHeaders(new HttpHeaders[] { headers, contentHeaders }); + + var response = new Response + { + Body = body, + StatusCode = (int)responseMessage.StatusCode, + Headers = responseHeaders + }; + + if (!responseMessage.IsSuccessStatusCode && options.ThrowExceptionOnErrorResponse) + { + throw new WebException($"Request to '{input.Url}' failed with status code {(int)responseMessage.StatusCode}. Response body: {response.Body}"); + } + + return response; + } + + //Combine http headears collections into one dictionary + private static Dictionary CombineHeaders(HttpHeaders[] headers) + { + var result = headers + .SelectMany(dict => dict) + .ToDictionary(pair => pair.Key, pair => string.Join(";", pair.Value)); + return result; + } + + private static async Task GetHttpRequestResponseAsync(HttpClient httpClient, Input input, Options options, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (options.Authentication == Authentication.Basic || options.Authentication == Authentication.OAuth) + { + switch (options.Authentication) + { + case Authentication.Basic: + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", + Convert.ToBase64String(Encoding.ASCII.GetBytes($"{options.Username}:{options.Password}"))); + break; + case Authentication.OAuth: + httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", + options.Token); + break; + } + } + + using MemoryStream reader = new(File.ReadAllBytes(input.FilePath)); + using HttpContent content = new StreamContent(reader); + var headerDict = input.Headers.ToDictionary(key => key.Name, value => value.Value, StringComparer.InvariantCultureIgnoreCase); + + foreach (var header in headerDict) + { + var requestHeaderAddedSuccessfully = httpClient.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value); + if (!requestHeaderAddedSuccessfully) + { + // Could not add to request headers, try to add to content headers + var contentHeaderAddedSuccessfully = content.Headers.TryAddWithoutValidation(header.Key, header.Value); + if (!contentHeaderAddedSuccessfully) + { + Trace.TraceWarning($"Could not add header {header.Key}:{header.Value}"); + } + } + } + + var request = new HttpRequestMessage(new HttpMethod(input.Method.ToString()), new Uri(input.Url)) + { + Content = content + }; + + var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + return response; + } +} \ No newline at end of file diff --git a/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.csproj b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.csproj new file mode 100644 index 0000000..a64fa71 --- /dev/null +++ b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile.csproj @@ -0,0 +1,28 @@ + + + + net6.0 + Frends + Frends + MIT + https://github.com/FrendsPlatform/Frends.HTTP + true + Frends + true + 1.0.0 + + + + + PreserveNewest + + + + + + + + + + + diff --git a/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/FrendsTaskMetadata.json b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/FrendsTaskMetadata.json new file mode 100644 index 0000000..34dfe4d --- /dev/null +++ b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/FrendsTaskMetadata.json @@ -0,0 +1,7 @@ +{ + "Tasks": [ + { + "TaskMethod": "Frends.HTTP.UploadFile.UploadFileTask.UploadFile" + } + ] +} \ No newline at end of file diff --git a/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/HttpClientHandlerExtensions.cs b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/HttpClientHandlerExtensions.cs new file mode 100644 index 0000000..c654bbd --- /dev/null +++ b/Frends.HTTP.UploadFile/Frends.HTTP.UploadFile/HttpClientHandlerExtensions.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography.X509Certificates; +using System.Text.RegularExpressions; +using Frends.HTTP.UploadFile.Definitions; + +namespace Frends.HTTP.UploadFile; + +/// +/// Provides extension methods for various types, allowing for additional functionality to be added to existing types. +/// +internal static class HttpClientHandlerExtensions +{ + internal static void SetHandleSettingsBasedOnOptions(this HttpClientHandler handler, Options options) + { + switch (options.Authentication) + { + case Authentication.WindowsIntegratedSecurity: + handler.UseDefaultCredentials = true; + break; + case Authentication.WindowsAuthentication: + var domainAndUserName = options.Username.Split('\\'); + if (domainAndUserName.Length != 2) + { + throw new ArgumentException($@"Username needs to be 'domain\username' now it was '{options.Username}'"); + } + handler.Credentials = new NetworkCredential(domainAndUserName[1], options.Password, domainAndUserName[0]); + break; + case Authentication.ClientCertificate: + handler.ClientCertificates.Add(GetCertificate(options.CertificateThumbprint)); + break; + } + + handler.AllowAutoRedirect = options.FollowRedirects; + + if (options.AllowInvalidCertificate) + { + handler.ServerCertificateCustomValidationCallback = (a, b, c, d) => true; + } + } + + internal static X509Certificate2 GetCertificate(string thumbprint) + { + thumbprint = Regex.Replace(thumbprint, @"[^\da-zA-z]", string.Empty).ToUpper(); + var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + try + { + store.Open(OpenFlags.ReadOnly); + var signingCert = store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); + if (signingCert.Count == 0) + { + throw new FileNotFoundException($"Certificate with thumbprint: '{thumbprint}' not found in current user cert store."); + } + + return signingCert[0]; + } + finally + { + store.Close(); + } + } +} diff --git a/Frends.HTTP.UploadFile/README.md b/Frends.HTTP.UploadFile/README.md new file mode 100644 index 0000000..e065fa8 --- /dev/null +++ b/Frends.HTTP.UploadFile/README.md @@ -0,0 +1,28 @@ +# Frends.HTTP.UploadFile +Frends Task for Executing a HTTP UploadFile. + +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) +[![Build](https://github.com/FrendsPlatform/Frends.HTTP/actions/workflows/Request_build_and_test_on_main.yml/badge.svg)](https://github.com/FrendsPlatform/Frends.HTTP/actions) +![MyGet](https://img.shields.io/myget/frends-tasks/v/Frends.HTTP/Frends.HTTP.UploadFile) +![Coverage](https://app-github-custom-badges.azurewebsites.net/Badge?key=FrendsPlatform/Frends.HTTP/Frends.HTTP.UploadFile|main) + +Returns the object of body, headers and statuscode. + +## Installing + +You can install the Task via Frends UI Task View or you can find the NuGet package from the following NuGet feed +https://www.myget.org/F/frends-tasks/api/v2. + +## Building + +Rebuild the project + +`dotnet build` + +Run tests + +`dotnet test` + +Create a NuGet package + +`dotnet pack --configuration Release` diff --git a/README.md b/README.md index f38fe2c..e5f9b90 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Frends tasks for HTTP operations. - [Frends.HTTP.SendBytes](Frends.HTTP.SendBytes/README.md) - [Frends.HTTP.SendAndReceiveBytes](Frends.HTTP.SendAndReceiveBytes/README.md) - [Frends.HTTP.DownloadFile](Frends.HTTP.DownloadFile/README.md) +- [Frends.HTTP.UploadFile](Frends.HTTP.UploadFile/README.md) # Contributing When contributing to this repository, please first discuss the change you wish to make via issue, email, or any other method with the owners of this repository before making a change. @@ -19,4 +20,4 @@ When contributing to this repository, please first discuss the change you wish t 4. Push your work back up to your fork 5. Submit a Pull request so that we can review your changes -NOTE: Be sure to merge the latest from "upstream" before making a pull request! \ No newline at end of file +NOTE: Be sure to merge the latest from "upstream" before making a pull request!