From d31886e34bdaaa853158ac7d3efff3ffd61392d1 Mon Sep 17 00:00:00 2001 From: Rowell Heria Date: Mon, 24 Sep 2018 11:41:22 +0100 Subject: [PATCH] Request builder added. StarlingClient wrapper --- LICENSE.md | 21 ++++ README.md | 1 + StarlingBank.sln | 12 +- .../IStarlingClient.cs} | 11 +- StarlingBank/Api/StarlingClient.cs | 60 +++++++++ StarlingBank/Api/StarlingRequestBuilder.cs | 119 ++++++++++++++++++ .../Entities/Webhook/TransactionContent.cs | 2 +- .../Entities/Webhook/TransactionPayload.cs | 5 +- .../HttpResponseMessageExtensions.cs | 15 --- StarlingBank/StarlingBankClient.cs | 94 -------------- .../StarlingBank.UnitTests.csproj | 17 ++- .../WebhookPayloadValidatorTests.cs | 40 ++++++ 12 files changed, 270 insertions(+), 127 deletions(-) create mode 100644 LICENSE.md create mode 100644 README.md rename StarlingBank/{IStarlingBankClient.cs => Api/IStarlingClient.cs} (72%) create mode 100644 StarlingBank/Api/StarlingClient.cs create mode 100644 StarlingBank/Api/StarlingRequestBuilder.cs delete mode 100644 StarlingBank/Extensions/HttpResponseMessageExtensions.cs delete mode 100644 StarlingBank/StarlingBankClient.cs create mode 100644 tests/StarlingBank.UnitTests/Validators/WebhookPayloadValidatorTests.cs diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..34a0bf1 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Rowell Heria + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f191f59 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Starling Bank diff --git a/StarlingBank.sln b/StarlingBank.sln index 55b2b68..89ed628 100644 --- a/StarlingBank.sln +++ b/StarlingBank.sln @@ -6,8 +6,9 @@ MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StarlingBank", "StarlingBank\StarlingBank.csproj", "{9044593E-C19A-479A-A011-603E6D6D44CF}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{D7396B59-0C59-4FF4-8002-9B1F44C243AC}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarlingBank.UnitTests", "tests\StarlingBank.UnitTests\StarlingBank.UnitTests.csproj", "{38A5B107-0E2F-4F73-A90A-EB8BBDF50294}" + ProjectSection(SolutionItems) = preProject + tests\StarlingBank.UnitTests\StarlingBank.UnitTests.csproj = tests\StarlingBank.UnitTests\StarlingBank.UnitTests.csproj + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -19,17 +20,10 @@ Global {9044593E-C19A-479A-A011-603E6D6D44CF}.Debug|Any CPU.Build.0 = Debug|Any CPU {9044593E-C19A-479A-A011-603E6D6D44CF}.Release|Any CPU.ActiveCfg = Release|Any CPU {9044593E-C19A-479A-A011-603E6D6D44CF}.Release|Any CPU.Build.0 = Release|Any CPU - {38A5B107-0E2F-4F73-A90A-EB8BBDF50294}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {38A5B107-0E2F-4F73-A90A-EB8BBDF50294}.Debug|Any CPU.Build.0 = Debug|Any CPU - {38A5B107-0E2F-4F73-A90A-EB8BBDF50294}.Release|Any CPU.ActiveCfg = Release|Any CPU - {38A5B107-0E2F-4F73-A90A-EB8BBDF50294}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {38A5B107-0E2F-4F73-A90A-EB8BBDF50294} = {D7396B59-0C59-4FF4-8002-9B1F44C243AC} - EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {552B3E62-DA4C-4A2E-B6DA-960C87E48DE4} EndGlobalSection diff --git a/StarlingBank/IStarlingBankClient.cs b/StarlingBank/Api/IStarlingClient.cs similarity index 72% rename from StarlingBank/IStarlingBankClient.cs rename to StarlingBank/Api/IStarlingClient.cs index d37d0c9..e9b0f8b 100644 --- a/StarlingBank/IStarlingBankClient.cs +++ b/StarlingBank/Api/IStarlingClient.cs @@ -3,19 +3,20 @@ using System.Threading.Tasks; using StarlingBank.Entities.SavingsGoals; -namespace StarlingBank +namespace StarlingBank.Api { - public interface IStarlingBankClient + public interface IStarlingClient { + string BaseUrl { get; set; } - #region SavingsGoal + string AccessToken { get; set; } + + // SavingsGoal Task<(HttpStatusCode status, CreateOrUpdateSavingsGoalResponse response)> CreateGoal(SavingsGoalRequest goal); Task<(HttpStatusCode status, SavingsGoal goal)> GetGoal(Guid goalId); Task<(HttpStatusCode status, CreateOrUpdateSavingsGoalResponse response)> AddMoney(Guid goalId, TopUpRequest topup); - - #endregion } } \ No newline at end of file diff --git a/StarlingBank/Api/StarlingClient.cs b/StarlingBank/Api/StarlingClient.cs new file mode 100644 index 0000000..bbf1348 --- /dev/null +++ b/StarlingBank/Api/StarlingClient.cs @@ -0,0 +1,60 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using StarlingBank.Entities.SavingsGoals; + +namespace StarlingBank.Api +{ + public class StarlingClient : IStarlingClient, IDisposable + { + public StarlingClient() + { + this.RequestBuilder = new StarlingRequestBuilder(); + } + + private StarlingRequestBuilder RequestBuilder { get; } + + public string BaseUrl { get; set; } + + public string AccessToken { get; set; } + + public void Dispose() + { + this.RequestBuilder.Dispose(); + } + + public Task<(HttpStatusCode status, CreateOrUpdateSavingsGoalResponse response)> CreateGoal(SavingsGoalRequest goal) + { + var goalId = Guid.NewGuid(); + + return this.RequestBuilder + .WithUrl($"{this.BaseUrl}v1/savings-goals/{goalId}") + .WithMethod(HttpMethod.Put) + .WithHeader("Authorization", $"Bearer {this.AccessToken}") + .WithBody(goal) + .SendAsync(); + } + + public Task<(HttpStatusCode status, SavingsGoal goal)> GetGoal(Guid goalId) + { + return this.RequestBuilder + .WithUrl($"{this.BaseUrl}v1/savings-goals/{goalId}") + .WithMethod(HttpMethod.Get) + .WithHeader("Authorization", $"Bearer {this.AccessToken}") + .SendAsync(); + } + + public Task<(HttpStatusCode status, CreateOrUpdateSavingsGoalResponse response)> AddMoney(Guid goalId, TopUpRequest topup) + { + var transferId = Guid.NewGuid(); + + return this.RequestBuilder + .WithUrl($"{this.BaseUrl}v1/savings-goals/{goalId}/add-money/{transferId}") + .WithMethod(HttpMethod.Put) + .WithHeader("Authorization", $"Bearer {this.AccessToken}") + .WithBody(topup) + .SendAsync(); + } + } +} \ No newline at end of file diff --git a/StarlingBank/Api/StarlingRequestBuilder.cs b/StarlingBank/Api/StarlingRequestBuilder.cs new file mode 100644 index 0000000..67e71a2 --- /dev/null +++ b/StarlingBank/Api/StarlingRequestBuilder.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace StarlingBank.Api +{ + public class StarlingRequestBuilder : IDisposable, IStarlingRequestBuilder, IStarlingRequestBuilderMethod, IStarlingRequestBuilderHeadersBodySend + { + public StarlingRequestBuilder() + { + this.Client = new HttpClient(); + } + + private StarlingRequestBuilder(HttpClient client, HttpRequestMessage request) + { + this.Client = client; + this.Request = request; + } + + protected HttpClient Client { get; } + + protected HttpRequestMessage Request { get; } + + private JsonSerializerSettings SerializerSettings { get; } = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver() + }; + + + public IStarlingRequestBuilderMethod WithUrl(string url) + { + var request = new HttpRequestMessage + { + RequestUri = new Uri(url) + }; + + return new StarlingRequestBuilder(this.Client, request); + } + + public IStarlingRequestBuilderHeadersBodySend WithMethod(HttpMethod method) + { + this.Request.Method = method; + + return this; + } + + public IStarlingRequestBuilderHeadersBodySend WithHeader(string header, string value) + { + this.Request.Headers.Add(header, value); + + return this; + } + + public IStarlingRequestBuilderHeadersBodySend WithHeaders(params KeyValuePair[] headers) + { + foreach (var kvp in headers) + { + this.Request.Headers.Add(kvp.Key, kvp.Value); + } + + return this; + } + + public IStarlingRequestBuilderHeadersBodySend WithBody(T body) where T : class + { + var json = JsonConvert.SerializeObject(body, this.SerializerSettings); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + this.Request.Content = content; + + return this; + } + + public async Task<(HttpStatusCode code, TResult result)> SendAsync() + { + var response = await this.Client.SendAsync(this.Request); + var result = await response.Content.ReadAsAsync(); + + return (response.StatusCode, result); + } + + public Task SendAsync() + { + return this.Client.SendAsync(this.Request); + } + + public void Dispose() + { + this.Client?.Dispose(); + } + } + + public interface IStarlingRequestBuilder + { + IStarlingRequestBuilderMethod WithUrl(string url); + } + + public interface IStarlingRequestBuilderMethod + { + IStarlingRequestBuilderHeadersBodySend WithMethod(HttpMethod method); + } + + public interface IStarlingRequestBuilderHeadersBodySend + { + IStarlingRequestBuilderHeadersBodySend WithHeader(string header, string value); + + IStarlingRequestBuilderHeadersBodySend WithHeaders(params KeyValuePair[] headers); + + IStarlingRequestBuilderHeadersBodySend WithBody(T body) where T : class; + + Task<(HttpStatusCode code, TResult result)> SendAsync(); + + Task SendAsync(); + } +} \ No newline at end of file diff --git a/StarlingBank/Entities/Webhook/TransactionContent.cs b/StarlingBank/Entities/Webhook/TransactionContent.cs index c33f214..9094233 100644 --- a/StarlingBank/Entities/Webhook/TransactionContent.cs +++ b/StarlingBank/Entities/Webhook/TransactionContent.cs @@ -1,6 +1,6 @@ using System; -namespace StarlingBank.Webhook +namespace StarlingBank.Entities.Webhook { /// /// Starling Bank: payload content. diff --git a/StarlingBank/Entities/Webhook/TransactionPayload.cs b/StarlingBank/Entities/Webhook/TransactionPayload.cs index 2e361cb..b2a6dfe 100644 --- a/StarlingBank/Entities/Webhook/TransactionPayload.cs +++ b/StarlingBank/Entities/Webhook/TransactionPayload.cs @@ -1,6 +1,7 @@ using System; +using StarlingBank.Constants; -namespace StarlingBank.Webhook +namespace StarlingBank.Entities.Webhook { public class TransactionPayload { @@ -13,7 +14,7 @@ public class TransactionPayload public Guid AccountHolderUid { get; set; } /// - /// String representation of the webhook event type. + /// String representation of the webhook event type. See for a complete list. /// public string WebhookType { get; set; } diff --git a/StarlingBank/Extensions/HttpResponseMessageExtensions.cs b/StarlingBank/Extensions/HttpResponseMessageExtensions.cs deleted file mode 100644 index ffd1c42..0000000 --- a/StarlingBank/Extensions/HttpResponseMessageExtensions.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Net.Http; -using System.Threading.Tasks; -using Newtonsoft.Json; - -namespace StarlingBank.Extensions -{ - internal static class HttpResponseMessageExtensions - { - public static async Task DeserialiseContent(this HttpResponseMessage response) - { - var content = await response.Content.ReadAsStringAsync(); - return JsonConvert.DeserializeObject(content); - } - } -} \ No newline at end of file diff --git a/StarlingBank/StarlingBankClient.cs b/StarlingBank/StarlingBankClient.cs deleted file mode 100644 index 1b8728d..0000000 --- a/StarlingBank/StarlingBankClient.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Net.Http.Formatting; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; -using StarlingBank.Entities.SavingsGoals; -using StarlingBank.Extensions; - -namespace StarlingBank -{ - public class StarlingBankClient : IStarlingBankClient, IDisposable - { - public StarlingBankClient(Uri baseUri, string token) - { - this.Client = new HttpClient - { - BaseAddress = baseUri, - DefaultRequestHeaders = - { - Authorization = new AuthenticationHeaderValue("Bearer", token), - Accept = { new MediaTypeWithQualityHeaderValue("application/json") } - } - }; - - this.Formatter = new JsonMediaTypeFormatter - { - SerializerSettings = new JsonSerializerSettings - { - ContractResolver = new CamelCasePropertyNamesContractResolver() - } - }; - } - - private HttpClient Client { get; } - - private JsonMediaTypeFormatter Formatter { get; } - - #region SavingsGoal - - public async Task<(HttpStatusCode status, CreateOrUpdateSavingsGoalResponse response)> CreateGoal(SavingsGoalRequest goal) - { - var goalId = Guid.NewGuid(); - var response = await this.Client.PutAsync($"v1/savings-goals/{goalId}", goal, this.Formatter); - - if (!response.IsSuccessStatusCode) - { - return (response.StatusCode, null); - } - - var result = await response.DeserialiseContent(); - - return (response.StatusCode, result); - } - - public async Task<(HttpStatusCode status, SavingsGoal goal)> GetGoal(Guid goalId) - { - var response = await this.Client.GetAsync($"v1/savings-goals/{goalId}"); - - if (!response.IsSuccessStatusCode) - { - return (response.StatusCode, null); - } - - var goal = await response.DeserialiseContent(); - - return (response.StatusCode, goal); - } - - public async Task<(HttpStatusCode status, CreateOrUpdateSavingsGoalResponse response)> AddMoney(Guid goalId, TopUpRequest topup) - { - var transferId = Guid.NewGuid(); - var response = await this.Client.PutAsync($"v1/savings-goals/{goalId}/add-money/{transferId}", topup, this.Formatter); - - if (!response.IsSuccessStatusCode) - { - return (response.StatusCode, null); - } - - var result = await response.DeserialiseContent(); - - return (response.StatusCode, result); - } - - #endregion - - public void Dispose() - { - this.Client.Dispose(); - } - } -} \ No newline at end of file diff --git a/tests/StarlingBank.UnitTests/StarlingBank.UnitTests.csproj b/tests/StarlingBank.UnitTests/StarlingBank.UnitTests.csproj index 9f5c4f4..9730917 100644 --- a/tests/StarlingBank.UnitTests/StarlingBank.UnitTests.csproj +++ b/tests/StarlingBank.UnitTests/StarlingBank.UnitTests.csproj @@ -1,7 +1,22 @@ - netstandard2.0 + netcoreapp2.1 + + false + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + diff --git a/tests/StarlingBank.UnitTests/Validators/WebhookPayloadValidatorTests.cs b/tests/StarlingBank.UnitTests/Validators/WebhookPayloadValidatorTests.cs new file mode 100644 index 0000000..e902192 --- /dev/null +++ b/tests/StarlingBank.UnitTests/Validators/WebhookPayloadValidatorTests.cs @@ -0,0 +1,40 @@ +using System; +using StarlingBank.Utilities; +using StarlingBank.Validators; +using Xunit; + +namespace StarlingBank.UnitTests.Validators +{ + public class WebhookPayloadValidatorTests + { + [Theory] + [InlineData(null, typeof(ArgumentNullException))] + [InlineData("", typeof(EmptyStringException))] + [InlineData(" ", typeof(WhitespaceException))] + public void Validate_NullEmptyWhitespaceSignature_ThrowsException(string value, Type exception) + { + // Act/Assert + Assert.Throws(exception, () => WebhookPayloadValidator.Validate(value, "secret", "{}")); + } + + [Theory] + [InlineData(null, typeof(ArgumentNullException))] + [InlineData("", typeof(EmptyStringException))] + [InlineData(" ", typeof(WhitespaceException))] + public void Validate_NullEmptyWhitespaceSecret_ThrowsException(string value, Type exception) + { + // Act/Assert + Assert.Throws(exception, () => WebhookPayloadValidator.Validate("signature", value, "{}")); + } + + [Theory] + [InlineData(null, typeof(ArgumentNullException))] + [InlineData("", typeof(EmptyStringException))] + [InlineData(" ", typeof(WhitespaceException))] + public void Validate_NullEmptyWhitespaceJson_ThrowsException(string value, Type exception) + { + // Act/Assert + Assert.Throws(exception, () => WebhookPayloadValidator.Validate("signature", "secret", value)); + } + } +} \ No newline at end of file