diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f4dd377..8aa2465 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,7 +6,7 @@ version: 2 updates: - package-ecosystem: "nuget" # See documentation for possible values - directory: "/" # Location of package manifests + directory: "/TransformerBeeClient" # Location of package manifests schedule: interval: "weekly" # Maintain dependencies for GitHub Actions diff --git a/.github/workflows/integrationtests.yml b/.github/workflows/integrationtests.yml index 6c1d5ab..42b38b2 100644 --- a/.github/workflows/integrationtests.yml +++ b/.github/workflows/integrationtests.yml @@ -48,3 +48,10 @@ jobs: working-directory: TransformerBeeClient/TransformerBeeClient.IntegrationTest run: | dotnet test + - name: Run Integration Tests (against real service) + working-directory: TransformerBeeClient/TransformerBeeClient.IntegrationTest + env: + CLIENT_ID: ${{ secrets.AUTH0_TEST_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.AUTH0_TEST_CLIENT_SECRET }} + run: | + dotnet test --filter TestCollectionName='authenticated' diff --git a/README.md b/README.md index ebe5d64..11d651e 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,24 @@ Install it from nuget [TransformerBeeClient](https://www.nuget.org/packages/Tran dotnet add package TransformerBeeClient ``` -### Use it in your code +### Authentication +You need to provide something that implements `ITransformerBeeAuthenticator` to the `TransformerBeeClient`. -Then, you can use the client like this: +#### No Authentication +If you're hosting transformer.bee in the same network and there is no authentication, you can use the `NoAuthenticator`. +```csharp +using TransformerBeeClient; +var myAuthenticator = new NoAuthenticationProvider(); +``` + +#### OAuth2 Client and Secret +If, which is more likely, Hochfrequenz provided you with a client ID and secret, you can use the `ClientIdClientSecretAuthenticator` class like this: +```csharp +using TransformerBeeClient; +var myAuthenticator = new ClientIdClientSecretAuthenticationProvider("YOUR_CLIENT_ID", "YOUR_CLIENT_SECRET"); +``` +...todo ```csharp ``` diff --git a/TransformerBeeClient/TransformerBeeClient.IntegrationTest/Bo4eToEdifactTests.cs b/TransformerBeeClient/TransformerBeeClient.IntegrationTest/Bo4eToEdifactTests.cs index 63079dd..deb5f23 100644 --- a/TransformerBeeClient/TransformerBeeClient.IntegrationTest/Bo4eToEdifactTests.cs +++ b/TransformerBeeClient/TransformerBeeClient.IntegrationTest/Bo4eToEdifactTests.cs @@ -13,17 +13,19 @@ namespace TransformerBeeClient.IntegrationTest; public class Bo4eToEdifactTests : IClassFixture { private readonly ClientFixture _client; + private readonly ITransformerBeeAuthenticator _authenticator; public Bo4eToEdifactTests(ClientFixture clientFixture) { _client = clientFixture; + _authenticator = clientFixture.Authenticator; } [Fact] public async Task BOneyComb_Can_Be_Converted_To_Edifact() { var httpClientFactory = _client.HttpClientFactory; - ICanConvertToEdifact client = new TransformerBeeRestClient(httpClientFactory); + ICanConvertToEdifact client = new TransformerBeeRestClient(httpClientFactory, _authenticator); var boneyCombString = await File.ReadAllTextAsync("TestEdifacts/FV2310/55001.json"); var deserializerOptions = new JsonSerializerOptions { diff --git a/TransformerBeeClient/TransformerBeeClient.IntegrationTest/ClientFixture.cs b/TransformerBeeClient/TransformerBeeClient.IntegrationTest/ClientFixture.cs index 9556601..1dec46b 100644 --- a/TransformerBeeClient/TransformerBeeClient.IntegrationTest/ClientFixture.cs +++ b/TransformerBeeClient/TransformerBeeClient.IntegrationTest/ClientFixture.cs @@ -12,6 +12,8 @@ public class ClientFixture : IClassFixture public readonly ServiceCollection ServiceCollection; + public readonly ITransformerBeeAuthenticator Authenticator; + public ClientFixture() { var services = new ServiceCollection(); @@ -22,5 +24,6 @@ public ClientFixture() var serviceProvider = services.BuildServiceProvider(); ServiceCollection = services; HttpClientFactory = serviceProvider.GetService(); + Authenticator = new NoAuthenticator(); // easy for integration tests with transformer.bee running in docker } } diff --git a/TransformerBeeClient/TransformerBeeClient.IntegrationTest/ConnectionTests.cs b/TransformerBeeClient/TransformerBeeClient.IntegrationTest/ConnectionTests.cs index 50e81a2..bdc0f28 100644 --- a/TransformerBeeClient/TransformerBeeClient.IntegrationTest/ConnectionTests.cs +++ b/TransformerBeeClient/TransformerBeeClient.IntegrationTest/ConnectionTests.cs @@ -11,17 +11,19 @@ public class ConnectionTests : IClassFixture { private readonly ClientFixture _client; + private readonly ITransformerBeeAuthenticator _authenticator; public ConnectionTests(ClientFixture clientFixture) { _client = clientFixture; + _authenticator = clientFixture.Authenticator; } [Fact] public async Task IsAvailable_Returns_True_If_Service_Is_Available() { var httpClientFactory = _client.HttpClientFactory; - var client = new TransformerBeeRestClient(httpClientFactory); + var client = new TransformerBeeRestClient(httpClientFactory, _authenticator); var result = await client.IsAvailable(); result.Should().BeTrue(); } @@ -35,7 +37,7 @@ public async Task IsAvailable_Throws_Exception_If_Host_Is_Unavailable() client.BaseAddress = new Uri("http://localhost:1234"); // <-- no service running under this address }); var serviceProvider = services.BuildServiceProvider(); - var client = new TransformerBeeRestClient(serviceProvider.GetService()); + var client = new TransformerBeeRestClient(serviceProvider.GetService(), _authenticator); var checkIfIsAvailable = async () => await client.IsAvailable(); await checkIfIsAvailable.Should().ThrowAsync(); } diff --git a/TransformerBeeClient/TransformerBeeClient.IntegrationTest/EdifactToBo4eTests.cs b/TransformerBeeClient/TransformerBeeClient.IntegrationTest/EdifactToBo4eTests.cs index e18f0ed..690cb96 100644 --- a/TransformerBeeClient/TransformerBeeClient.IntegrationTest/EdifactToBo4eTests.cs +++ b/TransformerBeeClient/TransformerBeeClient.IntegrationTest/EdifactToBo4eTests.cs @@ -2,6 +2,7 @@ using FluentAssertions; using TransformerBeeClient.Model; using Xunit; +using Xunit.Abstractions; namespace TransformerBeeClient.IntegrationTest; @@ -12,19 +13,59 @@ public class EdifactToBo4eTests : IClassFixture { private readonly ClientFixture _client; + private readonly ITransformerBeeAuthenticator _authenticator; + public EdifactToBo4eTests(ClientFixture clientFixture) { _client = clientFixture; + _authenticator = clientFixture.Authenticator; } [Fact] public async Task Edifact_Can_Be_Converted_To_Bo4e() { var httpClientFactory = _client.HttpClientFactory; - ICanConvertToBo4e client = new TransformerBeeRestClient(httpClientFactory); + ICanConvertToBo4e client = new TransformerBeeRestClient(httpClientFactory, _authenticator); + var edifactString = await File.ReadAllTextAsync("TestEdifacts/FV2310/55001.edi"); + var result = await client.ConvertToBo4e(edifactString, EdifactFormatVersion.FV2310); + result.Should().BeOfType>(); + result.Single().Transaktionen.Should().NotBeNullOrEmpty(); + } +} + +/// +/// those tests only run if the environment variables are set up correctly +/// +[Collection("authenticated")] +public class EdifactToBo4eTestsWithAuthentication : IClassFixture +{ + private readonly GithubActionClientFixture _client; + + private readonly ITransformerBeeAuthenticator? _authenticationProvider; + + ITestOutputHelper _output; + + public EdifactToBo4eTestsWithAuthentication(GithubActionClientFixture clientFixture, ITestOutputHelper output) + { + _client = clientFixture; + _authenticationProvider = clientFixture.AuthenticationProvider; + _output = output; + } + + [SkippableFact] + public async Task Edifact_Can_Be_Converted_To_Bo4e_With_Authentication() + { + if (_authenticationProvider is null) + { + _output.WriteLine("Skipping test because no authentication provider is available"); + } + Skip.If(_authenticationProvider is null, "No authentication provider available"); + var httpClientFactory = _client.HttpClientFactory; + ICanConvertToBo4e client = new TransformerBeeRestClient(httpClientFactory, _authenticationProvider); var edifactString = await File.ReadAllTextAsync("TestEdifacts/FV2310/55001.edi"); var result = await client.ConvertToBo4e(edifactString, EdifactFormatVersion.FV2310); result.Should().BeOfType>(); result.Single().Transaktionen.Should().NotBeNullOrEmpty(); + _output.WriteLine("Successfully converted edifact to bo4e - with authentication!"); } } diff --git a/TransformerBeeClient/TransformerBeeClient.IntegrationTest/GithubActionClientFixture.cs b/TransformerBeeClient/TransformerBeeClient.IntegrationTest/GithubActionClientFixture.cs new file mode 100644 index 0000000..0f8fb79 --- /dev/null +++ b/TransformerBeeClient/TransformerBeeClient.IntegrationTest/GithubActionClientFixture.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace TransformerBeeClient.IntegrationTest; + +/// +/// A fixture that sets up the http client factory and an injectable service collection. +/// It's thought to be used in the Github action. +/// +public class GithubActionClientFixture : IClassFixture +{ + public readonly IHttpClientFactory? HttpClientFactory; + + public readonly ServiceCollection? ServiceCollection; + + public readonly ITransformerBeeAuthenticator? AuthenticationProvider; + + public GithubActionClientFixture() + { + var clientId = Environment.GetEnvironmentVariable("CLIENT_ID"); + var clientSecret = Environment.GetEnvironmentVariable("CLIENT_SECRET"); + if (clientSecret is null && clientId is null) + { + return; + } + var services = new ServiceCollection(); + services.AddHttpClient("TransformerBee", client => { client.BaseAddress = new Uri("http://transformerstage.utilibee.io"); }); + var serviceProvider = services.BuildServiceProvider(); + ServiceCollection = services; + HttpClientFactory = serviceProvider.GetService(); + AuthenticationProvider = new ClientIdClientSecretAuthenticator(clientId: clientId, clientSecret: clientSecret); + } +} diff --git a/TransformerBeeClient/TransformerBeeClient.IntegrationTest/TransformerBeeClient.IntegrationTest.csproj b/TransformerBeeClient/TransformerBeeClient.IntegrationTest/TransformerBeeClient.IntegrationTest.csproj index 8b85110..a43ba73 100644 --- a/TransformerBeeClient/TransformerBeeClient.IntegrationTest/TransformerBeeClient.IntegrationTest.csproj +++ b/TransformerBeeClient/TransformerBeeClient.IntegrationTest/TransformerBeeClient.IntegrationTest.csproj @@ -14,6 +14,7 @@ + diff --git a/TransformerBeeClient/TransformerBeeClient.UnitTest/ConnectionTests.cs b/TransformerBeeClient/TransformerBeeClient.UnitTest/ConnectionTests.cs index e770442..ae92893 100644 --- a/TransformerBeeClient/TransformerBeeClient.UnitTest/ConnectionTests.cs +++ b/TransformerBeeClient/TransformerBeeClient.UnitTest/ConnectionTests.cs @@ -15,7 +15,7 @@ public void IsAvailable_Throws_ArgumentNullException_If_BaseAddress_Is_Not_Confi client.BaseAddress = null; }); var serviceProvider = services.BuildServiceProvider(); - var instantiateClient = () => new TransformerBeeRestClient(serviceProvider.GetService()); + var instantiateClient = () => new TransformerBeeRestClient(serviceProvider.GetService(), new NoAuthenticator()); instantiateClient.Should().Throw(); } } diff --git a/TransformerBeeClient/TransformerBeeClient/Class1.cs b/TransformerBeeClient/TransformerBeeClient/Class1.cs deleted file mode 100644 index 768b84c..0000000 --- a/TransformerBeeClient/TransformerBeeClient/Class1.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace TransformerBeeClient; - -public class Class1 -{ -} \ No newline at end of file diff --git a/TransformerBeeClient/TransformerBeeClient/ClientIdClientSecretAuthenticator.cs b/TransformerBeeClient/TransformerBeeClient/ClientIdClientSecretAuthenticator.cs new file mode 100644 index 0000000..43845dd --- /dev/null +++ b/TransformerBeeClient/TransformerBeeClient/ClientIdClientSecretAuthenticator.cs @@ -0,0 +1,136 @@ +namespace TransformerBeeClient; + +using System.Security.Authentication; +using IdentityModel.Client; +using System.IdentityModel.Tokens.Jwt; + +/// +/// a that is based on a client secret and a client id +/// +public class ClientIdClientSecretAuthenticator : ITransformerBeeAuthenticator +{ + protected const string Auth0Domain = "https://hochfrequenz.eu.auth0.com"; + protected const string TransformerBeeScope = "client:default"; + protected const string TransformerAudience = "https://transformer.bee"; + + /// + /// JWT token + /// + private string? _token; + + private readonly string? _clientSecret; + private readonly string? _clientId; + + /// + /// true iff the client should use authentication + /// + private readonly bool _useAuthentication; + + /// + /// + /// + public bool UseAuthentication() => _useAuthentication; + + /// + /// prevents us from requesting multiple tokens at the same time + /// + private readonly SemaphoreSlim _tokenRequestSemaphore = new(1, 1); + + /// A that is based on a client secret and a client id + /// OAuth2 client secret (ask Hochfrequenz) + /// OAuth2 client ID (ask Hochfrequenz) + public ClientIdClientSecretAuthenticator(string? clientId, string? clientSecret) + { + if (!string.IsNullOrWhiteSpace(clientId) && string.IsNullOrWhiteSpace(clientSecret)) + { + throw new ArgumentNullException(nameof(clientSecret), $"If you provide a {nameof(clientId)} you must also provide a {nameof(clientSecret)}"); + } + + if (!string.IsNullOrWhiteSpace(clientSecret) && string.IsNullOrWhiteSpace(clientId)) + { + throw new ArgumentNullException(nameof(clientId), $"If you provide a {nameof(clientSecret)} you must also provide a {nameof(clientId)}"); + } + + _useAuthentication = !string.IsNullOrWhiteSpace(clientId) && !string.IsNullOrWhiteSpace(clientSecret); + if (_useAuthentication) + { + _clientId = clientId; + _clientSecret = clientSecret; + } + } + + /// + /// returns true if the will expire in the next 5 minutes + /// + /// jwt + /// true if the token should be refreshed + private static bool TokenNeedsToBeRefreshed(string token) + { + ArgumentNullException.ThrowIfNull(token); + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadToken(token) as JwtSecurityToken; + if (jwtToken == null) return true; // Assume renewal if token cannot be read + var expiry = jwtToken.ValidTo; + // Consider a buffer to renew the token a bit before it actually expires + var buffer = TimeSpan.FromMinutes(5); + return DateTime.UtcNow.Add(buffer) >= expiry; + } + + /// + /// gets the oauth2 discovery document from the respective auth0 domain + /// + /// + /// + private static async Task GetDiscoveryDocument(HttpClient client) + { + var discoveryDocument = await client.GetDiscoveryDocumentAsync(Auth0Domain); + if (discoveryDocument.IsError) + { + throw new AuthenticationException($"Could not get discovery document from auth0: {discoveryDocument.Error}"); + } + + return discoveryDocument; + } + + /// + /// fetch a token to talk to the transformer bee + /// + /// the token + /// if something goes wrong + public async Task Authenticate(HttpClient client) + { + using (_tokenRequestSemaphore) + { + if (!UseAuthentication()) + { + throw new AuthenticationException("You must provide a client id and a client secret to the constructor to authenticate with the transformer bee"); + } + + if (!string.IsNullOrWhiteSpace(_token) && !TokenNeedsToBeRefreshed(_token)) + { + return _token; + } + + var discoveryDocument = await GetDiscoveryDocument(client); + var tokenRequest = new ClientCredentialsTokenRequest + { + Address = discoveryDocument.TokenEndpoint, + ClientId = _clientId!, + ClientSecret = _clientSecret!, + GrantType = "client_credentials", + Scope = TransformerBeeScope, + }; + tokenRequest.Parameters.AddOptional("audience", TransformerAudience); + + var tokenResponse = await client.RequestClientCredentialsTokenAsync(tokenRequest); + if (tokenResponse.IsError) + { + throw new AuthenticationException($"Could not get token from auth0: {tokenResponse.Error}"); + } + + _token = tokenResponse.AccessToken!; + } + + return _token; + } +} diff --git a/TransformerBeeClient/TransformerBeeClient/Interfaces.cs b/TransformerBeeClient/TransformerBeeClient/Interfaces.cs index 0398e0e..1dd9eaf 100644 --- a/TransformerBeeClient/TransformerBeeClient/Interfaces.cs +++ b/TransformerBeeClient/TransformerBeeClient/Interfaces.cs @@ -32,3 +32,21 @@ public interface ICanConvertToEdifact /// the edifact as plain string public Task ConvertToEdifact(BOneyComb boneyComb, EdifactFormatVersion formatVersion); } + +/// +/// Can provide information on whether you need to authenticate against transformer.bee and how +/// +public interface ITransformerBeeAuthenticator +{ + /// + /// returns true iff the client should use authentication + /// + /// + public bool UseAuthentication(); + + /// + /// provides the token to authenticate against transformer.bee (if and only if is true) + /// + /// + public Task Authenticate(HttpClient client); +} diff --git a/TransformerBeeClient/TransformerBeeClient/NoAutenticator.cs b/TransformerBeeClient/TransformerBeeClient/NoAutenticator.cs new file mode 100644 index 0000000..fdf0e02 --- /dev/null +++ b/TransformerBeeClient/TransformerBeeClient/NoAutenticator.cs @@ -0,0 +1,14 @@ +namespace TransformerBeeClient; + +/// +/// a stub that just indicates, we don't need to authenticate against transformer.bee, because we're e.g. in the same network +/// +public class NoAuthenticator : ITransformerBeeAuthenticator +{ + public bool UseAuthentication() => false; + + public Task Authenticate(HttpClient client) + { + throw new NotImplementedException("This must never be called, because we don't use authentication."); + } +} diff --git a/TransformerBeeClient/TransformerBeeClient/RestClient.cs b/TransformerBeeClient/TransformerBeeClient/RestClient.cs index 024558d..3628e2b 100644 --- a/TransformerBeeClient/TransformerBeeClient/RestClient.cs +++ b/TransformerBeeClient/TransformerBeeClient/RestClient.cs @@ -1,8 +1,11 @@ +using System.Security.Authentication; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using EDILibrary; using TransformerBeeClient.Model; +using System.Net; +using System.Net.Http.Headers; namespace TransformerBeeClient; @@ -11,7 +14,9 @@ namespace TransformerBeeClient; /// public class TransformerBeeRestClient : ICanConvertToBo4e, ICanConvertToEdifact { + private readonly ITransformerBeeAuthenticator _authenticator; private readonly HttpClient _httpClient; + private readonly JsonSerializerOptions _jsonSerializerOptions = new() { PropertyNameCaseInsensitive = true, @@ -20,17 +25,32 @@ public class TransformerBeeRestClient : ICanConvertToBo4e, ICanConvertToEdifact /// /// Provide the constructor with a http client factory. - /// It will create a client from said factory and use the for that. + /// It will create a client from said factory and use the for that. /// /// factory to create the http client from - /// name used to create the client + /// something that tells you whether and how you need to authenticate yourself at transformer.bee + /// name used to create the client /// Find the OpenAPI Spec here: https://transformerstage.utilibee.io/swagger/index.html - public TransformerBeeRestClient(IHttpClientFactory httpClientFactory, string clientName = "TransformerBee") + public TransformerBeeRestClient(IHttpClientFactory httpClientFactory, ITransformerBeeAuthenticator authenticator, string httpClientName = "TransformerBee") { - _httpClient = httpClientFactory.CreateClient(clientName); + _httpClient = httpClientFactory.CreateClient(httpClientName); if (_httpClient.BaseAddress == null) { - throw new ArgumentNullException(nameof(httpClientFactory), $"The http client factory must provide a base address for the client with name '{clientName}'"); + throw new ArgumentNullException(nameof(httpClientFactory), $"The http client factory must provide a base address for the client with name '{httpClientName}'"); + } + + _authenticator = authenticator ?? throw new ArgumentNullException(nameof(authenticator)); + } + + /// + /// Make sure that the client is authenticated, if necessary + /// + private async Task EnsureAuthentication() + { + if (_authenticator.UseAuthentication()) + { + var token = await _authenticator.Authenticate(_httpClient); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); } } @@ -53,6 +73,8 @@ public async Task IsAvailable() var versionUrl = uriBuilder.Uri.AbsoluteUri; var response = await _httpClient.GetAsync(versionUrl); + // note that this is available without any authentication + // see e.g. http://transformerstage.utilibee.io/version return response.IsSuccessStatusCode; } @@ -69,6 +91,7 @@ public async Task> ConvertToBo4e(string edifact, EdifactFor { throw new ArgumentNullException(nameof(edifact)); } + var uriBuilder = new UriBuilder(_httpClient!.BaseAddress!) { Path = "/v1/transformer/EdiToBo4E" @@ -81,12 +104,18 @@ public async Task> ConvertToBo4e(string edifact, EdifactFor FormatVersion = formatVersion, }; var requestJson = JsonSerializer.Serialize(request, _jsonSerializerOptions); + await EnsureAuthentication(); var httpResponse = await _httpClient.PostAsync(convertUrl, new StringContent(requestJson, Encoding.UTF8, "application/json")); if (!httpResponse.IsSuccessStatusCode) { - // e.g. 401 + if (httpResponse.StatusCode == HttpStatusCode.Unauthorized && !_authenticator.UseAuthentication()) + { + throw new AuthenticationException($"Did you correctly set up the {nameof(ITransformerBeeAuthenticator)}?"); + } + throw new HttpRequestException($"Could not convert {edifact} to BO4E. Status code: {httpResponse.StatusCode}"); } + var responseContent = await httpResponse.Content.ReadAsStringAsync(); var bo4eResponse = JsonSerializer.Deserialize(responseContent, _jsonSerializerOptions); // todo: handle the case that the deserialization fails and bo4eResponse is null @@ -102,6 +131,7 @@ public async Task ConvertToEdifact(BOneyComb boneyComb, EdifactFormatVer { throw new ArgumentNullException(nameof(boneyComb)); } + var uriBuilder = new UriBuilder(_httpClient!.BaseAddress!) { Path = "/v1/transformer/Bo4ETransactionToEdi" @@ -115,12 +145,18 @@ public async Task ConvertToEdifact(BOneyComb boneyComb, EdifactFormatVer FormatVersion = formatVersion, }; var requestJson = JsonSerializer.Serialize(request, _jsonSerializerOptions); + await EnsureAuthentication(); var httpResponse = await _httpClient.PostAsync(convertUrl, new StringContent(requestJson, Encoding.UTF8, "application/json")); if (!httpResponse.IsSuccessStatusCode) { - // e.g. 401 + if (httpResponse.StatusCode == HttpStatusCode.Unauthorized && !_authenticator.UseAuthentication()) + { + throw new AuthenticationException($"Did you correctly set up the {nameof(ITransformerBeeAuthenticator)}?"); + } + throw new HttpRequestException($"Could not convert to EDIFACT; Status code: {httpResponse.StatusCode}"); } + var responseContent = await httpResponse.Content.ReadAsStringAsync(); // todo: ensure that the deserialization does not fail and the response is not empty var responseBody = JsonSerializer.Deserialize(responseContent!, _jsonSerializerOptions); diff --git a/TransformerBeeClient/TransformerBeeClient/TransformerBeeClient.csproj b/TransformerBeeClient/TransformerBeeClient/TransformerBeeClient.csproj index 42d5454..5489d3b 100644 --- a/TransformerBeeClient/TransformerBeeClient/TransformerBeeClient.csproj +++ b/TransformerBeeClient/TransformerBeeClient/TransformerBeeClient.csproj @@ -13,7 +13,9 @@ + +