├── src ├── Tests │ ├── UnitTests │ │ ├── Weather.API.UnitTests │ │ │ ├── Usings.cs │ │ │ └── Weather.API.UnitTests.csproj │ │ ├── Weather.Domain.UnitTests │ │ │ ├── Usings.cs │ │ │ ├── Extensions │ │ │ │ ├── TestError.cs │ │ │ │ ├── EnumerableExtensionsTests.cs │ │ │ │ └── FluentResultExtensionsTests.cs │ │ │ └── Weather.Domain.UnitTests.csproj │ │ ├── Wheaterbit.Client.UnitTests │ │ │ ├── Usings.cs │ │ │ ├── Extensions │ │ │ │ ├── Http │ │ │ │ │ ├── ICustomHttpMessageHandler.cs │ │ │ │ │ ├── CustomHttpClientProxy.cs │ │ │ │ │ ├── HttpClientFactoryMockExtensions.cs │ │ │ │ │ └── CustomHttpMessageHandler.cs │ │ │ │ └── OptionsValidationExtensions.cs │ │ │ ├── DataGenerator │ │ │ │ └── WeatherbitOptionsGenerator.cs │ │ │ ├── Wheaterbit.Client.UnitTests.csproj │ │ │ └── WeatherbitHttpClientTests.cs │ │ ├── Weather.Infrastructure.UnitTests │ │ │ ├── Usings.cs │ │ │ ├── Database │ │ │ │ ├── TestWeatherContext.cs │ │ │ │ └── Repositories │ │ │ │ │ └── WeatherCommandsRepositoryTests.cs │ │ │ └── Weather.Infrastructure.UnitTests.csproj │ │ ├── Weather.Core.UnitTests │ │ │ ├── Usings.cs │ │ │ ├── Weather.Core.UnitTests.csproj │ │ │ ├── Commands │ │ │ │ ├── DeleteFavoriteHandlerTests.cs │ │ │ │ └── AddFavoriteHandlerTests.cs │ │ │ └── Queries │ │ │ │ ├── GetCurrentWeatherHandlerTests.cs │ │ │ │ └── GetForecastWeatherHandlerTests.cs │ │ └── Weather.UnitTests.Common │ │ │ ├── Weather.UnitTests.Common.csproj │ │ │ └── Extensions │ │ │ └── MoqLoggerExtensions.cs │ ├── SystemTests │ │ ├── Weather.API.SystemTests │ │ │ ├── Usings.cs │ │ │ ├── Weather.API.SystemTests.csproj │ │ │ └── WeatherSystemTests.cs │ │ └── Weather.Infrastructure.SystemTests │ │ │ ├── Usings.cs │ │ │ └── Weather.Infrastructure.SystemTests.csproj │ ├── IntegrationTests │ │ └── Weather.Infrastructure.IntegrationTests │ │ │ ├── Usings.cs │ │ │ └── Weather.Infrastructure.IntegrationTests.csproj │ └── HttpDebug │ │ └── debug-tests.http ├── global.json ├── Weather.Core │ ├── HandlerModel │ │ ├── EmptyRequest.cs │ │ ├── DataResponse.cs │ │ ├── HandlerStatusCode.cs │ │ ├── HandlerResponse.cs │ │ ├── RequestValidationResult.cs │ │ └── HandlerResponses.cs │ ├── Abstractions │ │ ├── IRequestHandler.cs │ │ ├── IRequestValidator.cs │ │ ├── IStatusRequestHandler.cs │ │ ├── IWeatherQueriesRepository.cs │ │ ├── IWeatherService.cs │ │ ├── IWeatherCommandsRepository.cs │ │ └── ValidationStatusRequestHandler.cs │ ├── Validation │ │ ├── LocationDtoValidator.cs │ │ ├── GeneralPredicates.cs │ │ ├── DeleteFavoriteCommandValidator.cs │ │ ├── AddFavoriteCommandValidator.cs │ │ ├── GetForecastWeatherValidator.cs │ │ ├── GetCurrentWeatherQueryValidator.cs │ │ ├── ForecastWeatherDtoValidator.cs │ │ └── CurrentWeatherDtoValidator.cs │ ├── Commands │ │ ├── DeleteFavoriteHandler.cs │ │ └── AddFavoriteHandler.cs │ ├── Weather.Core.csproj │ ├── Configuration │ │ └── ContainerConfigurationExtension.cs │ ├── Queries │ │ ├── GetForecastWeatherHandler.cs │ │ ├── GetCurrentWeatherHandler.cs │ │ └── GetFavoritesHandler.cs │ └── Resources │ │ ├── ErrorMessages.Designer.cs │ │ └── ErrorMessages.resx ├── Weather.API │ ├── appsettings.Development.json │ ├── Extensions │ │ ├── ApiVersionExtensions.cs │ │ ├── RouteHandlerBuilderExtensions.cs │ │ └── HandlerExtensions.cs │ ├── appsettings.json │ ├── Configuration │ │ └── ContainerConfigurationExtension.cs │ ├── Dockerfile │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── Weather.API.csproj │ ├── Middlewares │ │ └── ExceptionMiddleware.cs │ └── EndpointBuilders │ │ └── WeatherBuilder.cs ├── Weather.Domain │ ├── Commands │ │ ├── DeleteFavoriteCommand.cs │ │ └── AddFavoriteCommand.cs │ ├── Dtos │ │ ├── FavoriteCurrentWeatherDto.cs │ │ ├── LocationDto.cs │ │ ├── ForecastTemperatureDto.cs │ │ ├── FavoritesWeatherDto.cs │ │ ├── ForecastWeatherDto.cs │ │ └── CurrentWeatherDto.cs │ ├── BusinessEntities │ │ └── FavoriteLocation.cs │ ├── Queries │ │ ├── GetCurrentWeatherQuery.cs │ │ └── GetForecastWeatherQuery.cs │ ├── Extensions │ │ ├── FluentResultExtensions.cs │ │ └── EnumerableExtensions.cs │ ├── Weather.Domain.csproj │ ├── Logging │ │ └── LogEvents.cs │ └── Resources │ │ ├── ErrorLogMessages.Designer.cs │ │ └── ErrorLogMessages.resx ├── Wheaterbit.Client │ ├── Dtos │ │ ├── ForecastTemperatureDto.cs │ │ ├── CurrentWeatherDataDto.cs │ │ ├── ForecastWeatherDto.cs │ │ └── CurrentWeatherDto.cs │ ├── Abstractions │ │ ├── IJsonSerializerSettingsFactory.cs │ │ └── IWeatherbitHttpClient.cs │ ├── Options │ │ └── WeatherbitOptions.cs │ ├── Factories │ │ └── JsonSerializerSettingsFactory.cs │ ├── Validation │ │ └── WeatherbitOptionsSpecificationHolder.cs │ ├── Configuration │ │ └── ContainerConfigurationExtension.cs │ ├── Wheaterbit.Client.csproj │ └── WeatherbitHttpClient.cs ├── Weather.Infrastructure │ ├── Database │ │ ├── EFContext │ │ │ ├── Entities │ │ │ │ └── FavoriteLocationEntity.cs │ │ │ └── WeatherContext.cs │ │ ├── RepositoryBase.cs │ │ └── Repositories │ │ │ ├── WeatherQueriesRepository.cs │ │ │ └── WeatherCommandsRepository.cs │ ├── Mapping │ │ └── Profiles │ │ │ ├── WeatherEntitiesProfile.cs │ │ │ └── WeatherbitClientProfile.cs │ ├── Weather.Infrastructure.csproj │ ├── Configuration │ │ └── ContainerConfigurationExtension.cs │ ├── Services │ │ └── WeatherService.cs │ └── Resources │ │ ├── ErrorMessages.Designer.cs │ │ └── ErrorMessages.resx ├── .dockerignore ├── Directory.Packages.props └── Weather.API.sln ├── doc └── img │ ├── weatherApi_icon.png │ ├── cleanArchitecture.jpg │ └── weatherApiScalar.gif ├── .github └── workflows │ ├── coverage.yml │ └── dotnet.yml ├── LICENSE.md ├── .gitignore └── README.md /src/Tests/UnitTests/Weather.API.UnitTests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.Domain.UnitTests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /src/Tests/SystemTests/Weather.API.SystemTests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /src/global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "10.0.100" 4 | } 5 | } -------------------------------------------------------------------------------- /src/Tests/SystemTests/Weather.Infrastructure.SystemTests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /src/Tests/IntegrationTests/Weather.Infrastructure.IntegrationTests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /src/Tests/UnitTests/Wheaterbit.Client.UnitTests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; 2 | global using Moq; -------------------------------------------------------------------------------- /doc/img/weatherApi_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gramli/WeatherApi/HEAD/doc/img/weatherApi_icon.png -------------------------------------------------------------------------------- /doc/img/cleanArchitecture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gramli/WeatherApi/HEAD/doc/img/cleanArchitecture.jpg -------------------------------------------------------------------------------- /doc/img/weatherApiScalar.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gramli/WeatherApi/HEAD/doc/img/weatherApiScalar.gif -------------------------------------------------------------------------------- /src/Weather.Core/HandlerModel/EmptyRequest.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.Core.HandlerModel 2 | { 3 | public sealed record EmptyRequest; 4 | } 5 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.Infrastructure.UnitTests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; 2 | global using AutoMapper; 3 | global using FluentResults; 4 | global using Moq; -------------------------------------------------------------------------------- /src/Weather.API/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Weather.Domain/Commands/DeleteFavoriteCommand.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.Domain.Commands 2 | { 3 | public sealed class DeleteFavoriteCommand 4 | { 5 | public int Id { get; init; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.Core.UnitTests/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; 2 | global using Moq; 3 | global using FluentResults; 4 | global using Microsoft.Extensions.Logging; 5 | global using System.Net; 6 | global using Validot; -------------------------------------------------------------------------------- /src/Weather.Domain/Dtos/FavoriteCurrentWeatherDto.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.Domain.Dtos 2 | { 3 | public sealed class FavoriteCurrentWeatherDto : CurrentWeatherDto 4 | { 5 | public int Id { get; init; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Weather.Domain/Dtos/LocationDto.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.Domain.Dtos 2 | { 3 | public class LocationDto 4 | { 5 | public double Latitude { get; init; } 6 | public double Longitude { get; init; } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Wheaterbit.Client/Dtos/ForecastTemperatureDto.cs: -------------------------------------------------------------------------------- 1 | namespace Wheaterbit.Client.Dtos 2 | { 3 | public sealed class ForecastTemperatureDto 4 | { 5 | public double temp { get; init; } 6 | public DateTime datetime { get; init; } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Weather.Core/HandlerModel/DataResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.Core.HandlerModel 2 | { 3 | public class DataResponse 4 | { 5 | public T? Data { get; init; } 6 | 7 | public IEnumerable Errors { get; init; } = []; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Weather.Core/Abstractions/IRequestHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.Core.Abstractions 2 | { 3 | public interface IRequestHandler 4 | { 5 | Task HandleAsync(TRequest request, CancellationToken cancellationToken); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Weather.Domain/BusinessEntities/FavoriteLocation.cs: -------------------------------------------------------------------------------- 1 | using Weather.Domain.Dtos; 2 | 3 | namespace Weather.Domain.BusinessEntities 4 | { 5 | public sealed class FavoriteLocation : LocationDto 6 | { 7 | public int Id { get; init; } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Weather.Domain/Dtos/ForecastTemperatureDto.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.Domain.Dtos 2 | { 3 | public class ForecastTemperatureDto 4 | { 5 | public double Temperature { get; init; } 6 | 7 | public DateTime DateTime { get; init; } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Wheaterbit.Client/Dtos/CurrentWeatherDataDto.cs: -------------------------------------------------------------------------------- 1 | namespace Wheaterbit.Client.Dtos 2 | { 3 | public sealed class CurrentWeatherDataDto 4 | { 5 | public IReadOnlyCollection Data { get; init; } = new List(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Weather.Domain/Commands/AddFavoriteCommand.cs: -------------------------------------------------------------------------------- 1 | using Weather.Domain.Dtos; 2 | 3 | namespace Weather.Domain.Commands 4 | { 5 | public sealed class AddFavoriteCommand 6 | { 7 | public LocationDto Location { get; init; } = new LocationDto(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Wheaterbit.Client/Abstractions/IJsonSerializerSettingsFactory.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace Wheaterbit.Client.Abstractions 4 | { 5 | public interface IJsonSerializerSettingsFactory 6 | { 7 | JsonSerializerSettings Create(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Weather.Core/Abstractions/IRequestValidator.cs: -------------------------------------------------------------------------------- 1 | using Weather.Core.HandlerModel; 2 | 3 | namespace Weather.Core.Abstractions 4 | { 5 | public interface IRequestValidator 6 | { 7 | RequestValidationResult Validate(TRequest request); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Weather.Core/Abstractions/IStatusRequestHandler.cs: -------------------------------------------------------------------------------- 1 | using Weather.Core.HandlerModel; 2 | 3 | namespace Weather.Core.Abstractions 4 | { 5 | public interface IStatusRequestHandler : IRequestHandler, TRequest> 6 | { 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Weather.Core/HandlerModel/HandlerStatusCode.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.Core.HandlerModel 2 | { 3 | public enum HandlerStatusCode 4 | { 5 | Success = 0, 6 | SuccessWithEmptyResult = 1, 7 | ValidationError = 2, 8 | InternalError = 4 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Weather.Domain/Dtos/FavoritesWeatherDto.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.Domain.Dtos 2 | { 3 | public sealed class FavoritesWeatherDto 4 | { 5 | public IReadOnlyCollection FavoriteWeathers { get; init; } = new List(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Weather.Core/HandlerModel/HandlerResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Weather.Core.HandlerModel 4 | { 5 | public class HandlerResponse : DataResponse 6 | { 7 | [JsonIgnore] 8 | public HandlerStatusCode StatusCode { get; init; } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Weather.Core/Abstractions/IWeatherQueriesRepository.cs: -------------------------------------------------------------------------------- 1 | using Weather.Domain.BusinessEntities; 2 | 3 | namespace Weather.Core.Abstractions 4 | { 5 | public interface IWeatherQueriesRepository 6 | { 7 | Task> GetFavorites(CancellationToken cancellationToken); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Wheaterbit.Client.UnitTests/Extensions/Http/ICustomHttpMessageHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Wheaterbit.Client.UnitTests.Extensions.Http 2 | { 3 | public interface ICusomHttpMessageHandler 4 | { 5 | Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Wheaterbit.Client.UnitTests/Extensions/Http/CustomHttpClientProxy.cs: -------------------------------------------------------------------------------- 1 | namespace Wheaterbit.Client.UnitTests.Extensions.Http 2 | { 3 | internal class CustomHttpClientProxy : HttpClient 4 | { 5 | public CustomHttpClientProxy(HttpMessageHandler messageHandler) 6 | : base(messageHandler) { } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Weather.API/Extensions/ApiVersionExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.API.Extensions 2 | { 3 | public static class ApiVersionExtensions 4 | { 5 | public static IEndpointRouteBuilder MapVersionGroup(this IEndpointRouteBuilder builder, int version) 6 | => builder 7 | .MapGroup($"v{version}"); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Wheaterbit.Client/Dtos/ForecastWeatherDto.cs: -------------------------------------------------------------------------------- 1 | namespace Wheaterbit.Client.Dtos 2 | { 3 | public sealed class ForecastWeatherDto 4 | { 5 | public IReadOnlyCollection Data { get; init; } = new List(); 6 | 7 | public string city_name { get; init; } = string.Empty; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Weather.Domain/Dtos/ForecastWeatherDto.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.Domain.Dtos 2 | { 3 | public sealed class ForecastWeatherDto 4 | { 5 | public IReadOnlyCollection ForecastTemperatures { get; init; } = new List(); 6 | 7 | public string CityName { get; init; } = string.Empty; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Weather.Infrastructure/Database/EFContext/Entities/FavoriteLocationEntity.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.Infrastructure.Database.EFContext.Entities 2 | { 3 | public sealed class FavoriteLocationEntity 4 | { 5 | public int Id { get; set; } 6 | public double Latitude { get; set; } 7 | public double Longitude { get; set; } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Weather.Core/HandlerModel/RequestValidationResult.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.Core.HandlerModel 2 | { 3 | public class RequestValidationResult 4 | { 5 | public required bool IsValid { get; init; } 6 | 7 | public string[] ErrorMessages { get; init; } = []; 8 | 9 | public override string ToString() 10 | => string.Join(",", ErrorMessages); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Weather.API/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "Weatherbit": { 10 | "BaseUrl": "https://weatherbit-v1-mashape.p.rapidapi.com", 11 | "XRapidAPIKey": "", 12 | "XRapidAPIHost": "weatherbit-v1-mashape.p.rapidapi.com" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Wheaterbit.Client/Options/WeatherbitOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Wheaterbit.Client.Options 2 | { 3 | public class WeatherbitOptions 4 | { 5 | public const string Weatherbit = "Weatherbit"; 6 | 7 | public string BaseUrl { get; set; } = string.Empty; 8 | public string XRapidAPIKey { get; set; } = string.Empty; 9 | public string XRapidAPIHost { get; set; } = string.Empty; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md -------------------------------------------------------------------------------- /src/Weather.API/Configuration/ContainerConfigurationExtension.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.API.Configuration 2 | { 3 | public static class ContainerConfigurationExtension 4 | { 5 | public static WebApplicationBuilder AddLogging(this WebApplicationBuilder builder) 6 | { 7 | builder.Logging.ClearProviders(); 8 | builder.Logging.AddConsole(); 9 | return builder; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.Domain.UnitTests/Extensions/TestError.cs: -------------------------------------------------------------------------------- 1 | using FluentResults; 2 | 3 | namespace Weather.Domain.UnitTests.Extensions 4 | { 5 | internal class TestError : IError 6 | { 7 | public List Reasons => throw new NotImplementedException(); 8 | 9 | public string Message {get;set;} 10 | 11 | public Dictionary Metadata => throw new NotImplementedException(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Weather.Domain/Dtos/CurrentWeatherDto.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.Domain.Dtos 2 | { 3 | public class CurrentWeatherDto 4 | { 5 | public double Temperature { get; init; } 6 | 7 | public string CityName { get; init; } = string.Empty; 8 | 9 | public DateTime DateTime { get; init; } 10 | 11 | public string Sunset { get; init; } = string.Empty; 12 | 13 | public string Sunrise { get; init; } = string.Empty; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Weather.Core/Abstractions/IWeatherService.cs: -------------------------------------------------------------------------------- 1 | using FluentResults; 2 | using Weather.Domain.Dtos; 3 | 4 | namespace Weather.Core.Abstractions 5 | { 6 | public interface IWeatherService 7 | { 8 | Task> GetCurrentWeather(LocationDto locationDto, CancellationToken cancellationToken); 9 | 10 | Task> GetForecastWeather(LocationDto locationDto, CancellationToken cancellationToken); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Wheaterbit.Client/Dtos/CurrentWeatherDto.cs: -------------------------------------------------------------------------------- 1 | namespace Wheaterbit.Client.Dtos 2 | { 3 | public sealed class CurrentWeatherDto 4 | { 5 | public double temp { get; init; } 6 | 7 | public string city_name { get; init; } = string.Empty; 8 | 9 | public DateTime ob_time { get; init; } 10 | 11 | public string sunset { get; init; } = string.Empty; 12 | 13 | public string sunrise { get; init; } = string.Empty; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.UnitTests.Common/Weather.UnitTests.Common.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/Weather.Core/Abstractions/IWeatherCommandsRepository.cs: -------------------------------------------------------------------------------- 1 | using FluentResults; 2 | using Weather.Domain.Commands; 3 | 4 | namespace Weather.Core.Abstractions 5 | { 6 | public interface IWeatherCommandsRepository 7 | { 8 | Task> AddFavoriteLocation(AddFavoriteCommand addFavoriteCommand, CancellationToken cancellationToken); 9 | Task DeleteFavoriteLocationSafeAsync(DeleteFavoriteCommand command, CancellationToken cancellationToken); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | 2 | name: Codacy Coverage 3 | 4 | on: ["push"] 5 | 6 | jobs: 7 | codacy-coverage-reporter: 8 | runs-on: ubuntu-latest 9 | name: codacy-coverage-reporter 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Run codacy-coverage-reporter 13 | uses: codacy/codacy-coverage-reporter-action@v1 14 | with: 15 | project-token: ${{ secrets.CODACY_API_TOKEN }} 16 | coverage-reports: ./src/dotCover.Output.xml 17 | -------------------------------------------------------------------------------- /src/Wheaterbit.Client/Abstractions/IWeatherbitHttpClient.cs: -------------------------------------------------------------------------------- 1 | using FluentResults; 2 | using Wheaterbit.Client.Dtos; 3 | 4 | namespace Wheaterbit.Client.Abstractions 5 | { 6 | public interface IWeatherbitHttpClient 7 | { 8 | Task> GetSixteenDayForecast(double latitude, double longitude, CancellationToken cancellationToken); 9 | Task> GetCurrentWeather(double latitude, double longitude, CancellationToken cancellationToken); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Weather.API/Extensions/RouteHandlerBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using Weather.Core.HandlerModel; 2 | 3 | namespace Weather.API.Extensions 4 | { 5 | public static class RouteHandlerBuilderExtensions 6 | { 7 | public static RouteHandlerBuilder ProducesDataResponse( 8 | this RouteHandlerBuilder builder, 9 | params string[] additionalContentTypes) 10 | => builder.Produces>(StatusCodes.Status200OK, null, additionalContentTypes); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Weather.Infrastructure/Database/EFContext/WeatherContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Weather.Infrastructure.Database.EFContext.Entities; 3 | 4 | namespace Weather.Infrastructure.Database.EFContext 5 | { 6 | public class WeatherContext : DbContext 7 | { 8 | public WeatherContext(DbContextOptions options) 9 | : base(options) { } 10 | 11 | public virtual DbSet FavoriteLocations => Set(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Wheaterbit.Client/Factories/JsonSerializerSettingsFactory.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using Wheaterbit.Client.Abstractions; 3 | 4 | namespace Wheaterbit.Client.Factories 5 | { 6 | internal sealed class JsonSerializerSettingsFactory : IJsonSerializerSettingsFactory 7 | { 8 | public JsonSerializerSettings Create() 9 | { 10 | return new JsonSerializerSettings 11 | { 12 | DateFormatString = "yyyy-MM-dd hh:mm" 13 | }; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Weather.Domain/Queries/GetCurrentWeatherQuery.cs: -------------------------------------------------------------------------------- 1 | using Weather.Domain.Dtos; 2 | 3 | namespace Weather.Domain.Queries 4 | { 5 | public sealed class GetCurrentWeatherQuery 6 | { 7 | public LocationDto Location { get; init; } 8 | public GetCurrentWeatherQuery(double latitude, double longtitude) 9 | { 10 | Location = new LocationDto 11 | { 12 | Latitude = latitude, 13 | Longitude = longtitude 14 | }; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Weather.Domain/Queries/GetForecastWeatherQuery.cs: -------------------------------------------------------------------------------- 1 | using Weather.Domain.Dtos; 2 | 3 | namespace Weather.Domain.Queries 4 | { 5 | public sealed class GetForecastWeatherQuery 6 | { 7 | public LocationDto Location { get; init; } 8 | public GetForecastWeatherQuery(double latitude, double longtitude) 9 | { 10 | Location = new LocationDto 11 | { 12 | Latitude = latitude, 13 | Longitude = longtitude 14 | }; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Weather.Infrastructure/Mapping/Profiles/WeatherEntitiesProfile.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using Weather.Domain.BusinessEntities; 3 | using Weather.Domain.Dtos; 4 | using Weather.Infrastructure.Database.EFContext.Entities; 5 | 6 | namespace Weather.Infrastructure.Mapping.Profiles 7 | { 8 | internal sealed class WeatherEntitiesProfile : Profile 9 | { 10 | public WeatherEntitiesProfile() 11 | { 12 | CreateMap(); 13 | CreateMap(); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.Infrastructure.UnitTests/Database/TestWeatherContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Weather.Infrastructure.Database.EFContext; 3 | using Weather.Infrastructure.Database.EFContext.Entities; 4 | 5 | namespace Weather.Infrastructure.UnitTests.Database 6 | { 7 | public class TestWeatherContext : WeatherContext 8 | { 9 | public TestWeatherContext() 10 | : base(new DbContextOptions()) 11 | { 12 | } 13 | 14 | public override DbSet? FavoriteLocations { get; } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Weather.Infrastructure/Database/RepositoryBase.cs: -------------------------------------------------------------------------------- 1 | using Ardalis.GuardClauses; 2 | using AutoMapper; 3 | using Weather.Infrastructure.Database.EFContext; 4 | 5 | namespace Weather.Infrastructure.Database 6 | { 7 | internal abstract class RepositoryBase 8 | { 9 | protected readonly IMapper _mapper; 10 | protected readonly WeatherContext _weatherContext; 11 | protected RepositoryBase(WeatherContext weatherContext, IMapper mapper) 12 | { 13 | _weatherContext = Guard.Against.Null(weatherContext); 14 | _mapper = Guard.Against.Null(mapper); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Wheaterbit.Client.UnitTests/Extensions/Http/HttpClientFactoryMockExtensions.cs: -------------------------------------------------------------------------------- 1 | using Moq; 2 | 3 | namespace Wheaterbit.Client.UnitTests.Extensions.Http 4 | { 5 | internal static class HttpClientFactoryMockExtensions 6 | { 7 | internal static Mock Setup(this Mock httpClientFactoryMock, Mock customHttpMessageHandlerMock) 8 | { 9 | httpClientFactoryMock.Setup(x => x.CreateClient(It.IsAny())).Returns(new CustomHttpClientProxy(new CustomHttpMessageHandler(customHttpMessageHandlerMock.Object))); 10 | return httpClientFactoryMock; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Weather.Core/Validation/LocationDtoValidator.cs: -------------------------------------------------------------------------------- 1 | using Validot; 2 | using Weather.Core.Abstractions; 3 | using Weather.Core.HandlerModel; 4 | using Weather.Domain.Dtos; 5 | 6 | namespace Weather.Core.Validation 7 | { 8 | internal sealed class LocationDtoValidator : IRequestValidator 9 | { 10 | private readonly IValidator _validator; 11 | 12 | public LocationDtoValidator() 13 | { 14 | _validator = Validot.Validator.Factory.Create(GeneralPredicates.isValidLocation); 15 | } 16 | 17 | public RequestValidationResult Validate(LocationDto request) 18 | => new() { IsValid = _validator.IsValid(request) }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Weather.Core/Validation/GeneralPredicates.cs: -------------------------------------------------------------------------------- 1 | using Validot; 2 | using Weather.Domain.Dtos; 3 | 4 | namespace Weather.Core.Validation 5 | { 6 | internal static class GeneralPredicates 7 | { 8 | internal static readonly Predicate isValidTemperature = m => m < 60 && m > -90; 9 | internal static readonly Predicate isValidLatitude = m => m >= -90 && m <= 90; 10 | internal static readonly Predicate isValidLongitude = m => m >= -180 && m <= 180; 11 | internal static readonly Specification isValidLocation = s => s 12 | .Member(m => m.Latitude, m => m.Rule(isValidLatitude)) 13 | .Member(m => m.Longitude, m => m.Rule(isValidLongitude)); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Weather.API/Dockerfile: -------------------------------------------------------------------------------- 1 | #See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. 2 | 3 | FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base 4 | WORKDIR /app 5 | EXPOSE 80 6 | EXPOSE 443 7 | 8 | FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build 9 | WORKDIR /src 10 | COPY ["Weather.API/Weather.API.csproj", "Weather.API/"] 11 | RUN dotnet restore "Weather.API/Weather.API.csproj" 12 | COPY . . 13 | WORKDIR "/src/Weather.API" 14 | RUN dotnet build "Weather.API.csproj" -c Release -o /app/build 15 | 16 | FROM build AS publish 17 | RUN dotnet publish "Weather.API.csproj" -c Release -o /app/publish /p:UseAppHost=false 18 | 19 | FROM base AS final 20 | WORKDIR /app 21 | COPY --from=publish /app/publish . 22 | ENTRYPOINT ["dotnet", "Weather.API.dll"] -------------------------------------------------------------------------------- /src/Weather.Domain/Extensions/FluentResultExtensions.cs: -------------------------------------------------------------------------------- 1 | using FluentResults; 2 | 3 | namespace Weather.Domain.Extensions 4 | { 5 | public static class FluentResultExtensions 6 | { 7 | public static IEnumerable ToErrorMessages(this IList errors) 8 | { 9 | if(!errors.HasAny()) 10 | { 11 | return Array.Empty(); 12 | } 13 | 14 | return errors.Select(error => error.Message); 15 | } 16 | 17 | public static string JoinToMessage(this IList errors) 18 | { 19 | if (!errors.HasAny()) 20 | { 21 | return string.Empty; 22 | } 23 | 24 | return string.Join(',', errors.ToErrorMessages()); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Wheaterbit.Client.UnitTests/Extensions/Http/CustomHttpMessageHandler.cs: -------------------------------------------------------------------------------- 1 | using Ardalis.GuardClauses; 2 | 3 | namespace Wheaterbit.Client.UnitTests.Extensions.Http 4 | { 5 | internal class CustomHttpMessageHandler : HttpMessageHandler 6 | { 7 | private readonly ICusomHttpMessageHandler _cusomHttpMessageHandler; 8 | public CustomHttpMessageHandler(ICusomHttpMessageHandler cusomHttpMessageHandler) 9 | { 10 | _cusomHttpMessageHandler = Guard.Against.Null(cusomHttpMessageHandler); 11 | } 12 | protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) 13 | { 14 | return _cusomHttpMessageHandler.SendAsync(request, cancellationToken); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Tests/HttpDebug/debug-tests.http: -------------------------------------------------------------------------------- 1 | @hostname=localhost 2 | @port=7034 3 | @host={{hostname}}:{{port}} 4 | 5 | ### get current weather request 6 | GET https://{{host}}/weather/v1/current?latitude=38.5&longitude=-78.5 7 | Content-Type: application/json 8 | 9 | ### get forecast weather request 10 | GET https://{{host}}/weather/v1/forecast?latitude=38.5&longitude=-78.5 11 | Content-Type: application/json 12 | 13 | ### get favorites weather request 14 | GET https://{{host}}/weather/v1/favorites 15 | Content-Type: application/json 16 | 17 | ### add favorites weather request 18 | POST https://{{host}}/weather/v1/favorite 19 | Content-Type: application/json 20 | 21 | { 22 | "location": { 23 | "latitude": 38.5, 24 | "longitude": -78.5 25 | } 26 | } 27 | 28 | ### add favorites weather request 29 | DELETE https://{{host}}/weather/v1/favorite/1 30 | Content-Type: application/json -------------------------------------------------------------------------------- /src/Weather.Domain/Weather.Domain.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | True 16 | True 17 | ErrorLogMessages.resx 18 | 19 | 20 | 21 | 22 | 23 | PublicResXFileCodeGenerator 24 | ErrorLogMessages.Designer.cs 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/dotnet.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a .NET project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net 3 | 4 | name: .NET Build and Test 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | name: Build & Unit Test 17 | defaults: 18 | run: 19 | working-directory: src 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Setup .NET 24 | uses: actions/setup-dotnet@v3 25 | with: 26 | dotnet-version: 10.0.x 27 | - name: Restore dependencies 28 | run: dotnet restore 29 | - name: Build 30 | run: dotnet build --no-restore 31 | - name: Test 32 | run: dotnet test --filter FullyQualifiedName!~Weather.API.SystemTests --no-build --verbosity normal 33 | -------------------------------------------------------------------------------- /src/Weather.Core/Validation/DeleteFavoriteCommandValidator.cs: -------------------------------------------------------------------------------- 1 | using Validot; 2 | using Weather.Core.Abstractions; 3 | using Weather.Core.HandlerModel; 4 | using Weather.Domain.Commands; 5 | 6 | namespace Weather.Core.Validation 7 | { 8 | internal sealed class DeleteFavoriteCommandValidator : IRequestValidator 9 | { 10 | private readonly IValidator _validator; 11 | 12 | public DeleteFavoriteCommandValidator() 13 | { 14 | Specification deleteFavoriteCommandSpecification = s => s 15 | .Member(m => m.Id, r => r.NonNegative()); 16 | 17 | _validator = Validot.Validator.Factory.Create(deleteFavoriteCommandSpecification); 18 | } 19 | 20 | public RequestValidationResult Validate(DeleteFavoriteCommand request) 21 | => new() { IsValid = _validator.IsValid(request) }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Weather.Core/Validation/AddFavoriteCommandValidator.cs: -------------------------------------------------------------------------------- 1 | using Validot; 2 | using Weather.Core.Abstractions; 3 | using Weather.Core.HandlerModel; 4 | using Weather.Domain.Commands; 5 | 6 | namespace Weather.Core.Validation 7 | { 8 | internal sealed class AddFavoriteCommandValidator : IRequestValidator 9 | { 10 | private readonly IValidator _validator; 11 | 12 | public AddFavoriteCommandValidator() 13 | { 14 | Specification addFavoriteCommandSpecification = s => s 15 | .Member(m => m.Location, GeneralPredicates.isValidLocation); 16 | 17 | _validator = Validot.Validator.Factory.Create(addFavoriteCommandSpecification); 18 | } 19 | 20 | public RequestValidationResult Validate(AddFavoriteCommand request) 21 | => new() { IsValid = _validator.IsValid(request) }; 22 | 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Weather.Infrastructure/Database/Repositories/WeatherQueriesRepository.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using Microsoft.EntityFrameworkCore; 3 | using Weather.Core.Abstractions; 4 | using Weather.Domain.BusinessEntities; 5 | using Weather.Infrastructure.Database.EFContext; 6 | 7 | namespace Weather.Infrastructure.Database.Repositories 8 | { 9 | internal sealed class WeatherQueriesRepository : RepositoryBase, IWeatherQueriesRepository 10 | { 11 | public WeatherQueriesRepository(WeatherContext weatherContext, IMapper mapper) 12 | : base(weatherContext, mapper) { } 13 | 14 | public async Task> GetFavorites(CancellationToken cancellationToken) 15 | { 16 | var favoriteLocationEntities = await _weatherContext.FavoriteLocations.ToListAsync(cancellationToken); 17 | return _mapper.Map>(favoriteLocationEntities); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Weather.Core/Validation/GetForecastWeatherValidator.cs: -------------------------------------------------------------------------------- 1 | using Validot; 2 | using Weather.Core.Abstractions; 3 | using Weather.Core.HandlerModel; 4 | using Weather.Domain.Queries; 5 | 6 | namespace Weather.Core.Validation 7 | { 8 | internal sealed class GetForecastWeatherValidator : IRequestValidator 9 | { 10 | private readonly IValidator _validator; 11 | 12 | public GetForecastWeatherValidator() 13 | { 14 | Specification getForecastWeatherQuerySpecification = s => s 15 | .Member(m => m.Location, GeneralPredicates.isValidLocation); 16 | 17 | _validator = Validot.Validator.Factory.Create(getForecastWeatherQuerySpecification); 18 | } 19 | 20 | public RequestValidationResult Validate(GetForecastWeatherQuery request) 21 | => new() { IsValid = _validator.IsValid(request) }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Wheaterbit.Client.UnitTests/Extensions/OptionsValidationExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Options; 2 | using Moq; 3 | using System.Xml.Linq; 4 | using Validot; 5 | using Validot.Results; 6 | using Wheaterbit.Client.Options; 7 | 8 | namespace Wheaterbit.Client.UnitTests.Extensions 9 | { 10 | internal static class OptionsValidationExtensions 11 | { 12 | internal static Mock> Setup(this Mock> optionsValidatorMock, bool anyErrors) 13 | { 14 | var validationResultMock = new Mock(); 15 | validationResultMock.SetupGet(x => x.AnyErrors).Returns(anyErrors); 16 | 17 | optionsValidatorMock 18 | .Setup(x => x.Validate(It.IsAny(), It.IsAny())) 19 | .Returns(validationResultMock.Object); 20 | 21 | return optionsValidatorMock; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Weather.Domain/Logging/LogEvents.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.Domain.Logging 2 | { 3 | public static class LogEvents 4 | { 5 | public static readonly int GeneralError = 1000; 6 | 7 | //Favorites 8 | public static readonly int FavoriteWeathersGeneral = 2000; 9 | 10 | public static readonly int FavoriteWeathersStoreToDatabase = 2100; 11 | 12 | public static readonly int FavoriteWeathersGetFromDatabase = 2200; 13 | 14 | //Current 15 | public static readonly int CurrentWeathersGeneral = 3000; 16 | 17 | public static readonly int CurrentWeathersValidation = 3100; 18 | 19 | public static readonly int CurrentWeathersGet = 3200; 20 | 21 | //Forecast 22 | public static readonly int ForecastWeathersGeneral = 4000; 23 | 24 | public static readonly int ForecastWeathersValidation = 4100; 25 | 26 | public static readonly int ForecastWeathersGet = 4200; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.API.UnitTests/Weather.API.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | all 17 | 18 | 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | all 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/Wheaterbit.Client/Validation/WeatherbitOptionsSpecificationHolder.cs: -------------------------------------------------------------------------------- 1 | using Validot; 2 | using Wheaterbit.Client.Options; 3 | 4 | namespace Wheaterbit.Client.Validation 5 | { 6 | internal sealed class WeatherbitOptionsSpecificationHolder : ISpecificationHolder 7 | { 8 | public Specification Specification { get; } 9 | 10 | public WeatherbitOptionsSpecificationHolder() 11 | { 12 | Specification notEmptyStringSpecification = s => s 13 | .Rule(m => !string.IsNullOrWhiteSpace(m)); 14 | 15 | Specification weatherbitOptionsSpecification = s => s 16 | .Member(m => m.BaseUrl, notEmptyStringSpecification) 17 | .Member(m => m.XRapidAPIHost, notEmptyStringSpecification) 18 | .Member(m => m.XRapidAPIKey, notEmptyStringSpecification); 19 | 20 | Specification = weatherbitOptionsSpecification; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Weather.API/Program.cs: -------------------------------------------------------------------------------- 1 | using Scalar.AspNetCore; 2 | using Weather.API.Configuration; 3 | using Weather.API.EndpointBuilders; 4 | using Weather.API.Middlewares; 5 | using Weather.Core.Configuration; 6 | using Weather.Infrastructure.Configuration; 7 | 8 | var builder = WebApplication.CreateBuilder(args); 9 | 10 | builder.AddLogging(); 11 | builder.Services.AddInfrastructure(builder.Configuration); 12 | builder.Services.AddCore(); 13 | 14 | builder.Services.AddOpenApi(); 15 | 16 | var app = builder.Build(); 17 | 18 | if (app.Environment.IsDevelopment()) 19 | { 20 | app.MapOpenApi(); 21 | app.MapScalarApiReference(options => 22 | { 23 | options 24 | .WithTitle("Weather API") 25 | .WithTheme(ScalarTheme.Mars) 26 | .WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient); 27 | }); 28 | } 29 | 30 | app.UseHttpsRedirection(); 31 | 32 | app.UseMiddleware(); 33 | 34 | app.BuildWeatherEndpoints(); 35 | 36 | app.Run(); -------------------------------------------------------------------------------- /src/Tests/IntegrationTests/Weather.Infrastructure.IntegrationTests/Weather.Infrastructure.IntegrationTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | all 17 | 18 | 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | all 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/Weather.Domain/Extensions/EnumerableExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.Domain.Extensions 2 | { 3 | public static class EnumerableExtensions 4 | { 5 | public static void ForEach(this IEnumerable values, Action action) 6 | { 7 | foreach(var item in values) 8 | { 9 | action(item); 10 | } 11 | } 12 | 13 | public static async Task ForEachAsync(this IEnumerable values, Func action) 14 | { 15 | foreach (var item in values) 16 | { 17 | await action(item); 18 | } 19 | } 20 | 21 | public static bool HasAny(this IEnumerable values) 22 | => values?.Any() ?? false; 23 | 24 | public static string JoinToMessage(this IEnumerable values) 25 | => values.JoinToMessage(','); 26 | 27 | public static string JoinToMessage(this IEnumerable values, char separator) 28 | => string.Join(separator, values); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Tests/SystemTests/Weather.Infrastructure.SystemTests/Weather.Infrastructure.SystemTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | all 17 | 18 | 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | all 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Wheaterbit.Client.UnitTests/DataGenerator/WeatherbitOptionsGenerator.cs: -------------------------------------------------------------------------------- 1 | namespace Wheaterbit.Client.UnitTests.DataGenerator 2 | { 3 | internal static class WeatherbitOptionsGenerator 4 | { 5 | public static Microsoft.Extensions.Options.IOptions CreateInvalidOptions() 6 | { 7 | return Microsoft.Extensions.Options.Options.Create(new Options.WeatherbitOptions 8 | { 9 | BaseUrl = "baseUrl", 10 | XRapidAPIHost = "xRapidAPIHost", 11 | XRapidAPIKey = "xRapidAPIKey", 12 | }); 13 | } 14 | 15 | public static Microsoft.Extensions.Options.IOptions CreateValidOptions() 16 | { 17 | return Microsoft.Extensions.Options.Options.Create(new Options.WeatherbitOptions 18 | { 19 | BaseUrl = "https://baseUrl", 20 | XRapidAPIHost = "xRapidAPIHost", 21 | XRapidAPIKey = "xRapidAPIKey", 22 | }); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Wheaterbit.Client/Configuration/ContainerConfigurationExtension.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Validot; 4 | using Wheaterbit.Client.Abstractions; 5 | using Wheaterbit.Client.Factories; 6 | using Wheaterbit.Client.Options; 7 | using Wheaterbit.Client.Validation; 8 | 9 | namespace Wheaterbit.Client.Configuration 10 | { 11 | public static class ContainerConfigurationExtension 12 | { 13 | public static IServiceCollection AddWeatherbit(this IServiceCollection serviceCollection, IConfiguration configuration) 14 | { 15 | serviceCollection.Configure(configuration.GetSection(WeatherbitOptions.Weatherbit)); 16 | 17 | return serviceCollection.AddSingleton() 18 | .AddSingleton(typeof(IValidator), Validator.Factory.Create(new WeatherbitOptionsSpecificationHolder())) 19 | .AddSingleton(); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.Domain.UnitTests/Weather.Domain.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | all 17 | 18 | 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | all 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.Domain.UnitTests/Extensions/EnumerableExtensionsTests.cs: -------------------------------------------------------------------------------- 1 | using Weather.Domain.Extensions; 2 | 3 | namespace Weather.Domain.UnitTests.Extensions 4 | { 5 | public class EnumerableExtensionsTests 6 | { 7 | [Fact] 8 | public void HasAny_NullValues() 9 | { 10 | //Arrange 11 | var data = (List)null; 12 | //Act 13 | var result = data.HasAny(); 14 | //Assert 15 | Assert.False(result); 16 | } 17 | 18 | [Fact] 19 | public void HasAny_EmptyValues() 20 | { 21 | //Arrange 22 | var data = new List(); 23 | //Act 24 | var result = data.HasAny(); 25 | //Assert 26 | Assert.False(result); 27 | } 28 | 29 | [Fact] 30 | public void HasAny_Success() 31 | { 32 | //Arrange 33 | var data = new List { 1 }; 34 | //Act 35 | var result = data.HasAny(); 36 | //Assert 37 | Assert.True(result); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2022 Gramli 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/Tests/SystemTests/Weather.API.SystemTests/Weather.API.SystemTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | all 18 | 19 | 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | all 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/Weather.Core/Validation/GetCurrentWeatherQueryValidator.cs: -------------------------------------------------------------------------------- 1 | using Validot; 2 | using Weather.Core.Abstractions; 3 | using Weather.Core.HandlerModel; 4 | using Weather.Domain.Queries; 5 | 6 | namespace Weather.Core.Validation 7 | { 8 | internal sealed class GetCurrentWeatherQueryValidator : IRequestValidator 9 | { 10 | private readonly IValidator _validator; 11 | 12 | public GetCurrentWeatherQueryValidator() 13 | { 14 | Specification getCurrentWeatherQuerySpecification = s => s 15 | .Member(m => m.Location, GeneralPredicates.isValidLocation); 16 | 17 | _validator = Validot.Validator.Factory.Create(getCurrentWeatherQuerySpecification); 18 | } 19 | 20 | public RequestValidationResult Validate(GetCurrentWeatherQuery request) 21 | { 22 | var result = _validator.Validate(request); 23 | return new RequestValidationResult 24 | { 25 | IsValid = !result.AnyErrors, 26 | ErrorMessages = [.. result.Codes] 27 | }; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Wheaterbit.Client/Wheaterbit.Client.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | <_Parameter1>Wheaterbit.Client.UnitTests 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Weather.API/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Weather.API": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "launchUrl": "scalar", 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Development" 9 | }, 10 | "dotnetRunMessages": true, 11 | "applicationUrl": "https://localhost:7034;http://localhost:5037" 12 | }, 13 | "IIS Express": { 14 | "commandName": "IISExpress", 15 | "launchBrowser": true, 16 | "launchUrl": "scalar", 17 | "environmentVariables": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | } 20 | }, 21 | "Docker": { 22 | "commandName": "Docker", 23 | "launchBrowser": true, 24 | "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/scalar", 25 | "publishAllPorts": true, 26 | "useSSL": true 27 | } 28 | }, 29 | "$schema": "https://json.schemastore.org/launchsettings.json", 30 | "iisSettings": { 31 | "windowsAuthentication": false, 32 | "anonymousAuthentication": true, 33 | "iisExpress": { 34 | "applicationUrl": "http://localhost:56776", 35 | "sslPort": 44376 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.Core.UnitTests/Weather.Core.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | all 18 | 19 | 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | all 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/Weather.API/Weather.API.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 9d585f18-79f7-4f60-bf2b-ee9120eac9f9 8 | Linux 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | <_Parameter1>Weather.API.SystemTests 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Wheaterbit.Client.UnitTests/Wheaterbit.Client.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | runtime; build; native; contentfiles; analyzers; buildtransitive 19 | all 20 | 21 | 22 | runtime; build; native; contentfiles; analyzers; buildtransitive 23 | all 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Weather.Core/Commands/DeleteFavoriteHandler.cs: -------------------------------------------------------------------------------- 1 | using Ardalis.GuardClauses; 2 | using Weather.Core.Abstractions; 3 | using Weather.Core.HandlerModel; 4 | using Weather.Domain.Commands; 5 | 6 | namespace Weather.Core.Commands 7 | { 8 | internal sealed class DeleteFavoriteHandler : ValidationStatusRequestHandler 9 | { 10 | private readonly IWeatherCommandsRepository _weatherCommandsRepository; 11 | 12 | public DeleteFavoriteHandler( 13 | IWeatherCommandsRepository weatherCommandsRepository, 14 | IRequestValidator validator) 15 | : base(validator) 16 | { 17 | _weatherCommandsRepository = Guard.Against.Null(weatherCommandsRepository); 18 | } 19 | 20 | protected override async Task> HandleValidRequestAsync(DeleteFavoriteCommand request, CancellationToken cancellationToken) 21 | { 22 | var addResult = await _weatherCommandsRepository.DeleteFavoriteLocationSafeAsync(request, cancellationToken); 23 | if (addResult.IsFailed) 24 | { 25 | return HandlerResponses.AsInternalError("Location was not deleted from database."); 26 | } 27 | 28 | return HandlerResponses.AsSuccess(true); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Weather.API/Extensions/HandlerExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using Weather.Core.Abstractions; 3 | using Weather.Core.HandlerModel; 4 | 5 | namespace Weather.API.Extensions 6 | { 7 | public static class HandlerExtensions 8 | { 9 | /// 10 | /// Executes a request handler and maps the response to an appropriate HTTP result. 11 | /// 12 | public static async Task SendAsync(this IStatusRequestHandler requestHandler, TRequest request, CancellationToken cancellationToken) 13 | { 14 | var response = await requestHandler.HandleAsync(request, cancellationToken); 15 | 16 | return response.StatusCode switch 17 | { 18 | HandlerStatusCode.SuccessWithEmptyResult => Results.NoContent(), 19 | HandlerStatusCode.Success => Results.Json(response, statusCode: (int)HttpStatusCode.OK), 20 | HandlerStatusCode.ValidationError => Results.Json(response, statusCode: (int)HttpStatusCode.BadRequest), 21 | HandlerStatusCode.InternalError => Results.Json(response, statusCode: (int)HttpStatusCode.InternalServerError), 22 | _ => throw new InvalidOperationException($"Unknown HandlerStatusCode: {response.StatusCode}"), 23 | }; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.UnitTests.Common/Extensions/MoqLoggerExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using Moq; 3 | 4 | namespace Weather.UnitTests.Common.Extensions 5 | { 6 | public static class MoqLoggerExtensions 7 | { 8 | public static void VerifyLog(this Mock> loggerMock, LogLevel logLevel, EventId eventId, string message, Times times) 9 | { 10 | loggerMock.Verify( 11 | x => x.Log( 12 | It.Is(y=>y.Equals(logLevel)), 13 | It.Is(y=>y.Equals(eventId)), 14 | It.Is((o, _) => string.Equals(message, o.ToString(), StringComparison.InvariantCultureIgnoreCase)), 15 | It.IsAny(), 16 | It.IsAny>()), 17 | times); 18 | } 19 | 20 | public static void VerifyLog(this Mock> loggerMock, LogLevel logLevel, EventId eventId, Times times) 21 | { 22 | loggerMock.Verify( 23 | x => x.Log( 24 | It.Is(y => y.Equals(logLevel)), 25 | It.Is(y => y.Equals(eventId)), 26 | It.IsAny(), 27 | It.IsAny(), 28 | It.IsAny>()), 29 | times); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Weather.Infrastructure/Mapping/Profiles/WeatherbitClientProfile.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | 3 | namespace Weather.Infrastructure.Mapping.Profiles 4 | { 5 | internal sealed class WeatherbitClientProfile : Profile 6 | { 7 | public WeatherbitClientProfile() 8 | { 9 | CreateMap() 10 | .ForMember(dest=>dest.Temperature, opt => opt.MapFrom(src => src.temp)) 11 | .ForMember(dest => dest.DateTime, opt => opt.MapFrom(src => src.ob_time)) 12 | .ForMember(dest => dest.CityName, opt => opt.MapFrom(src => src.city_name)) 13 | .ForMember(dest => dest.Sunrise, opt => opt.MapFrom(src => src.sunrise)) 14 | .ForMember(dest => dest.Sunset, opt => opt.MapFrom(src => src.sunset)); 15 | 16 | CreateMap() 17 | .ForMember(dest => dest.Temperature, opt => opt.MapFrom(src => src.temp)) 18 | .ForMember(dest => dest.DateTime, opt => opt.MapFrom(src => src.datetime)); 19 | 20 | CreateMap() 21 | .ForMember(dest => dest.ForecastTemperatures, opt => opt.MapFrom(src => src.Data)) 22 | .ForMember(dest => dest.CityName, opt => opt.MapFrom(src => src.city_name)); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.Infrastructure.UnitTests/Weather.Infrastructure.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | all 19 | 20 | 21 | runtime; build; native; contentfiles; analyzers; buildtransitive 22 | all 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/Weather.Core/Validation/ForecastWeatherDtoValidator.cs: -------------------------------------------------------------------------------- 1 | using Validot; 2 | using Weather.Core.Abstractions; 3 | using Weather.Core.HandlerModel; 4 | using Weather.Domain.Dtos; 5 | 6 | namespace Weather.Core.Validation 7 | { 8 | internal sealed class ForecastWeatherDtoValidator : IRequestValidator 9 | { 10 | private readonly IValidator _validator; 11 | public ForecastWeatherDtoValidator() 12 | { 13 | Specification tempSpecification = s => s 14 | .Rule(GeneralPredicates.isValidTemperature); 15 | 16 | Specification dateTimeSpecification = s => s 17 | .Rule(s=> s > DateTime.Now.AddDays(-1)); 18 | 19 | Specification forecastTemperatureSpecification = s => s 20 | .Member(m=>m.Temperature, tempSpecification) 21 | .Member(m => m.DateTime, dateTimeSpecification); 22 | 23 | Specification forecastSpecification = s => s 24 | .Member(m => m.ForecastTemperatures, m => m.AsCollection(forecastTemperatureSpecification)) 25 | .Member(m => m.CityName, m=>m.NotEmpty().NotWhiteSpace()); 26 | 27 | _validator = Validot.Validator.Factory.Create(forecastSpecification); 28 | } 29 | 30 | public RequestValidationResult Validate(ForecastWeatherDto request) 31 | => new() { IsValid = _validator.IsValid(request) }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Weather.Core/Abstractions/ValidationStatusRequestHandler.cs: -------------------------------------------------------------------------------- 1 | using Ardalis.GuardClauses; 2 | using Weather.Core.HandlerModel; 3 | 4 | namespace Weather.Core.Abstractions 5 | { 6 | public abstract class ValidationStatusRequestHandler : IStatusRequestHandler 7 | { 8 | protected virtual string BadRequestMessage { get { return "Invalid request"; } } 9 | 10 | protected readonly IRequestValidator _validator; 11 | 12 | protected ValidationStatusRequestHandler(IRequestValidator validator) 13 | { 14 | _validator = Guard.Against.Null(validator); 15 | } 16 | public async Task> HandleAsync(TRequest request, CancellationToken cancellationToken) 17 | { 18 | 19 | var validationResult = _validator.Validate(request); 20 | if (!validationResult.IsValid) 21 | { 22 | return CreateInvalidResponse(request, validationResult); 23 | } 24 | 25 | return await HandleValidRequestAsync(request, cancellationToken); 26 | } 27 | 28 | protected abstract Task> HandleValidRequestAsync(TRequest request, CancellationToken cancellationToken); 29 | 30 | protected virtual HandlerResponse CreateInvalidResponse(TRequest request, RequestValidationResult validationResult) 31 | => HandlerResponses.AsValidationError(BadRequestMessage); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Weather.Core/Weather.Core.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | True 23 | True 24 | ErrorMessages.resx 25 | 26 | 27 | 28 | 29 | 30 | ResXFileCodeGenerator 31 | ErrorMessages.Designer.cs 32 | 33 | 34 | 35 | 36 | 37 | <_Parameter1>Weather.Core.UnitTests 38 | 39 | 40 | 41 | 42 | 43 | <_Parameter1>DynamicProxyGenAssembly2 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/Weather.Core/Validation/CurrentWeatherDtoValidator.cs: -------------------------------------------------------------------------------- 1 | using Validot; 2 | using Weather.Core.Abstractions; 3 | using Weather.Core.HandlerModel; 4 | using Weather.Domain.Dtos; 5 | 6 | namespace Weather.Core.Validation 7 | { 8 | internal sealed class CurrentWeatherDtoValidator : IRequestValidator 9 | { 10 | private readonly IValidator _validator; 11 | 12 | public CurrentWeatherDtoValidator() 13 | { 14 | Specification timeStringSpecification = s => s 15 | .NotEmpty() 16 | .And() 17 | .Rule(m => DateTime.TryParse(m, out var _)); 18 | 19 | Specification tempSpecification = s => s 20 | .Rule(GeneralPredicates.isValidTemperature); 21 | 22 | Specification currentWeatherDtoSpecification = s => s 23 | .Member(m => m.Sunrise, timeStringSpecification) 24 | .Member(m => m.Sunset, timeStringSpecification) 25 | .Member(m => m.Temperature, tempSpecification) 26 | .Member(m => m.CityName, m => m.NotEmpty().NotWhiteSpace()); 27 | 28 | _validator = Validot.Validator.Factory.Create(currentWeatherDtoSpecification); 29 | } 30 | 31 | public RequestValidationResult Validate(CurrentWeatherDto request) 32 | { 33 | var result = _validator.Validate(request); 34 | return new RequestValidationResult 35 | { 36 | IsValid = !result.AnyErrors, 37 | ErrorMessages = [.. result.Codes] 38 | }; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Weather.Infrastructure/Weather.Infrastructure.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net10.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | True 27 | True 28 | ErrorMessages.resx 29 | 30 | 31 | 32 | 33 | 34 | ResXFileCodeGenerator 35 | ErrorMessages.Designer.cs 36 | 37 | 38 | 39 | 40 | 41 | <_Parameter1>Weather.Infrastructure.UnitTests 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/Weather.Core/Commands/AddFavoriteHandler.cs: -------------------------------------------------------------------------------- 1 | using Ardalis.GuardClauses; 2 | using Microsoft.Extensions.Logging; 3 | using Weather.Core.Abstractions; 4 | using Weather.Core.HandlerModel; 5 | using Weather.Core.Resources; 6 | using Weather.Domain.Commands; 7 | using Weather.Domain.Extensions; 8 | using Weather.Domain.Logging; 9 | 10 | namespace Weather.Core.Commands 11 | { 12 | internal sealed class AddFavoriteHandler : ValidationStatusRequestHandler 13 | { 14 | private readonly ILogger _logger; 15 | private readonly IWeatherCommandsRepository _weatherCommandsRepository; 16 | public AddFavoriteHandler( 17 | IWeatherCommandsRepository weatherCommandsRepository, 18 | IRequestValidator addFavoriteCommandValidator, 19 | ILogger logger) 20 | :base(addFavoriteCommandValidator) 21 | { 22 | _weatherCommandsRepository = Guard.Against.Null(weatherCommandsRepository); 23 | _logger = Guard.Against.Null(logger); 24 | } 25 | 26 | protected override async Task> HandleValidRequestAsync(AddFavoriteCommand request, CancellationToken cancellationToken) 27 | { 28 | var addResult = await _weatherCommandsRepository.AddFavoriteLocation(request, cancellationToken); 29 | if(addResult.IsFailed) 30 | { 31 | _logger.LogError(LogEvents.FavoriteWeathersStoreToDatabase, addResult.Errors.JoinToMessage()); 32 | return HandlerResponses.AsInternalError(ErrorMessages.CantStoreLocation); 33 | } 34 | 35 | return HandlerResponses.AsSuccess(addResult.Value); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Directory.Packages.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Weather.Infrastructure/Configuration/ContainerConfigurationExtension.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.Configuration; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Weather.Core.Abstractions; 5 | using Weather.Infrastructure.Database.EFContext; 6 | using Weather.Infrastructure.Database.Repositories; 7 | using Weather.Infrastructure.Mapping.Profiles; 8 | using Weather.Infrastructure.Services; 9 | using Wheaterbit.Client.Configuration; 10 | 11 | namespace Weather.Infrastructure.Configuration 12 | { 13 | public static class ContainerConfigurationExtension 14 | { 15 | public static IServiceCollection AddInfrastructure(this IServiceCollection serviceCollection, IConfiguration configuration) 16 | => serviceCollection 17 | .AddMapping() 18 | .AddDatabase() 19 | .AddExternalHttpServices(configuration) 20 | .AddServices(); 21 | 22 | private static IServiceCollection AddServices(this IServiceCollection serviceCollection) 23 | => serviceCollection 24 | .AddScoped(); 25 | 26 | private static IServiceCollection AddMapping(this IServiceCollection serviceCollection) 27 | => serviceCollection 28 | .AddAutoMapper(typeof(WeatherEntitiesProfile)) 29 | .AddAutoMapper(typeof(WeatherbitClientProfile)); 30 | 31 | private static IServiceCollection AddDatabase(this IServiceCollection serviceCollection) 32 | => serviceCollection 33 | .AddDbContext(opt => opt.UseInMemoryDatabase("Weather")) 34 | .AddScoped() 35 | .AddScoped(); 36 | 37 | private static IServiceCollection AddExternalHttpServices(this IServiceCollection serviceCollection, IConfiguration configuration) 38 | => serviceCollection 39 | .AddHttpClient() 40 | .AddWeatherbit(configuration); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Weather.Core/Configuration/ContainerConfigurationExtension.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | using Weather.Core.Abstractions; 3 | using Weather.Core.Commands; 4 | using Weather.Core.HandlerModel; 5 | using Weather.Core.Queries; 6 | using Weather.Core.Validation; 7 | using Weather.Domain.Commands; 8 | using Weather.Domain.Dtos; 9 | using Weather.Domain.Queries; 10 | 11 | namespace Weather.Core.Configuration 12 | { 13 | public static class ContainerConfigurationExtension 14 | { 15 | public static IServiceCollection AddCore(this IServiceCollection serviceCollection) 16 | => serviceCollection 17 | .AddValidation() 18 | .AddHandlers(); 19 | 20 | private static IServiceCollection AddHandlers(this IServiceCollection serviceCollection) 21 | => serviceCollection 22 | .AddScoped, GetCurrentWeatherHandler>() 23 | .AddScoped, GetFavoritesHandler>() 24 | .AddScoped, GetForecastWeatherHandler>() 25 | .AddScoped, AddFavoriteHandler>() 26 | .AddScoped, DeleteFavoriteHandler>(); 27 | 28 | private static IServiceCollection AddValidation(this IServiceCollection serviceCollection) 29 | => serviceCollection 30 | .AddSingleton, CurrentWeatherDtoValidator>() 31 | .AddSingleton, ForecastWeatherDtoValidator>() 32 | .AddSingleton, LocationDtoValidator>() 33 | .AddSingleton, AddFavoriteCommandValidator>() 34 | .AddSingleton, GetCurrentWeatherQueryValidator>() 35 | .AddSingleton, GetForecastWeatherValidator>() 36 | .AddSingleton, DeleteFavoriteCommandValidator>(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.Domain.UnitTests/Extensions/FluentResultExtensionsTests.cs: -------------------------------------------------------------------------------- 1 | using FluentResults; 2 | using Weather.Domain.Extensions; 3 | 4 | namespace Weather.Domain.UnitTests.Extensions 5 | { 6 | public class FluentResultExtensionsTests 7 | { 8 | [Fact] 9 | public void ToErrorMessages_NullValues() 10 | { 11 | //Arrange 12 | var data = (IList)null; 13 | //Act 14 | var result = data.ToErrorMessages(); 15 | //Assert 16 | Assert.Empty(result); 17 | } 18 | 19 | [Fact] 20 | public void ToErrorMessages_EmptyValues() 21 | { 22 | //Arrange 23 | var data = new List(); 24 | //Act 25 | var result = data.ToErrorMessages(); 26 | //Assert 27 | Assert.Empty(result); 28 | } 29 | 30 | [Fact] 31 | public void ToErrorMessages_Success() 32 | { 33 | //Arrange 34 | var message = "message"; 35 | var data = new List { new TestError { Message = message } }; 36 | //Act 37 | var result = data.ToErrorMessages(); 38 | //Assert 39 | Assert.Single(result); 40 | Assert.Equal(message, result.Single()); 41 | } 42 | 43 | [Fact] 44 | public void JoinToMessage_NullValues() 45 | { 46 | //Arrange 47 | var data = (IList)null; 48 | //Act 49 | var result = data.JoinToMessage(); 50 | //Assert 51 | Assert.Empty(result); 52 | } 53 | 54 | [Fact] 55 | public void JoinToMessage_EmptyValues() 56 | { 57 | //Arrange 58 | var data = new List(); 59 | //Act 60 | var result = data.JoinToMessage(); 61 | //Assert 62 | Assert.Empty(result); 63 | } 64 | 65 | [Fact] 66 | public void JoinToMessage_Success() 67 | { 68 | //Arrange 69 | var message = "message"; 70 | var data = new List { new TestError { Message = message } }; 71 | //Act 72 | var result = data.JoinToMessage(); 73 | //Assert 74 | Assert.Equal(message, result); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Weather.API/Middlewares/ExceptionMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text.Json; 3 | using Weather.Core.HandlerModel; 4 | 5 | namespace Weather.API.Middlewares 6 | { 7 | public class ExceptionMiddleware(RequestDelegate next, ILogger logger) 8 | { 9 | private readonly RequestDelegate _next = next ?? throw new ArgumentNullException(nameof(next)); 10 | protected readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 11 | 12 | public async Task InvokeAsync(HttpContext context) 13 | { 14 | try 15 | { 16 | await _next(context); 17 | } 18 | catch (Exception generalEx) 19 | { 20 | _logger.LogError(generalEx, "Unexpected Error Occurred."); 21 | await WriteResponseAsync(generalEx, context); 22 | } 23 | } 24 | 25 | /// 26 | /// Write data to context response 27 | /// 28 | protected virtual async Task WriteResponseAsync(Exception generalEx, HttpContext context) 29 | { 30 | var (responseCode, responseMessage) = ExtractFromException(generalEx); 31 | context.Response.ContentType = "application/json"; 32 | context.Response.StatusCode = (int)responseCode; 33 | var jsonResult = CreateResponseJson(responseMessage); 34 | await context.Response.WriteAsync(jsonResult); 35 | } 36 | 37 | /// 38 | /// Create response object and serialize it to JSON 39 | /// 40 | protected virtual string CreateResponseJson(string errorMessage) 41 | { 42 | var response = new DataResponse 43 | { 44 | Errors = [errorMessage] 45 | }; 46 | return JsonSerializer.Serialize(response); 47 | } 48 | 49 | protected virtual (HttpStatusCode responseCode, string responseMessage) ExtractFromException(Exception generalEx) 50 | => generalEx switch 51 | { 52 | TaskCanceledException taskCanceledException => (HttpStatusCode.NoContent, taskCanceledException.Message), 53 | _ => (HttpStatusCode.InternalServerError, "Generic error occurred on server. Check logs for more info.") 54 | }; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Weather.Core/HandlerModel/HandlerResponses.cs: -------------------------------------------------------------------------------- 1 | namespace Weather.Core.HandlerModel 2 | { 3 | public static class HandlerResponses 4 | { 5 | public static HandlerResponse AsValidationError(IEnumerable errorMessages) 6 | { 7 | return AsResponse(HandlerStatusCode.ValidationError, errorMessages); 8 | } 9 | 10 | public static HandlerResponse AsValidationError(string errorMessages) 11 | { 12 | return AsResponse(HandlerStatusCode.ValidationError, errorMessages); 13 | } 14 | 15 | public static HandlerResponse AsInternalError(IEnumerable errorMessages) 16 | { 17 | return AsResponse(HandlerStatusCode.InternalError, errorMessages); 18 | } 19 | 20 | public static HandlerResponse AsInternalError(string errorMessages) 21 | { 22 | return AsResponse(HandlerStatusCode.InternalError, errorMessages); 23 | } 24 | 25 | public static HandlerResponse AsSuccessWithEmptyResult() 26 | => new() 27 | { 28 | StatusCode = HandlerStatusCode.SuccessWithEmptyResult, 29 | }; 30 | 31 | public static HandlerResponse AsSuccess(T data) 32 | => new() 33 | { 34 | Data = data, 35 | StatusCode = HandlerStatusCode.Success, 36 | }; 37 | 38 | public static HandlerResponse AsSuccess(T data, IEnumerable errorMessages) 39 | => new() 40 | { 41 | Data = data, 42 | StatusCode = HandlerStatusCode.Success, 43 | Errors = errorMessages 44 | }; 45 | 46 | private static HandlerResponse AsResponse(HandlerStatusCode handlerStatusCode, IEnumerable errorMessages) 47 | { 48 | return new HandlerResponse 49 | { 50 | StatusCode = handlerStatusCode, 51 | Errors = errorMessages 52 | }; 53 | } 54 | 55 | private static HandlerResponse AsResponse(HandlerStatusCode handlerStatusCode, string errorMessage) 56 | { 57 | return new HandlerResponse 58 | { 59 | StatusCode = handlerStatusCode, 60 | Errors = [errorMessage] 61 | }; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Weather.Infrastructure/Database/Repositories/WeatherCommandsRepository.cs: -------------------------------------------------------------------------------- 1 | using Ardalis.GuardClauses; 2 | using AutoMapper; 3 | using FluentResults; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.Extensions.Logging; 6 | using Weather.Core.Abstractions; 7 | using Weather.Domain.Commands; 8 | using Weather.Domain.Logging; 9 | using Weather.Infrastructure.Database.EFContext; 10 | using Weather.Infrastructure.Database.EFContext.Entities; 11 | 12 | namespace Weather.Infrastructure.Database.Repositories 13 | { 14 | internal sealed class WeatherCommandsRepository : RepositoryBase, IWeatherCommandsRepository 15 | { 16 | private readonly ILogger _logger; 17 | public WeatherCommandsRepository(WeatherContext weatherContext, IMapper mapper, ILogger logger) 18 | : base(weatherContext, mapper) 19 | { 20 | _logger = Guard.Against.Null(logger); 21 | } 22 | 23 | public async Task> AddFavoriteLocation(AddFavoriteCommand addFavoriteCommand, CancellationToken cancellationToken) 24 | { 25 | var locationEntity = _mapper.Map(addFavoriteCommand.Location); 26 | try 27 | { 28 | await _weatherContext.FavoriteLocations.AddAsync(locationEntity); 29 | await _weatherContext.SaveChangesAsync(cancellationToken); 30 | return Result.Ok(locationEntity.Id); 31 | } 32 | catch(DbUpdateException ex) 33 | { 34 | _logger.LogError(LogEvents.FavoriteWeathersStoreToDatabase, ex, "Can't add favorite locations into database."); 35 | return Result.Fail(ex.Message); 36 | } 37 | } 38 | 39 | public async Task DeleteFavoriteLocationSafeAsync(DeleteFavoriteCommand command, CancellationToken cancellationToken) 40 | { 41 | try 42 | { 43 | var location = await _weatherContext.FavoriteLocations.FindAsync(command.Id, cancellationToken); 44 | _weatherContext.FavoriteLocations.Remove(location!); 45 | await _weatherContext.SaveChangesAsync(cancellationToken); 46 | return Result.Ok(); 47 | } 48 | catch (DbUpdateException ex) 49 | { 50 | _logger.LogError(LogEvents.FavoriteWeathersStoreToDatabase, ex, "Can't delete location."); 51 | return Result.Fail(ex.Message); 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Weather.Core/Queries/GetForecastWeatherHandler.cs: -------------------------------------------------------------------------------- 1 | using Ardalis.GuardClauses; 2 | using Microsoft.Extensions.Logging; 3 | using Weather.Core.Abstractions; 4 | using Weather.Core.HandlerModel; 5 | using Weather.Core.Resources; 6 | using Weather.Domain.Dtos; 7 | using Weather.Domain.Extensions; 8 | using Weather.Domain.Logging; 9 | using Weather.Domain.Queries; 10 | using Weather.Domain.Resources; 11 | 12 | namespace Weather.Core.Queries 13 | { 14 | internal sealed class GetForecastWeatherHandler : ValidationStatusRequestHandler 15 | { 16 | private readonly IRequestValidator _forecastWeatherValidator; 17 | private readonly IWeatherService _weatherService; 18 | private readonly ILogger _logger; 19 | public GetForecastWeatherHandler( 20 | IRequestValidator getForecastWeatherQueryValidator, 21 | IWeatherService weatherService, 22 | IRequestValidator forecastWeatherValidator, 23 | ILogger logger) 24 | : base(getForecastWeatherQueryValidator) 25 | { 26 | _weatherService = Guard.Against.Null(weatherService); 27 | _forecastWeatherValidator = Guard.Against.Null(forecastWeatherValidator); 28 | _logger = Guard.Against.Null(logger); 29 | } 30 | protected override async Task> HandleValidRequestAsync(GetForecastWeatherQuery request, CancellationToken cancellationToken) 31 | { 32 | var forecastResult = await _weatherService.GetForecastWeather(request.Location, cancellationToken); 33 | 34 | if(forecastResult.IsFailed) 35 | { 36 | _logger.LogError(LogEvents.ForecastWeathersGet, forecastResult.Errors.JoinToMessage()); 37 | return HandlerResponses.AsInternalError(ErrorMessages.ExternalApiError); 38 | } 39 | 40 | var validationResult = _forecastWeatherValidator.Validate(forecastResult.Value); 41 | if (!validationResult.IsValid) 42 | { 43 | _logger.LogError(LogEvents.ForecastWeathersValidation, ErrorLogMessages.ValidationErrorLog, validationResult.ToString()); 44 | return HandlerResponses.AsInternalError(ErrorMessages.ExternalApiError); 45 | } 46 | 47 | return HandlerResponses.AsSuccess(forecastResult.Value); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Weather.Infrastructure/Services/WeatherService.cs: -------------------------------------------------------------------------------- 1 | using Ardalis.GuardClauses; 2 | using AutoMapper; 3 | using FluentResults; 4 | using Weather.Core.Abstractions; 5 | using Weather.Domain.Dtos; 6 | using Weather.Domain.Extensions; 7 | using Weather.Infrastructure.Resources; 8 | using Wheaterbit.Client.Abstractions; 9 | 10 | namespace Weather.Infrastructure.Services 11 | { 12 | internal sealed class WeatherService : IWeatherService 13 | { 14 | private readonly IWeatherbitHttpClient _weatherbitHttpClient; 15 | private readonly IMapper _mapper; 16 | 17 | public WeatherService(IWeatherbitHttpClient weatherbitHttpClient, IMapper mapper) 18 | { 19 | _weatherbitHttpClient = Guard.Against.Null(weatherbitHttpClient); 20 | _mapper = Guard.Against.Null(mapper); 21 | } 22 | 23 | public async Task> GetCurrentWeather(LocationDto locationDto, CancellationToken cancellationToken) 24 | { 25 | var currentWeatherResult = await _weatherbitHttpClient.GetCurrentWeather(locationDto.Latitude, locationDto.Longitude, cancellationToken); 26 | if(currentWeatherResult.IsFailed) 27 | { 28 | return Result.Fail(currentWeatherResult.Errors); 29 | } 30 | 31 | if(currentWeatherResult.Value is null || !currentWeatherResult.Value.Data.HasAny()) 32 | { 33 | return Result.Fail(ErrorMessages.ExternalClientGetDataFailed_EmptyOrNull); 34 | } 35 | 36 | if(currentWeatherResult.Value.Data.Count != 1) 37 | { 38 | return Result.Fail(string.Format(ErrorMessages.ExternalClientGetDataFailed_CorruptedData_InvalidCount, currentWeatherResult.Value.Data.Count)); 39 | } 40 | 41 | return _mapper.Map(currentWeatherResult.Value.Data.Single()); 42 | } 43 | 44 | public async Task> GetForecastWeather(LocationDto locationDto, CancellationToken cancellationToken) 45 | { 46 | var forecastWeatherResult = await _weatherbitHttpClient.GetSixteenDayForecast(locationDto.Latitude, locationDto.Longitude, cancellationToken); 47 | if (forecastWeatherResult.IsFailed) 48 | { 49 | return Result.Fail(forecastWeatherResult.Errors); 50 | } 51 | 52 | if (forecastWeatherResult.Value is null || !forecastWeatherResult.Value.Data.Any()) 53 | { 54 | return Result.Fail(ErrorMessages.ExternalClientGetDataFailed_EmptyOrNull); 55 | } 56 | 57 | return _mapper.Map(forecastWeatherResult.Value); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Weather.Core/Queries/GetCurrentWeatherHandler.cs: -------------------------------------------------------------------------------- 1 | using Ardalis.GuardClauses; 2 | using Microsoft.Extensions.Logging; 3 | using Weather.Core.Abstractions; 4 | using Weather.Core.HandlerModel; 5 | using Weather.Core.Resources; 6 | using Weather.Domain.Dtos; 7 | using Weather.Domain.Extensions; 8 | using Weather.Domain.Logging; 9 | using Weather.Domain.Queries; 10 | using Weather.Domain.Resources; 11 | 12 | namespace Weather.Core.Queries 13 | { 14 | internal sealed class GetCurrentWeatherHandler : ValidationStatusRequestHandler 15 | { 16 | private readonly IRequestValidator _currentWeatherValidator; 17 | private readonly IWeatherService _weatherService; 18 | private readonly ILogger _logger; 19 | public GetCurrentWeatherHandler(IRequestValidator getCurrentWeatherQueryValidator, 20 | IRequestValidator currentWeatherValidator, 21 | IWeatherService weatherService, 22 | ILogger logger) 23 | : base(getCurrentWeatherQueryValidator) 24 | { 25 | _weatherService = Guard.Against.Null(weatherService); 26 | _currentWeatherValidator = Guard.Against.Null(currentWeatherValidator); 27 | _logger = Guard.Against.Null(logger); 28 | } 29 | 30 | protected override HandlerResponse CreateInvalidResponse(GetCurrentWeatherQuery request, RequestValidationResult validationResult) 31 | { 32 | _logger.LogError(LogEvents.CurrentWeathersValidation, validationResult.ToString()); 33 | return HandlerResponses.AsValidationError(string.Format(ErrorMessages.RequestValidationError, request)); 34 | } 35 | 36 | protected override async Task> HandleValidRequestAsync(GetCurrentWeatherQuery request, CancellationToken cancellationToken) 37 | { 38 | var getCurrentWeatherResult = await _weatherService.GetCurrentWeather(request.Location, cancellationToken); 39 | if (getCurrentWeatherResult.IsFailed) 40 | { 41 | _logger.LogError(LogEvents.CurrentWeathersGet, getCurrentWeatherResult.Errors.JoinToMessage()); 42 | return HandlerResponses.AsInternalError(ErrorMessages.ExternalApiError); 43 | } 44 | 45 | var validationResult = _currentWeatherValidator.Validate(getCurrentWeatherResult.Value); 46 | if (!validationResult.IsValid) 47 | { 48 | _logger.LogError(LogEvents.CurrentWeathersValidation, ErrorLogMessages.ValidationErrorLog, validationResult.ToString()); 49 | return HandlerResponses.AsInternalError(ErrorMessages.ExternalApiError); 50 | } 51 | 52 | return HandlerResponses.AsSuccess(getCurrentWeatherResult.Value); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.Core.UnitTests/Commands/DeleteFavoriteHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Weather.Core.Abstractions; 2 | using Weather.Core.Commands; 3 | using Weather.Core.HandlerModel; 4 | using Weather.Domain.Commands; 5 | 6 | namespace Weather.Core.UnitTests.Commands 7 | { 8 | public class DeleteFavoriteHandlerTests 9 | { 10 | private readonly Mock _weatherCommandsRepositoryMock; 11 | private readonly Mock> _validatorMock; 12 | 13 | private readonly IStatusRequestHandler _uut; 14 | public DeleteFavoriteHandlerTests() 15 | { 16 | _weatherCommandsRepositoryMock = new(); 17 | _validatorMock = new(); 18 | 19 | _uut = new DeleteFavoriteHandler(_weatherCommandsRepositoryMock.Object, _validatorMock.Object); 20 | } 21 | 22 | [Fact] 23 | public async Task InvalidRequest() 24 | { 25 | //Arrange 26 | var deleteFavoriteCommand = new DeleteFavoriteCommand { Id = 5 }; 27 | 28 | _validatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = false}); 29 | 30 | //Act 31 | var result = await _uut.HandleAsync(deleteFavoriteCommand, CancellationToken.None); 32 | 33 | //Assert 34 | Assert.Equal(HandlerStatusCode.ValidationError, result.StatusCode); 35 | Assert.Single(result.Errors); 36 | _validatorMock.Verify(x => x.Validate(It.Is(y => y.Equals(deleteFavoriteCommand))), Times.Once); 37 | } 38 | 39 | [Fact] 40 | public async Task DeleteFavoriteLocationSafeAsync_Failed() 41 | { 42 | //Arrange 43 | var deleteFavoriteCommand = new DeleteFavoriteCommand { Id = 5 }; 44 | 45 | _validatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = true }); 46 | _weatherCommandsRepositoryMock.Setup(x => x.DeleteFavoriteLocationSafeAsync(deleteFavoriteCommand, CancellationToken.None)) 47 | .ReturnsAsync(Result.Fail(string.Empty)); 48 | 49 | //Act 50 | var result = await _uut.HandleAsync(deleteFavoriteCommand, CancellationToken.None); 51 | 52 | //Assert 53 | Assert.Equal(HandlerStatusCode.InternalError, result.StatusCode); 54 | Assert.Single(result.Errors); 55 | _validatorMock.Verify(x => x.Validate(deleteFavoriteCommand), Times.Once); 56 | _weatherCommandsRepositoryMock.Verify(x => x.DeleteFavoriteLocationSafeAsync(deleteFavoriteCommand, CancellationToken.None), Times.Once); 57 | } 58 | 59 | [Fact] 60 | public async Task DeleteFavoriteLocationSafeAsync_Success() 61 | { 62 | //Arrange 63 | var deleteFavoriteCommand = new DeleteFavoriteCommand { Id = 5 }; 64 | 65 | _validatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = true }); 66 | _weatherCommandsRepositoryMock.Setup(x => x.DeleteFavoriteLocationSafeAsync(deleteFavoriteCommand, CancellationToken.None)) 67 | .ReturnsAsync(Result.Ok()); 68 | 69 | //Act 70 | var result = await _uut.HandleAsync(deleteFavoriteCommand, CancellationToken.None); 71 | 72 | //Assert 73 | Assert.Equal(HandlerStatusCode.Success, result.StatusCode); 74 | Assert.Empty(result.Errors); 75 | _validatorMock.Verify(x => x.Validate(deleteFavoriteCommand), Times.Once); 76 | _weatherCommandsRepositoryMock.Verify(x => x.DeleteFavoriteLocationSafeAsync(deleteFavoriteCommand, CancellationToken.None), Times.Once); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Weather.API/EndpointBuilders/WeatherBuilder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Weather.API.Extensions; 3 | using Weather.Core.Abstractions; 4 | using Weather.Core.HandlerModel; 5 | using Weather.Domain.Commands; 6 | using Weather.Domain.Dtos; 7 | using Weather.Domain.Queries; 8 | 9 | namespace Weather.API.EndpointBuilders 10 | { 11 | public static class WeatherBuilder 12 | { 13 | public static IEndpointRouteBuilder BuildWeatherEndpoints(this IEndpointRouteBuilder endpointRouteBuilder) 14 | { 15 | 16 | endpointRouteBuilder 17 | .MapGroup("weather") 18 | .MapVersionGroup(1) 19 | .BuildActualWeatherEndpoints() 20 | .BuildForecastWeatherEndpoints() 21 | .BuildFavoriteWeatherEndpoints(); 22 | 23 | return endpointRouteBuilder; 24 | } 25 | 26 | private static IEndpointRouteBuilder BuildActualWeatherEndpoints(this IEndpointRouteBuilder endpointRouteBuilder) 27 | { 28 | endpointRouteBuilder.MapGet("/current", 29 | async (double latitude, double longitude, [FromServices] IStatusRequestHandler handler, CancellationToken cancellationToken) => 30 | await handler.SendAsync(new GetCurrentWeatherQuery(latitude, longitude), cancellationToken)) 31 | .ProducesDataResponse() 32 | .WithName("GetCurrentWeather") 33 | .WithTags("Getters"); 34 | return endpointRouteBuilder; 35 | } 36 | 37 | private static IEndpointRouteBuilder BuildForecastWeatherEndpoints(this IEndpointRouteBuilder endpointRouteBuilder) 38 | { 39 | endpointRouteBuilder.MapGet("/forecast", 40 | async (double latitude, double longitude, [FromServices] IStatusRequestHandler handler, CancellationToken cancellationToken) => 41 | await handler.SendAsync(new GetForecastWeatherQuery(latitude, longitude), cancellationToken)) 42 | .ProducesDataResponse() 43 | .WithName("GetForecastWeather") 44 | .WithTags("Getters"); 45 | 46 | return endpointRouteBuilder; 47 | } 48 | 49 | private static IEndpointRouteBuilder BuildFavoriteWeatherEndpoints(this IEndpointRouteBuilder endpointRouteBuilder) 50 | { 51 | endpointRouteBuilder.MapGet("/favorites", 52 | async ([FromServices] IStatusRequestHandler handler, CancellationToken cancellationToken) => 53 | await handler.SendAsync(new EmptyRequest(), cancellationToken)) 54 | .ProducesDataResponse() 55 | .WithName("GetFavorites") 56 | .WithTags("Getters"); 57 | 58 | endpointRouteBuilder.MapPost("/favorites", 59 | async ([FromBody] AddFavoriteCommand addFavoriteCommand, [FromServices] IStatusRequestHandler handler, CancellationToken cancellationToken) => 60 | await handler.SendAsync(addFavoriteCommand, cancellationToken)) 61 | .ProducesDataResponse() 62 | .WithName("AddFavorite") 63 | .WithTags("Setters"); 64 | 65 | endpointRouteBuilder.MapDelete("/favorites/{id}", 66 | async (int id, [FromServices] IStatusRequestHandler handler, CancellationToken cancellationToken) => 67 | await handler.SendAsync(new DeleteFavoriteCommand { Id = id }, cancellationToken)) 68 | .ProducesDataResponse() 69 | .WithName("DeleteFavorite") 70 | .WithTags("Delete"); 71 | 72 | return endpointRouteBuilder; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Weather.Domain/Resources/ErrorLogMessages.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.42000 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace Weather.Domain.Resources { 12 | using System; 13 | 14 | 15 | /// 16 | /// A strongly-typed resource class, for looking up localized strings, etc. 17 | /// 18 | // This class was auto-generated by the StronglyTypedResourceBuilder 19 | // class via a tool like ResGen or Visual Studio. 20 | // To add or remove a member, edit your .ResX file then rerun ResGen 21 | // with the /str option, or rebuild your VS project. 22 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] 23 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 24 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 25 | public class ErrorLogMessages { 26 | 27 | private static global::System.Resources.ResourceManager resourceMan; 28 | 29 | private static global::System.Globalization.CultureInfo resourceCulture; 30 | 31 | [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] 32 | internal ErrorLogMessages() { 33 | } 34 | 35 | /// 36 | /// Returns the cached ResourceManager instance used by this class. 37 | /// 38 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 39 | public static global::System.Resources.ResourceManager ResourceManager { 40 | get { 41 | if (object.ReferenceEquals(resourceMan, null)) { 42 | global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Weather.Domain.Resources.ErrorLogMessages", typeof(ErrorLogMessages).Assembly); 43 | resourceMan = temp; 44 | } 45 | return resourceMan; 46 | } 47 | } 48 | 49 | /// 50 | /// Overrides the current thread's CurrentUICulture property for all 51 | /// resource lookups using this strongly typed resource class. 52 | /// 53 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 54 | public static global::System.Globalization.CultureInfo Culture { 55 | get { 56 | return resourceCulture; 57 | } 58 | set { 59 | resourceCulture = value; 60 | } 61 | } 62 | 63 | /// 64 | /// Looks up a localized string similar to Invalid location {location}.. 65 | /// 66 | public static string InvalidLocation { 67 | get { 68 | return ResourceManager.GetString("InvalidLocation", resourceCulture); 69 | } 70 | } 71 | 72 | /// 73 | /// Looks up a localized string similar to Invalid weather object for location {location}.. 74 | /// 75 | public static string InvalidWeather { 76 | get { 77 | return ResourceManager.GetString("InvalidWeather", resourceCulture); 78 | } 79 | } 80 | 81 | /// 82 | /// Looks up a localized string similar to Validation error: {error}. 83 | /// 84 | public static string ValidationErrorLog { 85 | get { 86 | return ResourceManager.GetString("ValidationErrorLog", resourceCulture); 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Weather.Infrastructure/Resources/ErrorMessages.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.42000 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace Weather.Infrastructure.Resources { 12 | using System; 13 | 14 | 15 | /// 16 | /// A strongly-typed resource class, for looking up localized strings, etc. 17 | /// 18 | // This class was auto-generated by the StronglyTypedResourceBuilder 19 | // class via a tool like ResGen or Visual Studio. 20 | // To add or remove a member, edit your .ResX file then rerun ResGen 21 | // with the /str option, or rebuild your VS project. 22 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] 23 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 24 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 25 | internal class ErrorMessages { 26 | 27 | private static global::System.Resources.ResourceManager resourceMan; 28 | 29 | private static global::System.Globalization.CultureInfo resourceCulture; 30 | 31 | [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] 32 | internal ErrorMessages() { 33 | } 34 | 35 | /// 36 | /// Returns the cached ResourceManager instance used by this class. 37 | /// 38 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 39 | internal static global::System.Resources.ResourceManager ResourceManager { 40 | get { 41 | if (object.ReferenceEquals(resourceMan, null)) { 42 | global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Weather.Infrastructure.Resources.ErrorMessages", typeof(ErrorMessages).Assembly); 43 | resourceMan = temp; 44 | } 45 | return resourceMan; 46 | } 47 | } 48 | 49 | /// 50 | /// Overrides the current thread's CurrentUICulture property for all 51 | /// resource lookups using this strongly typed resource class. 52 | /// 53 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 54 | internal static global::System.Globalization.CultureInfo Culture { 55 | get { 56 | return resourceCulture; 57 | } 58 | set { 59 | resourceCulture = value; 60 | } 61 | } 62 | 63 | /// 64 | /// Looks up a localized string similar to Failed to retrieve data from database.. 65 | /// 66 | internal static string DatabaseGetFailed { 67 | get { 68 | return ResourceManager.GetString("DatabaseGetFailed", resourceCulture); 69 | } 70 | } 71 | 72 | /// 73 | /// Looks up a localized string similar to Can't retrieve data properly from external service. Not expected data. Invalid Count: {0}. 74 | /// 75 | internal static string ExternalClientGetDataFailed_CorruptedData_InvalidCount { 76 | get { 77 | return ResourceManager.GetString("ExternalClientGetDataFailed_CorruptedData_InvalidCount", resourceCulture); 78 | } 79 | } 80 | 81 | /// 82 | /// Looks up a localized string similar to Can't retrieve data properly from external service. Data are empty or null.. 83 | /// 84 | internal static string ExternalClientGetDataFailed_EmptyOrNull { 85 | get { 86 | return ResourceManager.GetString("ExternalClientGetDataFailed_EmptyOrNull", resourceCulture); 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Weather.Core/Resources/ErrorMessages.Designer.cs: -------------------------------------------------------------------------------- 1 | //------------------------------------------------------------------------------ 2 | // 3 | // This code was generated by a tool. 4 | // Runtime Version:4.0.30319.42000 5 | // 6 | // Changes to this file may cause incorrect behavior and will be lost if 7 | // the code is regenerated. 8 | // 9 | //------------------------------------------------------------------------------ 10 | 11 | namespace Weather.Core.Resources { 12 | using System; 13 | 14 | 15 | /// 16 | /// A strongly-typed resource class, for looking up localized strings, etc. 17 | /// 18 | // This class was auto-generated by the StronglyTypedResourceBuilder 19 | // class via a tool like ResGen or Visual Studio. 20 | // To add or remove a member, edit your .ResX file then rerun ResGen 21 | // with the /str option, or rebuild your VS project. 22 | [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] 23 | [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] 24 | [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] 25 | internal class ErrorMessages { 26 | 27 | private static global::System.Resources.ResourceManager resourceMan; 28 | 29 | private static global::System.Globalization.CultureInfo resourceCulture; 30 | 31 | [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] 32 | internal ErrorMessages() { 33 | } 34 | 35 | /// 36 | /// Returns the cached ResourceManager instance used by this class. 37 | /// 38 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 39 | internal static global::System.Resources.ResourceManager ResourceManager { 40 | get { 41 | if (object.ReferenceEquals(resourceMan, null)) { 42 | global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Weather.Core.Resources.ErrorMessages", typeof(ErrorMessages).Assembly); 43 | resourceMan = temp; 44 | } 45 | return resourceMan; 46 | } 47 | } 48 | 49 | /// 50 | /// Overrides the current thread's CurrentUICulture property for all 51 | /// resource lookups using this strongly typed resource class. 52 | /// 53 | [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] 54 | internal static global::System.Globalization.CultureInfo Culture { 55 | get { 56 | return resourceCulture; 57 | } 58 | set { 59 | resourceCulture = value; 60 | } 61 | } 62 | 63 | /// 64 | /// Looks up a localized string similar to Can't store location to database. . 65 | /// 66 | internal static string CantStoreLocation { 67 | get { 68 | return ResourceManager.GetString("CantStoreLocation", resourceCulture); 69 | } 70 | } 71 | 72 | /// 73 | /// Looks up a localized string similar to Problem with external API, can't retrive data properly.. 74 | /// 75 | internal static string ExternalApiError { 76 | get { 77 | return ResourceManager.GetString("ExternalApiError", resourceCulture); 78 | } 79 | } 80 | 81 | /// 82 | /// Looks up a localized string similar to Stored favorite location is invalid {0}.. 83 | /// 84 | internal static string InvalidStoredLocation { 85 | get { 86 | return ResourceManager.GetString("InvalidStoredLocation", resourceCulture); 87 | } 88 | } 89 | 90 | /// 91 | /// Looks up a localized string similar to Invalid request data: {0}.. 92 | /// 93 | internal static string RequestValidationError { 94 | get { 95 | return ResourceManager.GetString("RequestValidationError", resourceCulture); 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Wheaterbit.Client/WeatherbitHttpClient.cs: -------------------------------------------------------------------------------- 1 | using Ardalis.GuardClauses; 2 | using FluentResults; 3 | using Microsoft.Extensions.Options; 4 | using Newtonsoft.Json; 5 | using Validot; 6 | using Wheaterbit.Client.Abstractions; 7 | using Wheaterbit.Client.Dtos; 8 | using Wheaterbit.Client.Options; 9 | 10 | namespace Wheaterbit.Client 11 | { 12 | internal sealed class WeatherbitHttpClient : IWeatherbitHttpClient 13 | { 14 | private readonly HttpClient _httpClient; 15 | private readonly IOptions _options; 16 | private readonly IJsonSerializerSettingsFactory _jsonSerializerSettingsFactory; 17 | 18 | private const string XRapidAPIKeyHeader = "X-RapidAPI-Key"; 19 | private const string XRapidAPIHostHeader = "X-RapidAPI-Host"; 20 | public WeatherbitHttpClient(IOptions options, 21 | IHttpClientFactory httpClientFactory, 22 | IValidator optionsValidator, 23 | IJsonSerializerSettingsFactory jsonSerializerSettingsFactory) 24 | { 25 | Guard.Against.Null(httpClientFactory); 26 | _httpClient = httpClientFactory.CreateClient(); 27 | _options = Guard.Against.Null(options); 28 | _jsonSerializerSettingsFactory = Guard.Against.Null(jsonSerializerSettingsFactory); 29 | 30 | ValidateOptions(optionsValidator, options); 31 | } 32 | 33 | private static void ValidateOptions(IValidator optionsValidator, IOptions options) 34 | { 35 | var validationResult = optionsValidator.Validate(options.Value); 36 | 37 | if(validationResult.AnyErrors) 38 | { 39 | throw new ArgumentException($"Invalid {nameof(WeatherbitOptions)}: {validationResult}"); 40 | } 41 | } 42 | 43 | public async Task> GetSixteenDayForecast(double latitude, double longitude, CancellationToken cancellationToken) 44 | { 45 | var request = new HttpRequestMessage 46 | { 47 | Method = HttpMethod.Get, 48 | RequestUri = new Uri($"{_options.Value.BaseUrl}/forecast/daily?lon={longitude}&lat={latitude}"), 49 | Headers = 50 | { 51 | { XRapidAPIHostHeader, _options.Value.XRapidAPIHost }, 52 | { XRapidAPIKeyHeader, _options.Value.XRapidAPIKey }, 53 | } 54 | }; 55 | 56 | return await SendAsyncSave(request, cancellationToken); 57 | } 58 | 59 | public async Task> GetCurrentWeather(double latitude, double longitude, CancellationToken cancellationToken) 60 | { 61 | var request = new HttpRequestMessage 62 | { 63 | Method = HttpMethod.Get, 64 | RequestUri = new Uri($"{_options.Value.BaseUrl}/current?lat={latitude}&lon={longitude}"), 65 | Headers = 66 | { 67 | { XRapidAPIHostHeader, _options.Value.XRapidAPIHost }, 68 | { XRapidAPIKeyHeader, _options.Value.XRapidAPIKey }, 69 | } 70 | }; 71 | 72 | return await SendAsyncSave(request, cancellationToken); 73 | } 74 | 75 | private async Task> SendAsyncSave(HttpRequestMessage requestMessage, CancellationToken cancellationToken) 76 | { 77 | try 78 | { 79 | return await SendAsync(requestMessage, cancellationToken); 80 | } 81 | catch (Exception ex) 82 | { 83 | return Result.Fail(ex.Message); 84 | } 85 | } 86 | 87 | private async Task> SendAsync(HttpRequestMessage requestMessage, CancellationToken cancellationToken) 88 | { 89 | using var response = await _httpClient.SendAsync(requestMessage, cancellationToken); 90 | if (!response.IsSuccessStatusCode) 91 | { 92 | return Result.Fail($"Failed response to {nameof(SendAsync)}"); 93 | } 94 | 95 | var resultContent = await response.Content.ReadAsStringAsync(); 96 | 97 | var result = JsonConvert.DeserializeObject(resultContent, _jsonSerializerSettingsFactory.Create()); 98 | if(result is null) 99 | { 100 | return Result.Fail($"Failed to deserialize response."); 101 | } 102 | 103 | return Result.Ok(result); 104 | } 105 | 106 | } 107 | } -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.Core.UnitTests/Commands/AddFavoriteHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Weather.Core.Abstractions; 2 | using Weather.Core.Commands; 3 | using Weather.Core.HandlerModel; 4 | using Weather.Core.Resources; 5 | using Weather.Domain.Commands; 6 | using Weather.Domain.Logging; 7 | using Weather.UnitTests.Common.Extensions; 8 | 9 | namespace Weather.Core.UnitTests.Commands 10 | { 11 | public class AddFavoriteHandlerTests 12 | { 13 | private readonly Mock _weatherCommandsRepositoryMock; 14 | private readonly Mock> _addFavoriteCommandValidatorMock; 15 | private readonly Mock> _loggerMock; 16 | 17 | private readonly IStatusRequestHandler _uut; 18 | public AddFavoriteHandlerTests() 19 | { 20 | _weatherCommandsRepositoryMock = new(); 21 | _addFavoriteCommandValidatorMock = new(); 22 | _loggerMock = new(); 23 | 24 | _uut = new AddFavoriteHandler(_weatherCommandsRepositoryMock.Object, _addFavoriteCommandValidatorMock.Object, _loggerMock.Object); 25 | } 26 | 27 | 28 | [Fact] 29 | public async Task InvalidLocation() 30 | { 31 | //Arrange 32 | var addFavoriteCommand = new AddFavoriteCommand { Location = new Domain.Dtos.LocationDto { Latitude = 1, Longitude = 1 } }; 33 | 34 | _addFavoriteCommandValidatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = false}); 35 | 36 | //Act 37 | var result = await _uut.HandleAsync(addFavoriteCommand, CancellationToken.None); 38 | 39 | //Assert 40 | Assert.Equal(HandlerStatusCode.ValidationError, result.StatusCode); 41 | Assert.Single(result.Errors); 42 | _addFavoriteCommandValidatorMock.Verify(x => x.Validate(It.Is(y => y.Equals(addFavoriteCommand))), Times.Once); 43 | } 44 | 45 | [Fact] 46 | public async Task AddFavoriteLocation_Failed() 47 | { 48 | //Arrange 49 | var addFavoriteCommand = new AddFavoriteCommand { Location = new Domain.Dtos.LocationDto { Latitude = 1, Longitude = 1 } }; 50 | var errorMessage = "errorMessage"; 51 | 52 | _addFavoriteCommandValidatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = true }); 53 | _weatherCommandsRepositoryMock.Setup(x => x.AddFavoriteLocation(It.IsAny(), It.IsAny())).ReturnsAsync(Result.Fail(errorMessage)); 54 | 55 | //Act 56 | var result = await _uut.HandleAsync(addFavoriteCommand, CancellationToken.None); 57 | 58 | //Assert 59 | Assert.Equal(HandlerStatusCode.InternalError, result.StatusCode); 60 | Assert.Single(result.Errors); 61 | Assert.Equal(ErrorMessages.CantStoreLocation, result.Errors.Single()); 62 | _addFavoriteCommandValidatorMock.Verify(x => x.Validate(It.Is(y => y.Equals(addFavoriteCommand))), Times.Once); 63 | _weatherCommandsRepositoryMock.Verify(x => x.AddFavoriteLocation(It.Is(y=>y.Equals(addFavoriteCommand)), It.IsAny()), Times.Once); 64 | _loggerMock.VerifyLog(LogLevel.Error, LogEvents.FavoriteWeathersStoreToDatabase, errorMessage, Times.Once()); 65 | } 66 | 67 | [Fact] 68 | public async Task Success() 69 | { 70 | //Arrange 71 | var addFavoriteCommand = new AddFavoriteCommand { Location = new Domain.Dtos.LocationDto { Latitude = 1, Longitude = 1 } }; 72 | var locationId = 1; 73 | 74 | _addFavoriteCommandValidatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = true }); 75 | _weatherCommandsRepositoryMock.Setup(x => x.AddFavoriteLocation(It.IsAny(), It.IsAny())).ReturnsAsync(Result.Ok(locationId)); 76 | 77 | //Act 78 | var result = await _uut.HandleAsync(addFavoriteCommand, CancellationToken.None); 79 | 80 | //Assert 81 | Assert.Equal(HandlerStatusCode.Success, result.StatusCode); 82 | Assert.Empty(result.Errors); 83 | Assert.Equal(locationId, result.Data); 84 | _addFavoriteCommandValidatorMock.Verify(x => x.Validate(It.Is(y => y.Equals(addFavoriteCommand))), Times.Once); 85 | _weatherCommandsRepositoryMock.Verify(x => x.AddFavoriteLocation(It.Is(y => y.Equals(addFavoriteCommand)), It.IsAny()), Times.Once); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Tests/SystemTests/Weather.API.SystemTests/WeatherSystemTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc.Testing; 2 | using Newtonsoft.Json; 3 | using System.Text; 4 | using Weather.Core.HandlerModel; 5 | using Weather.Domain.Commands; 6 | using Weather.Domain.Dtos; 7 | 8 | namespace Weather.API.SystemTests 9 | { 10 | public class WeatherSystemTests 11 | { 12 | private readonly double latitude = 38.5; 13 | private readonly double longitude = -78.5; 14 | private readonly string cityName = "Stanley"; 15 | 16 | private readonly HttpClient _httpClient; 17 | 18 | public WeatherSystemTests() 19 | { 20 | var application = new WebApplicationFactory(); 21 | _httpClient = application.CreateClient(); 22 | } 23 | 24 | [Fact] 25 | public async Task GetCurrentWeather() 26 | { 27 | //Arrange 28 | //Act 29 | var response = await _httpClient.GetAsync($"weather/v1/current?latitude={latitude}&longitude={longitude}"); 30 | 31 | //Assert 32 | response.EnsureSuccessStatusCode(); 33 | var stringResult = await response.Content.ReadAsStringAsync(); 34 | var resultDto = JsonConvert.DeserializeObject>(stringResult); 35 | Assert.NotNull(resultDto?.Data); 36 | Assert.Equal(cityName, resultDto.Data.CityName); 37 | } 38 | 39 | [Fact] 40 | public async Task GetForecastWeather() 41 | { 42 | //Arrange 43 | //Act 44 | var response = await _httpClient.GetAsync($"weather/v1/forecast?latitude={latitude}&longitude={longitude}"); 45 | 46 | //Assert 47 | response.EnsureSuccessStatusCode(); 48 | var stringResult = await response.Content.ReadAsStringAsync(); 49 | var resultDto = JsonConvert.DeserializeObject>(stringResult); 50 | Assert.NotNull(resultDto?.Data); 51 | Assert.Equal(cityName, resultDto.Data.CityName); 52 | } 53 | 54 | [Fact] 55 | public async Task PostWeatherFavorites() 56 | { 57 | //Act 58 | var response = await AddFavorite(); 59 | 60 | //Assert 61 | response.EnsureSuccessStatusCode(); 62 | var stringResult = await response.Content.ReadAsStringAsync(); 63 | var result = JsonConvert.DeserializeObject>(stringResult); 64 | Assert.True(result?.Data); 65 | } 66 | 67 | [Fact] 68 | public async Task GetWeatherFavorites() 69 | { 70 | //Arrange 71 | var addResponse = await AddFavorite(); 72 | 73 | addResponse.EnsureSuccessStatusCode(); 74 | //Act 75 | var response = await _httpClient.GetAsync("weather/v1/favorites"); 76 | 77 | //Assert 78 | response.EnsureSuccessStatusCode(); 79 | var stringResult = await response.Content.ReadAsStringAsync(); 80 | var resultDto = JsonConvert.DeserializeObject>(stringResult); 81 | Assert.NotNull(resultDto?.Data); 82 | Assert.Equal(cityName, resultDto.Data.FavoriteWeathers.First().CityName); 83 | } 84 | 85 | [Fact] 86 | public async Task DeleteWeatherFavorites() 87 | { 88 | //Arrange 89 | var addResponse = await AddFavorite(); 90 | 91 | addResponse.EnsureSuccessStatusCode(); 92 | 93 | var content = await addResponse.Content.ReadAsStringAsync(); 94 | var addResult = JsonConvert.DeserializeObject>(content); 95 | //Act 96 | var response = await _httpClient.DeleteAsync($"weather/v1/favorites/{addResult!.Data}"); 97 | 98 | //Assert 99 | response.EnsureSuccessStatusCode(); 100 | var stringResult = await response.Content.ReadAsStringAsync(); 101 | var resultDto = JsonConvert.DeserializeObject>(stringResult); 102 | Assert.NotNull(resultDto?.Data); 103 | Assert.True(resultDto.Data); 104 | } 105 | 106 | private async Task AddFavorite() 107 | { 108 | //Arrange 109 | var body = JsonConvert.SerializeObject(new AddFavoriteCommand 110 | { 111 | Location = new LocationDto 112 | { 113 | Latitude = latitude, 114 | Longitude = longitude, 115 | } 116 | }); 117 | var content = new StringContent(body, Encoding.UTF8, "application/json"); 118 | 119 | //Act 120 | return await _httpClient.PostAsync("weather/v1/favorites", content); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/Weather.Core/Queries/GetFavoritesHandler.cs: -------------------------------------------------------------------------------- 1 | using Ardalis.GuardClauses; 2 | using FluentResults; 3 | using Microsoft.Extensions.Logging; 4 | using Weather.Core.Abstractions; 5 | using Weather.Core.HandlerModel; 6 | using Weather.Core.Resources; 7 | using Weather.Domain.BusinessEntities; 8 | using Weather.Domain.Dtos; 9 | using Weather.Domain.Extensions; 10 | using Weather.Domain.Logging; 11 | using Weather.Domain.Resources; 12 | 13 | namespace Weather.Core.Queries 14 | { 15 | internal sealed class GetFavoritesHandler : IStatusRequestHandler 16 | { 17 | private readonly IRequestValidator _locationValidator; 18 | private readonly IRequestValidator _currentWeatherValidator; 19 | private readonly IWeatherQueriesRepository _weatherQueriesRepository; 20 | private readonly IWeatherService _weatherService; 21 | private readonly ILogger _logger; 22 | 23 | public GetFavoritesHandler(IWeatherQueriesRepository weatherQueriesRepository, 24 | IWeatherService weatherService, 25 | IRequestValidator locationValidator, 26 | IRequestValidator currentWeatherValidator, 27 | ILogger logger) 28 | { 29 | _locationValidator = Guard.Against.Null(locationValidator); 30 | _currentWeatherValidator = Guard.Against.Null(currentWeatherValidator); 31 | _weatherQueriesRepository = Guard.Against.Null(weatherQueriesRepository); 32 | _weatherService = Guard.Against.Null(weatherService); 33 | _logger = Guard.Against.Null(logger); 34 | } 35 | 36 | public async Task> HandleAsync(EmptyRequest request, CancellationToken cancellationToken) 37 | { 38 | var favoriteLocationsResult = await _weatherQueriesRepository.GetFavorites(cancellationToken); 39 | 40 | if(!favoriteLocationsResult.HasAny()) 41 | { 42 | return HandlerResponses.AsSuccessWithEmptyResult(); 43 | } 44 | 45 | return await GetFavoritesAsync(favoriteLocationsResult, cancellationToken); 46 | 47 | } 48 | 49 | private async Task> GetFavoritesAsync(IEnumerable favoriteLocationsResult, CancellationToken cancellationToken) 50 | { 51 | var result = new List(); 52 | var errorMessages = new List(); 53 | 54 | await favoriteLocationsResult.ForEachAsync(async (location) => 55 | { 56 | var favoriteWeather = await GetWeatherAsync(location, cancellationToken); 57 | 58 | if(favoriteWeather.IsFailed) 59 | { 60 | errorMessages.AddRange(favoriteWeather.Errors.ToErrorMessages()); 61 | return; 62 | } 63 | 64 | result.Add(new FavoriteCurrentWeatherDto 65 | { 66 | CityName = favoriteWeather.Value.CityName, 67 | DateTime = favoriteWeather.Value.DateTime, 68 | Sunrise = favoriteWeather.Value.Sunrise, 69 | Sunset = favoriteWeather.Value.Sunset, 70 | Id = location.Id, 71 | Temperature = favoriteWeather.Value.Temperature 72 | }); 73 | }); 74 | 75 | return result.Any() ? 76 | HandlerResponses.AsSuccess(new FavoritesWeatherDto { FavoriteWeathers = result, }, errorMessages) : 77 | HandlerResponses.AsInternalError(errorMessages); 78 | } 79 | 80 | private async Task> GetWeatherAsync(LocationDto location, CancellationToken cancellationToken) 81 | { 82 | if (!_locationValidator.Validate(location).IsValid) 83 | { 84 | _logger.LogWarning(LogEvents.FavoriteWeathersGeneral, ErrorLogMessages.InvalidLocation, location); 85 | return Result.Fail(string.Format(ErrorMessages.InvalidStoredLocation, location)); 86 | } 87 | 88 | var favoriteWeather = await _weatherService.GetCurrentWeather(location, cancellationToken); 89 | if (favoriteWeather.IsFailed) 90 | { 91 | _logger.LogWarning(LogEvents.FavoriteWeathersGeneral, favoriteWeather.Errors.JoinToMessage()); 92 | return Result.Fail(ErrorMessages.ExternalApiError); 93 | } 94 | 95 | if (!_currentWeatherValidator.Validate(favoriteWeather.Value).IsValid) 96 | { 97 | _logger.LogWarning(LogEvents.FavoriteWeathersGeneral, ErrorLogMessages.InvalidWeather, location); 98 | return Result.Fail(ErrorMessages.ExternalApiError); 99 | } 100 | 101 | return favoriteWeather.Value; 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Weather.Domain/Resources/ErrorLogMessages.resx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | text/microsoft-resx 110 | 111 | 112 | 2.0 113 | 114 | 115 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 116 | 117 | 118 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 119 | 120 | 121 | Invalid location {location}. 122 | 123 | 124 | Invalid weather object for location {location}. 125 | 126 | 127 | Validation error: {error} 128 | 129 | -------------------------------------------------------------------------------- /src/Weather.Core/Resources/ErrorMessages.resx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | text/microsoft-resx 110 | 111 | 112 | 2.0 113 | 114 | 115 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 116 | 117 | 118 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 119 | 120 | 121 | Can't store location to database. 122 | 123 | 124 | Problem with external API, can't retrive data properly. 125 | 126 | 127 | Stored favorite location is invalid {0}. 128 | 129 | 130 | Invalid request data: {0}. 131 | 132 | -------------------------------------------------------------------------------- /src/Weather.Infrastructure/Resources/ErrorMessages.resx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | text/microsoft-resx 110 | 111 | 112 | 2.0 113 | 114 | 115 | System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 116 | 117 | 118 | System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 119 | 120 | 121 | Failed to retrieve data from database. 122 | 123 | 124 | Can't retrieve data properly from external service. Not expected data. Invalid Count: {0} 125 | 126 | 127 | Can't retrieve data properly from external service. Data are empty or null. 128 | 129 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Wheaterbit.Client.UnitTests/WeatherbitHttpClientTests.cs: -------------------------------------------------------------------------------- 1 | using Validot; 2 | using Wheaterbit.Client.Abstractions; 3 | using Wheaterbit.Client.UnitTests.DataGenerator; 4 | using Wheaterbit.Client.UnitTests.Extensions; 5 | using Wheaterbit.Client.UnitTests.Extensions.Http; 6 | 7 | namespace Wheaterbit.Client.UnitTests 8 | { 9 | public class WeatherbitHttpClientTests 10 | { 11 | [Fact] 12 | public void InvalidOptions() 13 | { 14 | //Arrange 15 | var _customHttpMessageHandlerMock = new Mock(); 16 | var _httpClientFactoryMock = new Mock().Setup(_customHttpMessageHandlerMock); 17 | var _jsonSerializerSettingsFactoryMock = new Mock(); 18 | var _optionsValidatorMock = new Mock>() 19 | .Setup(true); 20 | 21 | //Act & Assert 22 | Assert.Throws(() => new WeatherbitHttpClient(WeatherbitOptionsGenerator.CreateValidOptions(), _httpClientFactoryMock.Object, _optionsValidatorMock.Object, _jsonSerializerSettingsFactoryMock.Object)); 23 | } 24 | 25 | [Fact] 26 | public async Task GetSixteenDayForecast_SendAsync_NotSuccessStatusCode() 27 | { 28 | //Arrange 29 | var _customHttpMessageHandlerMock = new Mock(); 30 | var _httpClientFactoryMock = new Mock().Setup(_customHttpMessageHandlerMock); 31 | var _jsonSerializerSettingsFactoryMock = new Mock(); 32 | var _optionsValidatorMock = new Mock>() 33 | .Setup(false); 34 | 35 | var _uut = new WeatherbitHttpClient( 36 | WeatherbitOptionsGenerator.CreateValidOptions(), 37 | _httpClientFactoryMock.Object, 38 | _optionsValidatorMock.Object, 39 | _jsonSerializerSettingsFactoryMock.Object); 40 | 41 | _customHttpMessageHandlerMock 42 | .Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) 43 | .ReturnsAsync(new HttpResponseMessage { StatusCode = System.Net.HttpStatusCode.InternalServerError }); 44 | //Act 45 | var result = await _uut.GetSixteenDayForecast(0,0,CancellationToken.None); 46 | 47 | //Assert 48 | Assert.False(result.IsSuccess); 49 | Assert.Single(result.Errors); 50 | Assert.Equal("Failed response to SendAsync", result.Errors.Single().Message); 51 | } 52 | 53 | [Fact] 54 | public async Task GetSixteenDayForecast_InvalidUri() 55 | { 56 | //Arrange 57 | var _customHttpMessageHandlerMock = new Mock(); 58 | var _httpClientFactoryMock = new Mock().Setup(_customHttpMessageHandlerMock); 59 | var _jsonSerializerSettingsFactoryMock = new Mock(); 60 | var _optionsValidatorMock = new Mock>() 61 | .Setup(false); 62 | 63 | var _uut = new WeatherbitHttpClient( 64 | WeatherbitOptionsGenerator.CreateInvalidOptions(), 65 | _httpClientFactoryMock.Object, 66 | _optionsValidatorMock.Object, 67 | _jsonSerializerSettingsFactoryMock.Object); 68 | 69 | //Act & Assert 70 | await Assert.ThrowsAsync(() => _uut.GetSixteenDayForecast(0, 0, CancellationToken.None)); 71 | } 72 | 73 | [Fact] 74 | public async Task GetSixteenDayForecast_SendAsync_ThrowException() 75 | { 76 | //Arrange 77 | var _customHttpMessageHandlerMock = new Mock(); 78 | var _httpClientFactoryMock = new Mock().Setup(_customHttpMessageHandlerMock); 79 | var _jsonSerializerSettingsFactoryMock = new Mock(); 80 | var _optionsValidatorMock = new Mock>() 81 | .Setup(false); 82 | 83 | var sendAsyncException = new ArgumentOutOfRangeException("Exception message"); 84 | 85 | _customHttpMessageHandlerMock 86 | .Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) 87 | .ThrowsAsync(sendAsyncException); 88 | 89 | var _uut = new WeatherbitHttpClient( 90 | WeatherbitOptionsGenerator.CreateValidOptions(), 91 | _httpClientFactoryMock.Object, 92 | _optionsValidatorMock.Object, 93 | _jsonSerializerSettingsFactoryMock.Object); 94 | //Act 95 | var result = await _uut.GetSixteenDayForecast(0, 0, CancellationToken.None); 96 | 97 | //Assert 98 | Assert.False(result.IsSuccess); 99 | Assert.Single(result.Errors); 100 | Assert.Equal(sendAsyncException.Message, result.Errors.Single().Message); 101 | } 102 | 103 | [Fact] 104 | public async Task GetSixteenDayForecast_SendAsync_DeserializeObject_Failed() 105 | { 106 | //Arrange 107 | var _customHttpMessageHandlerMock = new Mock(); 108 | var _httpClientFactoryMock = new Mock().Setup(_customHttpMessageHandlerMock); 109 | var _jsonSerializerSettingsFactoryMock = new Mock(); 110 | var _optionsValidatorMock = new Mock>() 111 | .Setup(false); 112 | 113 | var _uut = new WeatherbitHttpClient( 114 | WeatherbitOptionsGenerator.CreateValidOptions(), 115 | _httpClientFactoryMock.Object, 116 | _optionsValidatorMock.Object, 117 | _jsonSerializerSettingsFactoryMock.Object); 118 | 119 | _customHttpMessageHandlerMock 120 | .Setup(x => x.SendAsync(It.IsAny(), It.IsAny())) 121 | .ReturnsAsync(new HttpResponseMessage { StatusCode = System.Net.HttpStatusCode.OK, Content = new StringContent("") }); 122 | //Act 123 | var result = await _uut.GetSixteenDayForecast(0, 0, CancellationToken.None); 124 | 125 | //Assert 126 | Assert.False(result.IsSuccess); 127 | Assert.Single(result.Errors); 128 | Assert.Equal("Failed to deserialize response.", result.Errors.Single().Message); 129 | } 130 | 131 | 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.Core.UnitTests/Queries/GetCurrentWeatherHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Weather.Core.Abstractions; 2 | using Weather.Core.HandlerModel; 3 | using Weather.Core.Queries; 4 | using Weather.Core.Resources; 5 | using Weather.Domain.Dtos; 6 | using Weather.Domain.Logging; 7 | using Weather.Domain.Queries; 8 | using Weather.UnitTests.Common.Extensions; 9 | 10 | namespace Weather.Core.UnitTests.Queries 11 | { 12 | public class GetCurrentWeatherHandlerTests 13 | { 14 | private readonly Mock> _getCurrentWeatherQueryValidatorMock; 15 | private readonly Mock> _currentWeatherValidatorMock; 16 | private readonly Mock _weatherServiceMock; 17 | private readonly Mock> _loggerMock; 18 | 19 | private readonly IStatusRequestHandler _uut; 20 | public GetCurrentWeatherHandlerTests() 21 | { 22 | _getCurrentWeatherQueryValidatorMock = new(); 23 | _currentWeatherValidatorMock = new(); 24 | _weatherServiceMock = new(); 25 | _loggerMock = new(); 26 | 27 | _uut = new GetCurrentWeatherHandler(_getCurrentWeatherQueryValidatorMock.Object, _currentWeatherValidatorMock.Object, _weatherServiceMock.Object, _loggerMock.Object); 28 | } 29 | 30 | [Fact] 31 | public async Task InvalidLocation() 32 | { 33 | //Arrange 34 | var getCurrentWeatherQuery = new GetCurrentWeatherQuery(1,1); 35 | 36 | _getCurrentWeatherQueryValidatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = false}); 37 | 38 | //Act 39 | var result = await _uut.HandleAsync(getCurrentWeatherQuery, CancellationToken.None); 40 | 41 | //Assert 42 | Assert.Equal(HandlerStatusCode.ValidationError, result.StatusCode); 43 | Assert.Single(result.Errors); 44 | Assert.Null(result.Data); 45 | _getCurrentWeatherQueryValidatorMock.Verify(x => x.Validate(It.Is(y => y.Equals(getCurrentWeatherQuery))), Times.Once); 46 | } 47 | 48 | [Fact] 49 | public async Task GetCurrentWeather_Failed() 50 | { 51 | //Arrange 52 | var errorMessage = "error"; 53 | var getCurrentWeatherQuery = new GetCurrentWeatherQuery(1, 1); 54 | 55 | _getCurrentWeatherQueryValidatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = true }); 56 | _weatherServiceMock.Setup(x=>x.GetCurrentWeather(It.IsAny(), It.IsAny())).ReturnsAsync(Result.Fail(errorMessage)); 57 | //Act 58 | var result = await _uut.HandleAsync(getCurrentWeatherQuery, CancellationToken.None); 59 | 60 | //Assert 61 | Assert.Equal(HandlerStatusCode.InternalError, result.StatusCode); 62 | Assert.Single(result.Errors); 63 | Assert.Equal(ErrorMessages.ExternalApiError, result.Errors.Single()); 64 | Assert.Null(result.Data); 65 | _getCurrentWeatherQueryValidatorMock.Verify(x => x.Validate(It.Is(y => y.Equals(getCurrentWeatherQuery))), Times.Once); 66 | _weatherServiceMock.Verify(x => x.GetCurrentWeather(It.Is(y => y.Equals(getCurrentWeatherQuery.Location)), It.IsAny()), Times.Once); 67 | _loggerMock.VerifyLog(LogLevel.Error, LogEvents.CurrentWeathersGet, errorMessage, Times.Once()); 68 | } 69 | 70 | [Fact] 71 | public async Task CurrentWeather_ValidationFailed() 72 | { 73 | //Arrange 74 | var getCurrentWeatherQuery = new GetCurrentWeatherQuery(1, 1); 75 | var currentWeather = new CurrentWeatherDto(); 76 | 77 | _getCurrentWeatherQueryValidatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = true }); 78 | _weatherServiceMock.Setup(x => x.GetCurrentWeather(It.IsAny(), It.IsAny())).ReturnsAsync(Result.Ok(currentWeather)); 79 | _currentWeatherValidatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = false}); 80 | 81 | //Act 82 | var result = await _uut.HandleAsync(getCurrentWeatherQuery, CancellationToken.None); 83 | 84 | //Assert 85 | Assert.Equal(HandlerStatusCode.InternalError, result.StatusCode); 86 | Assert.Single(result.Errors); 87 | Assert.Null(result.Data); 88 | _getCurrentWeatherQueryValidatorMock.Verify(x => x.Validate(It.Is(y => y.Equals(getCurrentWeatherQuery))), Times.Once); 89 | _weatherServiceMock.Verify(x => x.GetCurrentWeather(It.Is(y => y.Equals(getCurrentWeatherQuery.Location)), It.IsAny()), Times.Once); 90 | _currentWeatherValidatorMock.Verify(x => x.Validate(It.Is(y=>y.Equals(currentWeather))), Times.Once); 91 | _loggerMock.VerifyLog(LogLevel.Error, LogEvents.CurrentWeathersValidation, Times.Once()); 92 | } 93 | 94 | [Fact] 95 | public async Task Success() 96 | { 97 | //Arrange 98 | var getCurrentWeatherQuery = new GetCurrentWeatherQuery(1, 1); 99 | var currentWeather = new CurrentWeatherDto(); 100 | 101 | _getCurrentWeatherQueryValidatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = true }); 102 | _weatherServiceMock.Setup(x => x.GetCurrentWeather(It.IsAny(), It.IsAny())).ReturnsAsync(Result.Ok(currentWeather)); 103 | _currentWeatherValidatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = true}); 104 | 105 | //Act 106 | var result = await _uut.HandleAsync(getCurrentWeatherQuery, CancellationToken.None); 107 | 108 | //Assert 109 | Assert.Equal(HandlerStatusCode.Success, result.StatusCode); 110 | Assert.Empty(result.Errors); 111 | Assert.NotNull(result.Data); 112 | Assert.Equal(currentWeather, result.Data); 113 | _getCurrentWeatherQueryValidatorMock.Verify(x => x.Validate(It.Is(y => y.Equals(getCurrentWeatherQuery))), Times.Once); 114 | _weatherServiceMock.Verify(x => x.GetCurrentWeather(It.Is(y => y.Equals(getCurrentWeatherQuery.Location)), It.IsAny()), Times.Once); 115 | _currentWeatherValidatorMock.Verify(x => x.Validate(It.Is(y => y.Equals(currentWeather))), Times.Once); 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.Core.UnitTests/Queries/GetForecastWeatherHandlerTests.cs: -------------------------------------------------------------------------------- 1 | using Weather.Core.Abstractions; 2 | using Weather.Core.HandlerModel; 3 | using Weather.Core.Queries; 4 | using Weather.Core.Resources; 5 | using Weather.Domain.Dtos; 6 | using Weather.Domain.Logging; 7 | using Weather.Domain.Queries; 8 | using Weather.UnitTests.Common.Extensions; 9 | 10 | namespace Weather.Core.UnitTests.Queries 11 | { 12 | public class GetForecastWeatherHandlerTests 13 | { 14 | private readonly Mock> _getForecastWeatherQueryValidatorMock; 15 | private readonly Mock> _forecastWeatherValidatorMock; 16 | private readonly Mock _weatherServiceMock; 17 | private readonly Mock> _loggerMock; 18 | 19 | private readonly IStatusRequestHandler _uut; 20 | public GetForecastWeatherHandlerTests() 21 | { 22 | _getForecastWeatherQueryValidatorMock = new(); 23 | _forecastWeatherValidatorMock = new(); 24 | _weatherServiceMock = new(); 25 | _loggerMock = new(); 26 | 27 | _uut = new GetForecastWeatherHandler( 28 | _getForecastWeatherQueryValidatorMock.Object, 29 | _weatherServiceMock.Object, 30 | _forecastWeatherValidatorMock.Object, 31 | _loggerMock.Object); 32 | } 33 | 34 | [Fact] 35 | public async Task InvalidLocation() 36 | { 37 | //Arrange 38 | var getForecastWeatherQuery = new GetForecastWeatherQuery(1, 1); 39 | 40 | _getForecastWeatherQueryValidatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = false }); 41 | 42 | //Act 43 | var result = await _uut.HandleAsync(getForecastWeatherQuery, CancellationToken.None); 44 | 45 | //Assert 46 | Assert.Equal(HandlerStatusCode.ValidationError, result.StatusCode); 47 | Assert.Single(result.Errors); 48 | Assert.Null(result.Data); 49 | _getForecastWeatherQueryValidatorMock.Verify(x => x.Validate(It.Is(y => y.Equals(getForecastWeatherQuery))), Times.Once); 50 | } 51 | 52 | [Fact] 53 | public async Task GetForecastWeather_Failed() 54 | { 55 | //Arrange 56 | var errorMessage = "error"; 57 | var getForecastWeatherQuery = new GetForecastWeatherQuery(1, 1); 58 | 59 | _getForecastWeatherQueryValidatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = true }); 60 | _weatherServiceMock.Setup(x => x.GetForecastWeather(It.IsAny(), It.IsAny())).ReturnsAsync(Result.Fail(errorMessage)); 61 | //Act 62 | var result = await _uut.HandleAsync(getForecastWeatherQuery, CancellationToken.None); 63 | 64 | //Assert 65 | Assert.Equal(HandlerStatusCode.InternalError, result.StatusCode); 66 | Assert.Single(result.Errors); 67 | Assert.Equal(ErrorMessages.ExternalApiError, result.Errors.Single()); 68 | Assert.Null(result.Data); 69 | _getForecastWeatherQueryValidatorMock.Verify(x => x.Validate(It.Is(y => y.Equals(getForecastWeatherQuery))), Times.Once); 70 | _weatherServiceMock.Verify(x => x.GetForecastWeather(It.Is(y => y.Equals(getForecastWeatherQuery.Location)), It.IsAny()), Times.Once); 71 | _loggerMock.VerifyLog(LogLevel.Error, LogEvents.ForecastWeathersGet, errorMessage, Times.Once()); 72 | } 73 | 74 | [Fact] 75 | public async Task GetForecastWeather_ValidationFailed() 76 | { 77 | //Arrange 78 | var getForecastWeatherQuery = new GetForecastWeatherQuery(1, 1); 79 | var forecastWeather = new ForecastWeatherDto(); 80 | 81 | _getForecastWeatherQueryValidatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = true }); 82 | _weatherServiceMock.Setup(x => x.GetForecastWeather(It.IsAny(), It.IsAny())).ReturnsAsync(Result.Ok(forecastWeather)); 83 | _forecastWeatherValidatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = false }); 84 | 85 | //Act 86 | var result = await _uut.HandleAsync(getForecastWeatherQuery, CancellationToken.None); 87 | 88 | //Assert 89 | Assert.Equal(HandlerStatusCode.InternalError, result.StatusCode); 90 | Assert.Single(result.Errors); 91 | Assert.Null(result.Data); 92 | _getForecastWeatherQueryValidatorMock.Verify(x => x.Validate(It.Is(y => y.Equals(getForecastWeatherQuery))), Times.Once); 93 | _weatherServiceMock.Verify(x => x.GetForecastWeather(It.Is(y => y.Equals(getForecastWeatherQuery.Location)), It.IsAny()), Times.Once); 94 | _forecastWeatherValidatorMock.Verify(x => x.Validate(It.Is(y => y.Equals(forecastWeather))), Times.Once); 95 | _loggerMock.VerifyLog(LogLevel.Error, LogEvents.ForecastWeathersValidation, Times.Once()); 96 | } 97 | 98 | [Fact] 99 | public async Task Success() 100 | { 101 | //Arrange 102 | var getForecastWeatherQuery = new GetForecastWeatherQuery(1, 1); 103 | var forecastWeather = new ForecastWeatherDto(); 104 | 105 | _getForecastWeatherQueryValidatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = true }); 106 | _weatherServiceMock.Setup(x => x.GetForecastWeather(It.IsAny(), It.IsAny())).ReturnsAsync(Result.Ok(forecastWeather)); 107 | _forecastWeatherValidatorMock.Setup(x => x.Validate(It.IsAny())).Returns(new RequestValidationResult { IsValid = true }); 108 | 109 | //Act 110 | var result = await _uut.HandleAsync(getForecastWeatherQuery, CancellationToken.None); 111 | 112 | //Assert 113 | Assert.Equal(HandlerStatusCode.Success, result.StatusCode); 114 | Assert.Empty(result.Errors); 115 | Assert.NotNull(result.Data); 116 | Assert.Equal(forecastWeather, result.Data); 117 | _getForecastWeatherQueryValidatorMock.Verify(x => x.Validate(It.Is(y => y.Equals(getForecastWeatherQuery))), Times.Once); 118 | _weatherServiceMock.Verify(x => x.GetForecastWeather(It.Is(y => y.Equals(getForecastWeatherQuery.Location)), It.IsAny()), Times.Once); 119 | _forecastWeatherValidatorMock.Verify(x => x.Validate(It.Is(y => y.Equals(forecastWeather))), Times.Once); 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | 33 | # Visual Studio 2015/2017 cache/options directory 34 | .vs/ 35 | # Uncomment if you have tasks that create the project's static files in wwwroot 36 | #wwwroot/ 37 | 38 | # Visual Studio 2017 auto generated files 39 | Generated\ Files/ 40 | 41 | # MSTest test Results 42 | [Tt]est[Rr]esult*/ 43 | [Bb]uild[Ll]og.* 44 | 45 | # NUnit 46 | *.VisualState.xml 47 | TestResult.xml 48 | nunit-*.xml 49 | 50 | # Build Results of an ATL Project 51 | [Dd]ebugPS/ 52 | [Rr]eleasePS/ 53 | dlldata.c 54 | 55 | # Benchmark Results 56 | BenchmarkDotNet.Artifacts/ 57 | 58 | # .NET Core 59 | project.lock.json 60 | project.fragment.lock.json 61 | artifacts/ 62 | 63 | # StyleCop 64 | StyleCopReport.xml 65 | 66 | # Files built by Visual Studio 67 | *_i.c 68 | *_p.c 69 | *_h.h 70 | *.ilk 71 | *.meta 72 | *.obj 73 | *.iobj 74 | *.pch 75 | *.pdb 76 | *.ipdb 77 | *.pgc 78 | *.pgd 79 | *.rsp 80 | *.sbr 81 | *.tlb 82 | *.tli 83 | *.tlh 84 | *.tmp 85 | *.tmp_proj 86 | *_wpftmp.csproj 87 | *.log 88 | *.vspscc 89 | *.vssscc 90 | .builds 91 | *.pidb 92 | *.svclog 93 | *.scc 94 | 95 | # Chutzpah Test files 96 | _Chutzpah* 97 | 98 | # Visual C++ cache files 99 | ipch/ 100 | *.aps 101 | *.ncb 102 | *.opendb 103 | *.opensdf 104 | *.sdf 105 | *.cachefile 106 | *.VC.db 107 | *.VC.VC.opendb 108 | 109 | # Visual Studio profiler 110 | *.psess 111 | *.vsp 112 | *.vspx 113 | *.sap 114 | 115 | # Visual Studio Trace Files 116 | *.e2e 117 | 118 | # TFS 2012 Local Workspace 119 | $tf/ 120 | 121 | # Guidance Automation Toolkit 122 | *.gpState 123 | 124 | # ReSharper is a .NET coding add-in 125 | _ReSharper*/ 126 | *.[Rr]e[Ss]harper 127 | *.DotSettings.user 128 | 129 | # JustCode is a .NET coding add-in 130 | .JustCode 131 | 132 | # TeamCity is a build add-in 133 | _TeamCity* 134 | 135 | # DotCover is a Code Coverage Tool 136 | *.dotCover 137 | 138 | # AxoCover is a Code Coverage Tool 139 | .axoCover/* 140 | !.axoCover/settings.json 141 | 142 | # Visual Studio code coverage results 143 | *.coverage 144 | *.coveragexml 145 | 146 | # NCrunch 147 | _NCrunch_* 148 | .*crunch*.local.xml 149 | nCrunchTemp_* 150 | 151 | # MightyMoose 152 | *.mm.* 153 | AutoTest.Net/ 154 | 155 | # Web workbench (sass) 156 | .sass-cache/ 157 | 158 | # Installshield output folder 159 | [Ee]xpress/ 160 | 161 | # DocProject is a documentation generator add-in 162 | DocProject/buildhelp/ 163 | DocProject/Help/*.HxT 164 | DocProject/Help/*.HxC 165 | DocProject/Help/*.hhc 166 | DocProject/Help/*.hhk 167 | DocProject/Help/*.hhp 168 | DocProject/Help/Html2 169 | DocProject/Help/html 170 | 171 | # Click-Once directory 172 | publish/ 173 | 174 | # Publish Web Output 175 | *.[Pp]ublish.xml 176 | *.azurePubxml 177 | # Note: Comment the next line if you want to checkin your web deploy settings, 178 | # but database connection strings (with potential passwords) will be unencrypted 179 | *.pubxml 180 | *.publishproj 181 | 182 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 183 | # checkin your Azure Web App publish settings, but sensitive information contained 184 | # in these scripts will be unencrypted 185 | PublishScripts/ 186 | 187 | # NuGet Packages 188 | *.nupkg 189 | # NuGet Symbol Packages 190 | *.snupkg 191 | # The packages folder can be ignored because of Package Restore 192 | **/[Pp]ackages/* 193 | # except build/, which is used as an MSBuild target. 194 | !**/[Pp]ackages/build/ 195 | # Uncomment if necessary however generally it will be regenerated when needed 196 | #!**/[Pp]ackages/repositories.config 197 | # NuGet v3's project.json files produces more ignorable files 198 | *.nuget.props 199 | *.nuget.targets 200 | 201 | # Microsoft Azure Build Output 202 | csx/ 203 | *.build.csdef 204 | 205 | # Microsoft Azure Emulator 206 | ecf/ 207 | rcf/ 208 | 209 | # Windows Store app package directories and files 210 | AppPackages/ 211 | BundleArtifacts/ 212 | Package.StoreAssociation.xml 213 | _pkginfo.txt 214 | *.appx 215 | *.appxbundle 216 | *.appxupload 217 | 218 | # Visual Studio cache files 219 | # files ending in .cache can be ignored 220 | *.[Cc]ache 221 | # but keep track of directories ending in .cache 222 | !?*.[Cc]ache/ 223 | 224 | # Others 225 | ClientBin/ 226 | ~$* 227 | *~ 228 | *.dbmdl 229 | *.dbproj.schemaview 230 | *.jfm 231 | *.pfx 232 | *.publishsettings 233 | orleans.codegen.cs 234 | 235 | # Including strong name files can present a security risk 236 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 237 | #*.snk 238 | 239 | # Since there are multiple workflows, uncomment next line to ignore bower_components 240 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 241 | #bower_components/ 242 | 243 | # RIA/Silverlight projects 244 | Generated_Code/ 245 | 246 | # Backup & report files from converting an old project file 247 | # to a newer Visual Studio version. Backup files are not needed, 248 | # because we have git ;-) 249 | _UpgradeReport_Files/ 250 | Backup*/ 251 | UpgradeLog*.XML 252 | UpgradeLog*.htm 253 | ServiceFabricBackup/ 254 | *.rptproj.bak 255 | 256 | # SQL Server files 257 | *.mdf 258 | *.ldf 259 | *.ndf 260 | 261 | # Business Intelligence projects 262 | *.rdl.data 263 | *.bim.layout 264 | *.bim_*.settings 265 | *.rptproj.rsuser 266 | *- [Bb]ackup.rdl 267 | *- [Bb]ackup ([0-9]).rdl 268 | *- [Bb]ackup ([0-9][0-9]).rdl 269 | 270 | # Microsoft Fakes 271 | FakesAssemblies/ 272 | 273 | # GhostDoc plugin setting file 274 | *.GhostDoc.xml 275 | 276 | # Node.js Tools for Visual Studio 277 | .ntvs_analysis.dat 278 | node_modules/ 279 | 280 | # Visual Studio 6 build log 281 | *.plg 282 | 283 | # Visual Studio 6 workspace options file 284 | *.opt 285 | 286 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 287 | *.vbw 288 | 289 | # Visual Studio LightSwitch build output 290 | **/*.HTMLClient/GeneratedArtifacts 291 | **/*.DesktopClient/GeneratedArtifacts 292 | **/*.DesktopClient/ModelManifest.xml 293 | **/*.Server/GeneratedArtifacts 294 | **/*.Server/ModelManifest.xml 295 | _Pvt_Extensions 296 | 297 | # Paket dependency manager 298 | .paket/paket.exe 299 | paket-files/ 300 | 301 | # FAKE - F# Make 302 | .fake/ 303 | 304 | # CodeRush personal settings 305 | .cr/personal 306 | 307 | # Python Tools for Visual Studio (PTVS) 308 | __pycache__/ 309 | *.pyc 310 | 311 | # Cake - Uncomment if you are using it 312 | # tools/** 313 | # !tools/packages.config 314 | 315 | # Tabs Studio 316 | *.tss 317 | 318 | # Telerik's JustMock configuration file 319 | *.jmconfig 320 | 321 | # BizTalk build output 322 | *.btp.cs 323 | *.btm.cs 324 | *.odx.cs 325 | *.xsd.cs 326 | 327 | # OpenCover UI analysis results 328 | OpenCover/ 329 | 330 | # Azure Stream Analytics local run output 331 | ASALocalRun/ 332 | 333 | # MSBuild Binary and Structured Log 334 | *.binlog 335 | 336 | # NVidia Nsight GPU debugger configuration file 337 | *.nvuser 338 | 339 | # MFractors (Xamarin productivity tool) working folder 340 | .mfractor/ 341 | 342 | # Local History for Visual Studio 343 | .localhistory/ 344 | 345 | # BeatPulse healthcheck temp database 346 | healthchecksdb 347 | 348 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 349 | MigrationBackup/ 350 | -------------------------------------------------------------------------------- /src/Tests/UnitTests/Weather.Infrastructure.UnitTests/Database/Repositories/WeatherCommandsRepositoryTests.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.Logging; 3 | using Weather.Core.Abstractions; 4 | using Weather.Domain.Commands; 5 | using Weather.Domain.Dtos; 6 | using Weather.Domain.Logging; 7 | using Weather.Infrastructure.Database.EFContext.Entities; 8 | using Weather.Infrastructure.Database.Repositories; 9 | using Weather.UnitTests.Common.Extensions; 10 | 11 | namespace Weather.Infrastructure.UnitTests.Database.Repositories 12 | { 13 | public class WeatherCommandsRepositoryTests 14 | { 15 | private readonly Mock> _favoriteLocationEntityDbSetMock; 16 | private readonly Mock _mapperMock; 17 | private readonly Mock _weatherDbContextMock; 18 | private readonly Mock> _loggerMock; 19 | 20 | private readonly IWeatherCommandsRepository _uut; 21 | 22 | public WeatherCommandsRepositoryTests() 23 | { 24 | _favoriteLocationEntityDbSetMock = new Mock>(); 25 | _weatherDbContextMock = new Mock(); 26 | _weatherDbContextMock.Setup(x => x.FavoriteLocations).Returns(_favoriteLocationEntityDbSetMock.Object); 27 | 28 | _mapperMock = new Mock(); 29 | _loggerMock = new Mock>(); 30 | 31 | _uut = new WeatherCommandsRepository(_weatherDbContextMock.Object, _mapperMock.Object, _loggerMock.Object); 32 | } 33 | 34 | [Fact] 35 | public async Task AddFavoriteLocation_Success() 36 | { 37 | //Arrange 38 | var addFavoriteCommand = new AddFavoriteCommand { Location = new LocationDto { Latitude = 1, Longitude = 1 } }; 39 | var favoriteLocationEntity = new FavoriteLocationEntity(); 40 | 41 | _mapperMock.Setup(x => x.Map(It.IsAny())).Returns(favoriteLocationEntity); 42 | 43 | //Act 44 | var result = await _uut.AddFavoriteLocation(addFavoriteCommand, CancellationToken.None); 45 | 46 | //Assert 47 | Assert.True(result.IsSuccess); 48 | _mapperMock.Verify(x => x.Map(It.IsAny()), Times.Once); 49 | _weatherDbContextMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); 50 | _favoriteLocationEntityDbSetMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Once); 51 | } 52 | 53 | [Fact] 54 | public async Task AddFavoriteLocation_Failed() 55 | { 56 | //Arrange 57 | var addFavoriteCommand = new AddFavoriteCommand { Location = new LocationDto { Latitude = 1, Longitude = 1 } }; 58 | var favoriteLocationEntity = new FavoriteLocationEntity(); 59 | 60 | _mapperMock.Setup(x => x.Map(It.IsAny())).Returns(favoriteLocationEntity); 61 | _favoriteLocationEntityDbSetMock.Setup(x => x.AddAsync(It.IsAny(), It.IsAny())).Throws(new DbUpdateException()); 62 | 63 | //Act 64 | var result = await _uut.AddFavoriteLocation(addFavoriteCommand, CancellationToken.None); 65 | 66 | //Assert 67 | Assert.True(result.IsFailed); 68 | _loggerMock.VerifyLog(LogLevel.Error, LogEvents.FavoriteWeathersStoreToDatabase, Times.Once()); 69 | _mapperMock.Verify(x => x.Map(It.IsAny()), Times.Once); 70 | _weatherDbContextMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Never); 71 | _favoriteLocationEntityDbSetMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Once); 72 | } 73 | 74 | [Fact] 75 | public async Task AddFavoriteLocation_Throw() 76 | { 77 | //Arrange 78 | var addFavoriteCommand = new AddFavoriteCommand { Location = new LocationDto { Latitude = 1, Longitude = 1 } }; 79 | var favoriteLocationEntity = new FavoriteLocationEntity(); 80 | var exception = new ArgumentException("some message"); 81 | 82 | _mapperMock.Setup(x => x.Map(It.IsAny())).Returns(favoriteLocationEntity); 83 | _favoriteLocationEntityDbSetMock.Setup(x => x.AddAsync(It.IsAny(), It.IsAny())).Throws(exception); 84 | 85 | //Act 86 | var exceptionResult = await Assert.ThrowsAsync(() => _uut.AddFavoriteLocation(addFavoriteCommand, CancellationToken.None)); 87 | 88 | //Assert 89 | Assert.Equivalent(exception, exceptionResult); 90 | _mapperMock.Verify(x => x.Map(It.IsAny()), Times.Once); 91 | _weatherDbContextMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Never); 92 | _favoriteLocationEntityDbSetMock.Verify(x => x.AddAsync(It.IsAny(), It.IsAny()), Times.Once); 93 | } 94 | 95 | [Fact] 96 | public async Task DeleteFavoriteLocationSafeAsync_Success() 97 | { 98 | //Arrange 99 | var deleteFavoriteCommand = new DeleteFavoriteCommand { Id = 1 }; 100 | var favoriteLocationEntity = new FavoriteLocationEntity(); 101 | _favoriteLocationEntityDbSetMock.Setup(x => x.FindAsync(It.IsAny(), It.IsAny())) 102 | .ReturnsAsync(favoriteLocationEntity); 103 | 104 | //Act 105 | var result = await _uut.DeleteFavoriteLocationSafeAsync(deleteFavoriteCommand, CancellationToken.None); 106 | 107 | //Assert 108 | Assert.True(result.IsSuccess); 109 | _weatherDbContextMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); 110 | _favoriteLocationEntityDbSetMock.Verify(x => x.FindAsync(deleteFavoriteCommand.Id, CancellationToken.None), Times.Once); 111 | _favoriteLocationEntityDbSetMock.Verify(x => x.Remove(favoriteLocationEntity), Times.Once); 112 | } 113 | 114 | [Fact] 115 | public async Task DeleteFavoriteLocationSafeAsync_Failed() 116 | { 117 | //Arrange 118 | var deleteFavoriteCommand = new DeleteFavoriteCommand { Id = 1 }; 119 | var favoriteLocationEntity = new FavoriteLocationEntity(); 120 | _favoriteLocationEntityDbSetMock.Setup(x => x.FindAsync(It.IsAny(), It.IsAny())) 121 | .ReturnsAsync(favoriteLocationEntity); 122 | _weatherDbContextMock.Setup(x => x.SaveChangesAsync(It.IsAny())) 123 | .ThrowsAsync(new DbUpdateException()); 124 | 125 | //Act 126 | var result = await _uut.DeleteFavoriteLocationSafeAsync(deleteFavoriteCommand, CancellationToken.None); 127 | 128 | //Assert 129 | Assert.True(result.IsFailed); 130 | _weatherDbContextMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); 131 | _favoriteLocationEntityDbSetMock.Verify(x => x.FindAsync(deleteFavoriteCommand.Id, CancellationToken.None), Times.Once); 132 | _favoriteLocationEntityDbSetMock.Verify(x => x.Remove(favoriteLocationEntity), Times.Once); 133 | } 134 | 135 | [Fact] 136 | public async Task DeleteFavoriteLocationSafeAsync_Throw() 137 | { 138 | //Arrange 139 | var deleteFavoriteCommand = new DeleteFavoriteCommand { Id = 1 }; 140 | var favoriteLocationEntity = new FavoriteLocationEntity(); 141 | _favoriteLocationEntityDbSetMock.Setup(x => x.FindAsync(It.IsAny(), It.IsAny())) 142 | .ReturnsAsync(favoriteLocationEntity); 143 | 144 | _weatherDbContextMock.Setup(x => x.SaveChangesAsync(It.IsAny())) 145 | .ThrowsAsync(new ArgumentException()); 146 | 147 | //Act 148 | await Assert.ThrowsAsync(() => _uut.DeleteFavoriteLocationSafeAsync(deleteFavoriteCommand, CancellationToken.None)); 149 | 150 | //Assert 151 | _weatherDbContextMock.Verify(x => x.SaveChangesAsync(It.IsAny()), Times.Once); 152 | _favoriteLocationEntityDbSetMock.Verify(x => x.FindAsync(deleteFavoriteCommand.Id, CancellationToken.None), Times.Once); 153 | _favoriteLocationEntityDbSetMock.Verify(x => x.Remove(favoriteLocationEntity), Times.Once); 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Clean Architecture WeatherApi 4 | [![.NET Build and Test](https://github.com/Gramli/WeatherApi/actions/workflows/dotnet.yml/badge.svg)](https://github.com/Gramli/WeatherApi/actions/workflows/dotnet.yml) 5 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/77a7db482a44489aa5fbe40ca15d3137)](https://www.codacy.com/gh/Gramli/WeatherApi/dashboard?utm_source=github.com&utm_medium=referral&utm_content=Gramli/WeatherApi&utm_campaign=Badge_Grade) 6 | [![Codacy Badge](https://app.codacy.com/project/badge/Coverage/77a7db482a44489aa5fbe40ca15d3137)](https://www.codacy.com/gh/Gramli/WeatherApi/dashboard?utm_source=github.com&utm_medium=referral&utm_content=Gramli/WeatherApi&utm_campaign=Badge_Coverage) 7 | 8 | This REST API solution demonstrates how to create a clean, modern API (from my point of view) using Clean Architecture, Minimal API, and various design patterns. 9 | 10 | The example API allows users to retrieve current and forecasted weather data by location from [Weatherbit](https://www.weatherbit.io/) via [RapidAPI](https://rapidapi.com). It also allows users to add favorite locations to an [in memory database](https://learn.microsoft.com/en-us/ef/core/providers/in-memory/?tabs=dotnet-core-cli) and retrieve weather data for those stored locations. 11 | 12 | ## Menu 13 | - [Clean Architecture WeatherApi](#clean-architecture-weatherapi) 14 | - [Menu](#menu) 15 | - [Prerequisites](#prerequisites) 16 | - [Installation](#installation) 17 | - [Get Started](#get-started) 18 | - [Try it in Scalar](#try-it-in-scalar) 19 | - [Try it using .http file (VS2022)](#try-it-using-http-file-vs2022) 20 | - [Motivation](#motivation) 21 | - [Architecture](#architecture) 22 | - [Clean Architecture Layers](#clean-architecture-layers) 23 | - [Horizontal Diagram (references)](#horizontal-diagram-references) 24 | - [Pros and Cons](#pros-and-cons) 25 | - [Technologies](#technologies) 26 | 27 | ## Prerequisites 28 | * **.NET SDK 10.0.x** 29 | 30 | ## Installation 31 | 32 | To install the project using Git Bash: 33 | 34 | 1. Clone the repository: 35 | ```bash 36 | git clone https://github.com/Gramli/WeatherApi.git 37 | ``` 38 | 2. Navigate to the project directory: 39 | ```bash 40 | cd WeatherApi/src 41 | ``` 42 | 3. Install the backend dependencies: 43 | ```bash 44 | dotnet restore 45 | ``` 46 | 47 | ## Get Started 48 | 1. Register on [RapidAPI](https://rapidapi.com) 49 | 2. Subscribe [Weatherbit](https://rapidapi.com/weatherbit/api/weather) (its for free) and go to Endpoints tab 50 | 3. In API documentation copy (from Code Snippet) **X-RapidAPI-Key**, **X-RapidAPI-Host** and put them to appsettings.json file in WeatherAPI project 51 | ```json 52 | "Weatherbit": { 53 | "BaseUrl": "https://weatherbit-v1-mashape.p.rapidapi.com", 54 | "XRapidAPIKey": "value from code snippet", 55 | "XRapidAPIHost": "value from code snippet" 56 | } 57 | ``` 58 | 4. Run Weather.API 59 | 60 | ### Try it in Scalar 61 | ![Scalar API Reference](./doc/img/weatherApiScalar.gif) 62 | 63 | ### Try it using .http file (VS2022) 64 | * Go to Tests/Debug folder and open **debug-tests.http** file (in VS2022) 65 | * Send request 66 | 67 | ## Motivation 68 | The main motivation for this project is to create a practical example of Minimal API, to explore its benefits and drawbacks, and to build a REST API using Clean Architecture and various design patterns. 69 | ## Architecture 70 | 71 | This project follows **[Clean Architecture](https://learn.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/common-web-application-architectures#clean-architecture)**. The application layer is split into the Core and Domain projects, where the Core project holds the business rules, and the Domain project contains the business entities. 72 | 73 | Since Minimal API allows injecting handlers into endpoint mapping methods, I decided not to use **[MediatR](https://github.com/jbogard/MediatR)**. Instead, each endpoint has its own request and handler. The solution follows the **[CQRS pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs)**, where handlers are separated into commands and queries. Command handlers handle command requests, while query handlers handle query requests. Additionally, repositories (**[Repository pattern](https://learn.microsoft.com/en-us/aspnet/mvc/overview/older-versions/getting-started-with-ef-5-using-mvc-4/implementing-the-repository-and-unit-of-work-patterns-in-an-asp-net-mvc-application)**) are also separated into command and query repositories. 74 | 75 | Instead of throwing exceptions, the project uses the **[Result pattern](https://www.forevolve.com/en/articles/2018/03/19/operation-result/)** (using the [FluentResuls package](https://github.com/altmann/FluentResults)). Every handler returns a HandlerResponse object, which contains the result data, error messages, and a HandlerStatusCode enum value that represents the operation's outcome (Success, ValidationError, InternalError, etc.). 76 | 77 | All API responses are wrapped in a DataResponse class, which holds the result data and any errors. The HandlerStatusCode is then mapped to appropriate HTTP status codes at the API layer, separating business logic outcomes from HTTP protocol concerns. 78 | 79 | An important aspect of any project is **[testing](https://github.com/Gramli/WeatherApi/tree/main/src/Tests)**. When writing tests, we aim for [optimal code coverage](https://stackoverflow.com/questions/90002/what-is-a-reasonable-code-coverage-for-unit-tests-and-why). I believe every project has its own optimal coverage based on its needs. My rule is: cover your code enough to confidently refactor without worrying about functionality changes. 80 | 81 | In this solution, each code project has its own unit test project, and each unit test project mirrors the directory structure of its respective code project. This structure helps with organization in larger projects. 82 | 83 | To ensure the REST API works as expected for end users, we write **system tests**. These tests typically call API endpoints in a specific order defined by business requirements and check the expected results. The solution contains simple [System Tests](https://github.com/Gramli/WeatherApi/tree/main/src/Tests/SystemTests), which call the exposed endpoints and validate the response. 84 | 85 | ### Clean Architecture Layers 86 | 87 | * **WeatherAPI** 88 | This is the entry point of the application and the top layer, containing: 89 | 90 | * Endpoints: Define and expose application routes. 91 | * Middlewares (or Filters): Handle cross-cutting concerns like exception handling and logging. 92 | * API Configuration: Centralized setup for services, routes, and middleware. 93 | 94 | * **Weather.Infrastructure** 95 | This layer handles communication with external resources, such as databases, caches, and web services. It includes: 96 | 97 | * Repositories Implementation: Provides access to the database. 98 | * External Services Proxies: Proxy classes for obtaining data from external web services. 99 | * ** Weatherbit.Client** - A standalone project dedicated to communication with RapidAPI/Weatherbit. 100 | * Infrastructure-Specific Services: Services required to interact with external libraries and frameworks. 101 | 102 | * **Weather.Core** 103 | This layer contains the application's business logic, including: 104 | 105 | * Request Handlers/Managers: Implement business operations and workflows. 106 | * Abstractions: Define interfaces and contracts, including abstractions for the infrastructure layer (e.g., services, repositories) to ensure their usability in the core layer. 107 | 108 | * **Weather.Domain** 109 | Contains shared components that are used across all projects, such as: 110 | 111 | * DTOs: Data Transfer Objects for communication between layers. 112 | * General Extensions: Common utilities and extension methods. 113 | 114 | #### Horizontal Diagram (references) 115 | ![Project Clean Architecture Diagram](./doc/img/cleanArchitecture.jpg) 116 | 117 | ### Pros and Cons 118 | * [Clean Architecture pros and cons](https://gramli.github.io//posts/architecture/clean-architecture-pros-and-cons) 119 | * [Minimal API pros and cons](https://gramli.github.io/posts/code/aspnet/minimap-api-pros-and-cons) 120 | 121 | ## Technologies 122 | * [ASP.NET Core 10](https://learn.microsoft.com/en-us/aspnet/core/introduction-to-aspnet-core?view=aspnetcore-10.0) 123 | * [Entity Framework Core InMemory](https://learn.microsoft.com/en-us/ef/core/providers/in-memory/?tabs=dotnet-core-cli) 124 | * [SmallApiToolkit](https://github.com/Gramli/SmallApiToolkit) 125 | * [AutoMapper](https://github.com/AutoMapper/AutoMapper) 126 | * [FluentResuls](https://github.com/altmann/FluentResults) 127 | * [Validot](https://github.com/bartoszlenar/Validot) 128 | * [GuardClauses](https://github.com/ardalis/GuardClauses) 129 | * [Moq](https://github.com/moq/moq4) 130 | * [Xunit](https://github.com/xunit/xunit) 131 | 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /src/Weather.API.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 18 4 | VisualStudioVersion = 18.1.11304.174 d18.0 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Weather.API", "Weather.API\Weather.API.csproj", "{F87A6E06-2DB3-40BB-8831-6C12E828DA11}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Weather.Core", "Weather.Core\Weather.Core.csproj", "{D1C1E4E1-F77D-4D27-BB2D-B4609F917CAF}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Weather.Domain", "Weather.Domain\Weather.Domain.csproj", "{E0DE3B29-9CC2-4A5C-B0A4-A2485D03CB36}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Weather.Infrastructure", "Weather.Infrastructure\Weather.Infrastructure.csproj", "{AED0FC1F-8E88-4B61-AB81-CF2F934549DB}" 13 | EndProject 14 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wheaterbit.Client", "Wheaterbit.Client\Wheaterbit.Client.csproj", "{D4AA1812-0615-483D-B1C5-5DFFB2800D5D}" 15 | EndProject 16 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ExternalClients", "ExternalClients", "{A7E5DDBF-9DF9-4220-9BEA-9FC36FFC659C}" 17 | EndProject 18 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{F3BB2E4D-53BC-4EB0-9A73-300134879E26}" 19 | EndProject 20 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Weather.API.UnitTests", "Tests\UnitTests\Weather.API.UnitTests\Weather.API.UnitTests.csproj", "{AB3F6519-5124-40C5-99E5-7043855AD62F}" 21 | EndProject 22 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Weather.Core.UnitTests", "Tests\UnitTests\Weather.Core.UnitTests\Weather.Core.UnitTests.csproj", "{C457E260-1E22-4ED0-B32A-2C6C7CA0D659}" 23 | EndProject 24 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Weather.Domain.UnitTests", "Tests\UnitTests\Weather.Domain.UnitTests\Weather.Domain.UnitTests.csproj", "{20107565-B8E0-484D-BA65-D933E23B784C}" 25 | EndProject 26 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Weather.Infrastructure.UnitTests", "Tests\UnitTests\Weather.Infrastructure.UnitTests\Weather.Infrastructure.UnitTests.csproj", "{4C21DB4C-18DC-490D-8CFA-7EBB7D8999F8}" 27 | EndProject 28 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "UnitTests", "UnitTests", "{DBB0632C-F7EA-4593-927B-49DB2371CDE5}" 29 | EndProject 30 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SystemTests", "SystemTests", "{457E4CA0-5F3C-4653-A131-2E5046A98C85}" 31 | EndProject 32 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Weather.API.SystemTests", "Tests\SystemTests\Weather.API.SystemTests\Weather.API.SystemTests.csproj", "{48E1C36D-3609-441B-9753-6D9F130EE1B6}" 33 | EndProject 34 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "IntegrationTests", "IntegrationTests", "{F1939A12-97F2-4846-95BB-D187C90A24E7}" 35 | EndProject 36 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Weather.Infrastructure.IntegrationTests", "Tests\IntegrationTests\Weather.Infrastructure.IntegrationTests\Weather.Infrastructure.IntegrationTests.csproj", "{AC3B0CA2-967D-465A-A6E1-B052B5FB25DB}" 37 | EndProject 38 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Weather.UnitTests.Common", "Tests\UnitTests\Weather.UnitTests.Common\Weather.UnitTests.Common.csproj", "{A42AAA9B-DD74-48D3-A23C-9C3D69009C1C}" 39 | EndProject 40 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ExternalClients", "ExternalClients", "{20EF0756-7FA6-4708-8349-D1E1FF49229A}" 41 | EndProject 42 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Wheaterbit.Client.UnitTests", "Tests\UnitTests\Wheaterbit.Client.UnitTests\Wheaterbit.Client.UnitTests.csproj", "{501B5489-717D-49E3-940B-99DDA39F278F}" 43 | EndProject 44 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "HttpDebug", "HttpDebug", "{799572B7-2EF0-498A-ABD0-7AAA963286F5}" 45 | ProjectSection(SolutionItems) = preProject 46 | Tests\HttpDebug\debug-tests.http = Tests\HttpDebug\debug-tests.http 47 | EndProjectSection 48 | EndProject 49 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E834AC05-A42D-4605-840F-90A863436680}" 50 | ProjectSection(SolutionItems) = preProject 51 | Directory.Packages.props = Directory.Packages.props 52 | EndProjectSection 53 | EndProject 54 | Global 55 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 56 | Debug|Any CPU = Debug|Any CPU 57 | Release|Any CPU = Release|Any CPU 58 | EndGlobalSection 59 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 60 | {F87A6E06-2DB3-40BB-8831-6C12E828DA11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 61 | {F87A6E06-2DB3-40BB-8831-6C12E828DA11}.Debug|Any CPU.Build.0 = Debug|Any CPU 62 | {F87A6E06-2DB3-40BB-8831-6C12E828DA11}.Release|Any CPU.ActiveCfg = Release|Any CPU 63 | {F87A6E06-2DB3-40BB-8831-6C12E828DA11}.Release|Any CPU.Build.0 = Release|Any CPU 64 | {D1C1E4E1-F77D-4D27-BB2D-B4609F917CAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 65 | {D1C1E4E1-F77D-4D27-BB2D-B4609F917CAF}.Debug|Any CPU.Build.0 = Debug|Any CPU 66 | {D1C1E4E1-F77D-4D27-BB2D-B4609F917CAF}.Release|Any CPU.ActiveCfg = Release|Any CPU 67 | {D1C1E4E1-F77D-4D27-BB2D-B4609F917CAF}.Release|Any CPU.Build.0 = Release|Any CPU 68 | {E0DE3B29-9CC2-4A5C-B0A4-A2485D03CB36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 69 | {E0DE3B29-9CC2-4A5C-B0A4-A2485D03CB36}.Debug|Any CPU.Build.0 = Debug|Any CPU 70 | {E0DE3B29-9CC2-4A5C-B0A4-A2485D03CB36}.Release|Any CPU.ActiveCfg = Release|Any CPU 71 | {E0DE3B29-9CC2-4A5C-B0A4-A2485D03CB36}.Release|Any CPU.Build.0 = Release|Any CPU 72 | {AED0FC1F-8E88-4B61-AB81-CF2F934549DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 73 | {AED0FC1F-8E88-4B61-AB81-CF2F934549DB}.Debug|Any CPU.Build.0 = Debug|Any CPU 74 | {AED0FC1F-8E88-4B61-AB81-CF2F934549DB}.Release|Any CPU.ActiveCfg = Release|Any CPU 75 | {AED0FC1F-8E88-4B61-AB81-CF2F934549DB}.Release|Any CPU.Build.0 = Release|Any CPU 76 | {D4AA1812-0615-483D-B1C5-5DFFB2800D5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 77 | {D4AA1812-0615-483D-B1C5-5DFFB2800D5D}.Debug|Any CPU.Build.0 = Debug|Any CPU 78 | {D4AA1812-0615-483D-B1C5-5DFFB2800D5D}.Release|Any CPU.ActiveCfg = Release|Any CPU 79 | {D4AA1812-0615-483D-B1C5-5DFFB2800D5D}.Release|Any CPU.Build.0 = Release|Any CPU 80 | {AB3F6519-5124-40C5-99E5-7043855AD62F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 81 | {AB3F6519-5124-40C5-99E5-7043855AD62F}.Debug|Any CPU.Build.0 = Debug|Any CPU 82 | {AB3F6519-5124-40C5-99E5-7043855AD62F}.Release|Any CPU.ActiveCfg = Release|Any CPU 83 | {AB3F6519-5124-40C5-99E5-7043855AD62F}.Release|Any CPU.Build.0 = Release|Any CPU 84 | {C457E260-1E22-4ED0-B32A-2C6C7CA0D659}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 85 | {C457E260-1E22-4ED0-B32A-2C6C7CA0D659}.Debug|Any CPU.Build.0 = Debug|Any CPU 86 | {C457E260-1E22-4ED0-B32A-2C6C7CA0D659}.Release|Any CPU.ActiveCfg = Release|Any CPU 87 | {C457E260-1E22-4ED0-B32A-2C6C7CA0D659}.Release|Any CPU.Build.0 = Release|Any CPU 88 | {20107565-B8E0-484D-BA65-D933E23B784C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 89 | {20107565-B8E0-484D-BA65-D933E23B784C}.Debug|Any CPU.Build.0 = Debug|Any CPU 90 | {20107565-B8E0-484D-BA65-D933E23B784C}.Release|Any CPU.ActiveCfg = Release|Any CPU 91 | {20107565-B8E0-484D-BA65-D933E23B784C}.Release|Any CPU.Build.0 = Release|Any CPU 92 | {4C21DB4C-18DC-490D-8CFA-7EBB7D8999F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 93 | {4C21DB4C-18DC-490D-8CFA-7EBB7D8999F8}.Debug|Any CPU.Build.0 = Debug|Any CPU 94 | {4C21DB4C-18DC-490D-8CFA-7EBB7D8999F8}.Release|Any CPU.ActiveCfg = Release|Any CPU 95 | {4C21DB4C-18DC-490D-8CFA-7EBB7D8999F8}.Release|Any CPU.Build.0 = Release|Any CPU 96 | {48E1C36D-3609-441B-9753-6D9F130EE1B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 97 | {48E1C36D-3609-441B-9753-6D9F130EE1B6}.Debug|Any CPU.Build.0 = Debug|Any CPU 98 | {48E1C36D-3609-441B-9753-6D9F130EE1B6}.Release|Any CPU.ActiveCfg = Release|Any CPU 99 | {48E1C36D-3609-441B-9753-6D9F130EE1B6}.Release|Any CPU.Build.0 = Release|Any CPU 100 | {AC3B0CA2-967D-465A-A6E1-B052B5FB25DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 101 | {AC3B0CA2-967D-465A-A6E1-B052B5FB25DB}.Debug|Any CPU.Build.0 = Debug|Any CPU 102 | {AC3B0CA2-967D-465A-A6E1-B052B5FB25DB}.Release|Any CPU.ActiveCfg = Release|Any CPU 103 | {AC3B0CA2-967D-465A-A6E1-B052B5FB25DB}.Release|Any CPU.Build.0 = Release|Any CPU 104 | {A42AAA9B-DD74-48D3-A23C-9C3D69009C1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 105 | {A42AAA9B-DD74-48D3-A23C-9C3D69009C1C}.Debug|Any CPU.Build.0 = Debug|Any CPU 106 | {A42AAA9B-DD74-48D3-A23C-9C3D69009C1C}.Release|Any CPU.ActiveCfg = Release|Any CPU 107 | {A42AAA9B-DD74-48D3-A23C-9C3D69009C1C}.Release|Any CPU.Build.0 = Release|Any CPU 108 | {501B5489-717D-49E3-940B-99DDA39F278F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 109 | {501B5489-717D-49E3-940B-99DDA39F278F}.Debug|Any CPU.Build.0 = Debug|Any CPU 110 | {501B5489-717D-49E3-940B-99DDA39F278F}.Release|Any CPU.ActiveCfg = Release|Any CPU 111 | {501B5489-717D-49E3-940B-99DDA39F278F}.Release|Any CPU.Build.0 = Release|Any CPU 112 | EndGlobalSection 113 | GlobalSection(SolutionProperties) = preSolution 114 | HideSolutionNode = FALSE 115 | EndGlobalSection 116 | GlobalSection(NestedProjects) = preSolution 117 | {D4AA1812-0615-483D-B1C5-5DFFB2800D5D} = {A7E5DDBF-9DF9-4220-9BEA-9FC36FFC659C} 118 | {AB3F6519-5124-40C5-99E5-7043855AD62F} = {DBB0632C-F7EA-4593-927B-49DB2371CDE5} 119 | {C457E260-1E22-4ED0-B32A-2C6C7CA0D659} = {DBB0632C-F7EA-4593-927B-49DB2371CDE5} 120 | {20107565-B8E0-484D-BA65-D933E23B784C} = {DBB0632C-F7EA-4593-927B-49DB2371CDE5} 121 | {4C21DB4C-18DC-490D-8CFA-7EBB7D8999F8} = {DBB0632C-F7EA-4593-927B-49DB2371CDE5} 122 | {DBB0632C-F7EA-4593-927B-49DB2371CDE5} = {F3BB2E4D-53BC-4EB0-9A73-300134879E26} 123 | {457E4CA0-5F3C-4653-A131-2E5046A98C85} = {F3BB2E4D-53BC-4EB0-9A73-300134879E26} 124 | {48E1C36D-3609-441B-9753-6D9F130EE1B6} = {457E4CA0-5F3C-4653-A131-2E5046A98C85} 125 | {F1939A12-97F2-4846-95BB-D187C90A24E7} = {F3BB2E4D-53BC-4EB0-9A73-300134879E26} 126 | {AC3B0CA2-967D-465A-A6E1-B052B5FB25DB} = {F1939A12-97F2-4846-95BB-D187C90A24E7} 127 | {A42AAA9B-DD74-48D3-A23C-9C3D69009C1C} = {DBB0632C-F7EA-4593-927B-49DB2371CDE5} 128 | {20EF0756-7FA6-4708-8349-D1E1FF49229A} = {DBB0632C-F7EA-4593-927B-49DB2371CDE5} 129 | {501B5489-717D-49E3-940B-99DDA39F278F} = {20EF0756-7FA6-4708-8349-D1E1FF49229A} 130 | {799572B7-2EF0-498A-ABD0-7AAA963286F5} = {F3BB2E4D-53BC-4EB0-9A73-300134879E26} 131 | EndGlobalSection 132 | GlobalSection(ExtensibilityGlobals) = postSolution 133 | SolutionGuid = {75897F3D-1882-40EE-A49D-10503A1CCA69} 134 | EndGlobalSection 135 | EndGlobal 136 | --------------------------------------------------------------------------------