Skip to content

Commit

Permalink
Various improvements, mocked WebApi for tests
Browse files Browse the repository at this point in the history
  • Loading branch information
YuriyDurov committed Aug 30, 2023
1 parent 1f8cc1e commit 5f5d9f1
Show file tree
Hide file tree
Showing 9 changed files with 263 additions and 44 deletions.
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using BitzArt.Pagination;
using Microsoft.Extensions.Logging;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Web;
Expand All @@ -10,51 +12,83 @@ internal class CommunicatorRestEntityContext<TEntity> : ICommunicationContext<TE
{
internal readonly HttpClient HttpClient;
internal readonly CommunicatorRestServiceOptions ServiceOptions;
internal readonly CommunicatorRestEntityOptions<TEntity> EntityOptions;
internal readonly ILogger _logger;

protected CommunicatorRestEntityOptions<TEntity> _entityOptions;
internal virtual CommunicatorRestEntityOptions<TEntity> EntityOptions
{
get => _entityOptions;
set => _entityOptions = value;
}

internal string GetFullPath(string path)
=> HttpClient.BaseAddress is not null ?
Path.Combine(HttpClient.BaseAddress.ToString(), path) :
path;

internal async Task<TResult> HandleRequestAsync<TResult>(HttpRequestMessage message) where TResult : class
{
try
{
var response = await HttpClient.SendAsync(message);
if (!response.IsSuccessStatusCode) throw new Exception($"External REST Service responded with http status code '{response.StatusCode}'.");
var content = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<TResult>(content, ServiceOptions.SerializerOptions)!;

return result;
}
catch (Exception ex)
{
throw new Exception("An error has occured while processing http request. See inner exception for details.", ex);
}
}

private class KeyNotFoundException : Exception
{
private static readonly string Msg = $"Unable to find TKey for type '{typeof(TEntity).Name}'. Consider specifying a key when registering the entity.";
public KeyNotFoundException() : base(Msg) { }
}

public CommunicatorRestEntityContext(HttpClient httpClient, CommunicatorRestServiceOptions serviceOptions)
public CommunicatorRestEntityContext(HttpClient httpClient, CommunicatorRestServiceOptions serviceOptions, ILogger logger, CommunicatorRestEntityOptions<TEntity> entityOptions)
{
HttpClient = httpClient;
ServiceOptions = serviceOptions;
EntityOptions = null!;
}

public CommunicatorRestEntityContext(HttpClient httpClient, CommunicatorRestServiceOptions serviceOptions, CommunicatorRestEntityOptions<TEntity> entityOptions)
: this(httpClient, serviceOptions)
{
_logger = logger;
EntityOptions = entityOptions;
}

public virtual async Task<IEnumerable<TEntity>> GetAllAsync()
{
var path = EntityOptions.Endpoint is not null ? EntityOptions.Endpoint : string.Empty;
if (HttpClient.BaseAddress is not null) path = Path.Combine(HttpClient.BaseAddress.ToString(), path);
var response = await HttpClient.GetAsync(path);
if (!response.IsSuccessStatusCode) throw new Exception($"External REST Service responded with http status code '{response.StatusCode}'.");
var content = await response.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<IEnumerable<TEntity>>(content, ServiceOptions.SerializerOptions)!;

_logger.LogInformation("GetAll {type}: {path}", typeof(TEntity).Name, GetFullPath(path));

var msg = new HttpRequestMessage(HttpMethod.Get, path);
var result = await HandleRequestAsync<IEnumerable<TEntity>>(msg);

return result;
}

public virtual async Task<PageResult<TEntity>> GetPageAsync(int offset, int limit) => await GetPageAsync(new PageRequest(offset, limit));

public virtual async Task<PageResult<TEntity>> GetPageAsync(PageRequest pageRequest)
{
var path = EntityOptions.Endpoint is not null ? EntityOptions.Endpoint : string.Empty;
var queryIndex = path.IndexOf('?');

var query = queryIndex == -1 ?
HttpUtility.ParseQueryString(string.Empty) :
HttpUtility.ParseQueryString(path.Substring(queryIndex));

var query = HttpUtility.ParseQueryString(path);
query.Add("offset", pageRequest.Offset?.ToString());
query.Add("limit", pageRequest.Limit?.ToString());

var queryIndex = path.IndexOf('?');
if (queryIndex != -1) path = path[..queryIndex];
path = path + "?" + query.ToString();

_logger.LogInformation("GetPage {type}: {path}", typeof(TEntity).Name, GetFullPath(path));

var response = await HttpClient.GetAsync(path);

if (!response.IsSuccessStatusCode) throw new Exception($"External REST Service responded with http status code '{response.StatusCode}'.");
Expand All @@ -69,7 +103,7 @@ public virtual async Task<TEntity> GetAsync(object id)
if (EntityOptions.GetIdEndpointAction is null) throw new KeyNotFoundException();

var idEndpoint = EntityOptions.GetIdEndpointAction(id);

_logger.LogInformation("Get {type}[{id}]: {path}", typeof(TEntity).Name, id.ToString(), GetFullPath(idEndpoint));
var response = await HttpClient.GetAsync(idEndpoint);

if (!response.IsSuccessStatusCode) throw new Exception($"External REST Service responded with http status code '{response.StatusCode}'.");
Expand All @@ -83,10 +117,17 @@ public virtual async Task<TEntity> GetAsync(object id)
internal class CommunicatorRestEntityContext<TEntity, TKey> : CommunicatorRestEntityContext<TEntity>, ICommunicationContext<TEntity, TKey>
where TEntity : class
{
public new readonly CommunicatorRestEntityOptions<TEntity, TKey> EntityOptions;
internal new CommunicatorRestEntityOptions<TEntity, TKey> EntityOptions
{
get => (CommunicatorRestEntityOptions<TEntity, TKey>)_entityOptions;
set
{
_entityOptions = value;
}
}

public CommunicatorRestEntityContext(HttpClient httpClient, CommunicatorRestServiceOptions serviceOptions, CommunicatorRestEntityOptions<TEntity, TKey> entityOptions)
: base(httpClient, serviceOptions)
public CommunicatorRestEntityContext(HttpClient httpClient, CommunicatorRestServiceOptions serviceOptions, ILogger logger, CommunicatorRestEntityOptions<TEntity, TKey> entityOptions)
: base(httpClient, serviceOptions, logger, entityOptions)
{
EntityOptions = entityOptions;
}
Expand All @@ -100,6 +141,8 @@ public async Task<TEntity> GetAsync(TKey id)
if (EntityOptions.GetIdEndpointAction is not null) idEndpoint = EntityOptions.GetIdEndpointAction(id);
else idEndpoint = EntityOptions.Endpoint is not null ? Path.Combine(EntityOptions.Endpoint, id!.ToString()!) : id!.ToString()!;

_logger.LogInformation("Get {type}[{id}]: {path}", typeof(TEntity).Name, id!.ToString(), GetFullPath(idEndpoint));

var response = await HttpClient.GetAsync(idEndpoint);
if (!response.IsSuccessStatusCode) throw new Exception($"External REST Service responded with http status code '{response.StatusCode}'.");
var content = await response.Content.ReadAsStringAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ public class CommunicatorRestEntityOptions<TEntity>
where TEntity : class
{
public string? Endpoint { get; set; }
public Func<object, string>? GetIdEndpointAction { get; set; }
protected Func<object, string>? _getIdEndpointAction;
public Func<object, string>? GetIdEndpointAction
{
get => _getIdEndpointAction;
set => _getIdEndpointAction = value;
}

public CommunicatorRestEntityOptions()
{
Expand All @@ -15,9 +20,25 @@ public CommunicatorRestEntityOptions()
public class CommunicatorRestEntityOptions<TEntity, TKey> : CommunicatorRestEntityOptions<TEntity>
where TEntity : class
{
public new Func<TKey, string>? GetIdEndpointAction { get; set; }
public new Func<TKey, string>? GetIdEndpointAction
{
get
{
if (_getIdEndpointAction is null) return null;
return (key) => _getIdEndpointAction!(key!);
}
set
{
if (value is null)
{
_getIdEndpointAction = null;
return;
}
_getIdEndpointAction = (key) => value!((TKey)key);
}
}

public CommunicatorRestEntityOptions()
public CommunicatorRestEntityOptions()
{
GetIdEndpointAction = null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace BitzArt.Communicator;

Expand Down Expand Up @@ -33,8 +34,10 @@ public ICommunicationContext<TEntity> GetEntityCommunicator<TEntity>(IServicePro

var httpClientFactory = services.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient(ServiceName);
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("Communicator");

return new CommunicatorRestEntityContext<TEntity>(httpClient, _serviceOptions, optionsCasted);
return new CommunicatorRestEntityContext<TEntity>(httpClient, _serviceOptions, logger, optionsCasted);
}

public ICommunicationContext<TEntity, TKey> GetEntityCommunicator<TEntity, TKey>(IServiceProvider services, object? options)
Expand All @@ -44,7 +47,9 @@ public ICommunicationContext<TEntity, TKey> GetEntityCommunicator<TEntity, TKey>

var httpClientFactory = services.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient(ServiceName);
var loggerFactory = services.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("Communicator");

return new CommunicatorRestEntityContext<TEntity, TKey>(httpClient, _serviceOptions, optionsCasted);
return new CommunicatorRestEntityContext<TEntity, TKey>(httpClient, _serviceOptions, logger, optionsCasted);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public interface ICommunicationContext<TEntity>
where TEntity : class
{
public Task<IEnumerable<TEntity>> GetAllAsync();
public Task<PageResult<TEntity>> GetPageAsync(int offset, int limit);
public Task<PageResult<TEntity>> GetPageAsync(PageRequest pageRequest);
public Task<TEntity> GetAsync(object id);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="Moq" Version="4.20.69" />
<PackageReference Include="RichardSzalay.MockHttp" Version="6.0.0" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
110 changes: 89 additions & 21 deletions tests/BitzArt.Communicator.REST.Tests/MockedRestServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,35 +1,103 @@
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json.Serialization;
using BitzArt.Pagination;
using Castle.Components.DictionaryAdapter.Xml;
using Microsoft.Extensions.Logging;
using RichardSzalay.MockHttp;
using System.Net.Http.Json;
using System.Net;

namespace BitzArt.Communicator;

file class Country
{
[JsonPropertyName("cca3")]
public required string CountryCode { get; set; }
}

public class MockedRestServiceTests
{
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(10)]
[InlineData(100)]
[InlineData(1000)]
public async Task GetAllAsync_MockedHttpClient_ReturnsAll(int entityCount)
{
var entityContext = TestEntityContext.GetTestEntityContext(entityCount, x =>
{
x.When($"{MockedService.BaseUrl}/entity")
.Respond(HttpStatusCode.OK,
JsonContent.Create(TestEntity.GetAll(entityCount)));
});

var result = await entityContext.GetAllAsync();

Assert.NotNull(result);
if (entityCount > 0) Assert.True(result.Any());
Assert.True(result.Count() == entityCount);
}

[Theory]
[InlineData(0, 0, 0)]
[InlineData(0, 0, 10)]
[InlineData(100, 0, 10)]
[InlineData(1000, 0, 10)]
[InlineData(10, 5, 10)]
[InlineData(100, 1, 100)]
public async Task GetPageAsync_MockedHttpClient_ReturnsPage(int entityCount, int offset, int limit)
{
var entityContext = TestEntityContext.GetTestEntityContext(entityCount, x =>
{
x.When($"{MockedService.BaseUrl}/entity?offset={offset}&limit={limit}")
.Respond(HttpStatusCode.OK,
JsonContent.Create(TestEntity.GetPage(entityCount, offset, limit)));
});

var result = await entityContext.GetPageAsync(offset, limit);

Assert.NotNull(result);
Assert.NotNull(result.Data);
if (offset < entityCount) Assert.True(result.Data.Any());
if (offset + limit > entityCount)
{
var shouldCount = entityCount - offset;
Assert.Equal(shouldCount, result.Data.Count());
}
}

[Fact]
// TODO: Make it a mocked REST service instead of a real WebAPI
public async Task AddCommunicator_RealService_AbleToRequestAndParseResponse()
public async Task GetAsync_MockedHttpClient_ReturnsEntity()
{
var services = new ServiceCollection();
services.AddCommunicator(x =>
var entityCount = 10;
var id = 1;

var entityContext = TestEntityContext.GetTestEntityContext(entityCount, x =>
{
x.When($"{MockedService.BaseUrl}/entity/{id}")
.Respond(HttpStatusCode.OK,
JsonContent.Create(
TestEntity.GetAll(entityCount).FirstOrDefault(x => x.Id == id)));
});

var result = await entityContext.GetAsync(id);

Assert.NotNull(result);
Assert.Equal(id, result.Id);
}

[Fact]
public async Task GetAsync_CustomIdEndpointLogic_ReturnsEntity()
{
var entityCount = 1;

var entityContext = TestEntityContext.GetTestEntityContext(entityCount, x =>
{
x.AddService("RestCountries")
.UsingRest("https://restcountries.com/v3.1")
.AddEntity<Country>().WithEndpoint("all?fields=cca3");
x.When($"{MockedService.BaseUrl}/entity/specific")
.Respond(HttpStatusCode.OK,
JsonContent.Create(
TestEntity.GetAll(entityCount).FirstOrDefault(x => x.Id == 1)));
});

var serviceProvider = services.BuildServiceProvider();
((CommunicatorRestEntityContext<TestEntity>)entityContext)
.EntityOptions.GetIdEndpointAction = (key) => "entity/specific";

var entityCommunicator = serviceProvider.GetRequiredService<ICommunicationContext<Country>>();
Assert.NotNull(entityCommunicator);
var result = await entityContext.GetAsync(1);

var countries = await entityCommunicator.GetAllAsync();
Assert.NotNull(countries);
Assert.True(countries.Any());
Assert.NotNull(result);
Assert.Equal(1, result.Id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using RichardSzalay.MockHttp;
using System.Net.Http.Json;
using System.Net;

namespace BitzArt.Communicator;

internal class MockedService
{
public const string BaseUrl = "https://mockedservice";

private readonly MockHttpMessageHandler _handler;
private readonly int EntityCount;

public MockedService(int entityCount, Action<MockHttpMessageHandler> configure)
{
EntityCount = entityCount;
_handler = new();
configure(_handler);
}

public HttpClient GetHttpClient() => new(_handler)
{
BaseAddress = new Uri(BaseUrl)
};

public static MockedService GetService(int entityCount, Action<MockHttpMessageHandler>? configureMockWebApi = null)
=> new(entityCount, x =>
{
if (configureMockWebApi is not null) configureMockWebApi(x);
});
}
Loading

0 comments on commit 5f5d9f1

Please sign in to comment.