├── .gitignore ├── Backend ├── Api │ ├── Api.csproj │ ├── Api.csproj.DotSettings │ ├── ApiResult.cs │ ├── Apis │ │ ├── ApiBase.cs │ │ ├── AuthApi.cs │ │ ├── IAuthApi.cs │ │ ├── IWeatherForecastApi.cs │ │ └── WeatherForecastApi.cs │ ├── ConfigureSwaggerOptions.cs │ ├── Extensions │ │ ├── ApplicationBuilderExtension.cs │ │ ├── ConfigurationExtension.cs │ │ ├── RouteHandlerBuilderExtensions.cs │ │ ├── ServiceCollectionExtensions.cs │ │ └── ValidationResultExtension.cs │ ├── Middleware │ │ └── JwtAuthentication.cs │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── Routes │ │ ├── AuthRouteConfiguration.cs │ │ └── WeatherForecastRouteConfiguration.cs │ ├── Validation │ │ └── WeatherEntryValidator.cs │ └── appsettings.json ├── ApiClassic │ ├── ApiClassic.csproj │ ├── ApiResult.cs │ ├── Controllers │ │ ├── AuthController.cs │ │ ├── BaseController.cs │ │ └── WeatherForecastsController.cs │ ├── Extensions │ │ ├── ApplicationBuilderExtension.cs │ │ ├── ConfigurationExtension.cs │ │ ├── ServiceCollectionExtensions.cs │ │ └── ValidationResultExtension.cs │ ├── Middleware │ │ └── JwtAuthentication.cs │ ├── Program.cs │ ├── Properties │ │ └── launchSettings.json │ ├── Validation │ │ └── WeatherEntryValidator.cs │ └── appsettings.json ├── Entities │ ├── DataContract │ │ ├── ErrorResponse │ │ │ ├── BadRequestResponse.cs │ │ │ ├── BaseErrorResponse.cs │ │ │ ├── ClientErrorResponse.cs │ │ │ ├── ErrorDetail.cs │ │ │ └── InternalServerErrorResponse.cs │ │ ├── WeatherEntry.cs │ │ └── WeatherEntryResponse.cs │ ├── Entities.csproj │ ├── Enums │ │ ├── Role.cs │ │ └── TemperatureType.cs │ ├── Exceptions │ │ └── ApiCallException.cs │ └── Jwt │ │ └── WeatherForcastJwtPayload.cs └── Infrastructure │ ├── DataAccess │ ├── IUserRepository.cs │ ├── IWeatherRepository.cs │ ├── UserRepository.cs │ └── WeatherRepository.cs │ ├── DataService │ ├── IJwtGenerator.cs │ ├── IUserService.cs │ ├── IWeatherService.cs │ ├── JwtGenerator.cs │ ├── UserService.cs │ └── WeatherService.cs │ ├── Extensions │ ├── ObjectExtensions.cs │ └── StringExtensions.cs │ └── Infrastructure.csproj ├── LICENSE ├── README.md ├── Tests ├── ApiTests │ ├── AuthApiTest.cs │ ├── TestDataGenerator.cs │ ├── TestHost.cs │ └── WeatherForecastApiTest.cs ├── Extensions │ └── HttpClientExtensions.cs ├── IntegrationTests │ ├── ApiIntegrationTestBase.cs │ └── SigningAssignmentIntegrationTest.cs ├── Startup.cs ├── Tests.csproj ├── UnitTests │ └── WeatherEntryValidationTest.cs └── appsettings.json ├── WeatherService.sln └── WeatherService.sln.DotSettings /.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 | .vscode/ 14 | **/need-analysis/**/*.js 15 | **/need-analysis/**/*.js.map 16 | **/app/corporateMeeting/**/*.js 17 | **/app/corporateMeeting/**/*.js.map 18 | **/jedi-base-copy/files/src/main/js/**/*.js 19 | **/jedi-base-copy/files/src/main/js/**/*.js.map 20 | **/SPIKWebClient/dist 21 | **/SPIKWebClient/Views/Shared/_Layout.cshtml 22 | **/SPIKWebClient/build/* 23 | 24 | # User-specific files (MonoDevelop/Xamarin Studio) 25 | *.userprefs 26 | 27 | # Build results 28 | [Dd]ebug/ 29 | [Dd]ebugPublic/ 30 | [Rr]elease/ 31 | #[Rr]eleases/ (commented out because we have a Releases folder that should be version controlled) 32 | x64/ 33 | x86/ 34 | [Aa][Rr][Mm]/ 35 | [Aa][Rr][Mm]64/ 36 | bld/ 37 | [Bb]in/ 38 | [Oo]bj/ 39 | 40 | # Visual Studio 2015/2017 cache/options directory 41 | .vs/ 42 | # Uncomment if you have tasks that create the project's static files in wwwroot 43 | #wwwroot/ 44 | 45 | # Visual Studio 2017 auto generated files 46 | Generated\ Files/ 47 | 48 | # MSTest test Results 49 | [Tt]est[Rr]esult*/ 50 | [Bb]uild[Ll]og.* 51 | 52 | # NUNIT 53 | *.VisualState.xml 54 | TestResult.xml 55 | 56 | # Build Results of an ATL Project 57 | [Dd]ebugPS/ 58 | [Rr]eleasePS/ 59 | dlldata.c 60 | 61 | # Benchmark Results 62 | BenchmarkDotNet.Artifacts/ 63 | 64 | # .NET Core 65 | project.lock.json 66 | project.fragment.lock.json 67 | artifacts/ 68 | 69 | # StyleCop 70 | StyleCopReport.xml 71 | 72 | # Files built by Visual Studio 73 | *_i.c 74 | *_p.c 75 | *_h.h 76 | *.ilk 77 | *.meta 78 | *.obj 79 | *.iobj 80 | *.pch 81 | *.pdb 82 | *.ipdb 83 | *.pgc 84 | *.pgd 85 | *.rsp 86 | *.sbr 87 | *.tlb 88 | *.tli 89 | *.tlh 90 | *.tmp 91 | *.tmp_proj 92 | *_wpftmp.csproj 93 | *.log 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # JustCode is a .NET coding add-in 136 | .JustCode 137 | 138 | # TeamCity is a build add-in 139 | _TeamCity* 140 | 141 | # DotCover is a Code Coverage Tool 142 | *.dotCover 143 | 144 | # AxoCover is a Code Coverage Tool 145 | .axoCover/* 146 | !.axoCover/settings.json 147 | 148 | # Visual Studio code coverage results 149 | *.coverage 150 | *.coveragexml 151 | 152 | # NCrunch 153 | _NCrunch_* 154 | .*crunch*.local.xml 155 | nCrunchTemp_* 156 | 157 | # MightyMoose 158 | *.mm.* 159 | AutoTest.Net/ 160 | 161 | # Web workbench (sass) 162 | .sass-cache/ 163 | 164 | # Installshield output folder 165 | [Ee]xpress/ 166 | 167 | # DocProject is a documentation generator add-in 168 | DocProject/buildhelp/ 169 | DocProject/Help/*.HxT 170 | DocProject/Help/*.HxC 171 | DocProject/Help/*.hhc 172 | DocProject/Help/*.hhk 173 | DocProject/Help/*.hhp 174 | DocProject/Help/Html2 175 | DocProject/Help/html 176 | 177 | # Click-Once directory 178 | publish/ 179 | 180 | # Publish Web Output 181 | #*.[Pp]ublish.xml (commented out because it excluded RASP.FinancialPlanning.Database.publish.xml) 182 | *.azurePubxml 183 | # Note: Comment the next line if you want to checkin your web deploy settings, 184 | # but database connection strings (with potential passwords) will be unencrypted 185 | #*.pubxml 186 | *.publishproj 187 | 188 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 189 | # checkin your Azure Web App publish settings, but sensitive information contained 190 | # in these scripts will be unencrypted 191 | PublishScripts/ 192 | 193 | # NuGet Packages 194 | *.nupkg 195 | # The packages folder can be ignored because of Package Restore 196 | **/[Pp]ackages/* 197 | # except build/, which is used as an MSBuild target. 198 | !**/[Pp]ackages/build/ 199 | # Uncomment if necessary however generally it will be regenerated when needed 200 | #!**/[Pp]ackages/repositories.config 201 | # NuGet v3's project.json files produces more ignorable files 202 | *.nuget.props 203 | *.nuget.targets 204 | 205 | # Microsoft Azure Build Output 206 | csx/ 207 | *.build.csdef 208 | 209 | # Microsoft Azure Emulator 210 | ecf/ 211 | rcf/ 212 | 213 | # Windows Store app package directories and files 214 | AppPackages/ 215 | BundleArtifacts/ 216 | Package.StoreAssociation.xml 217 | _pkginfo.txt 218 | *.appx 219 | 220 | # Visual Studio cache files 221 | # files ending in .cache can be ignored 222 | *.[Cc]ache 223 | # but keep track of directories ending in .cache 224 | !?*.[Cc]ache/ 225 | 226 | # Others 227 | ClientBin/ 228 | ~$* 229 | *~ 230 | *.dbmdl 231 | *.dbproj.schemaview 232 | *.jfm 233 | *.pfx 234 | *.publishsettings 235 | orleans.codegen.cs 236 | 237 | # Including strong name files can present a security risk 238 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 239 | #*.snk 240 | 241 | # Since there are multiple workflows, uncomment next line to ignore bower_components 242 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 243 | #bower_components/ 244 | 245 | # RIA/Silverlight projects 246 | Generated_Code/ 247 | 248 | # Backup & report files from converting an old project file 249 | # to a newer Visual Studio version. Backup files are not needed, 250 | # because we have git ;-) 251 | _UpgradeReport_Files/ 252 | Backup*/ 253 | UpgradeLog*.XML 254 | UpgradeLog*.htm 255 | ServiceFabricBackup/ 256 | *.rptproj.bak 257 | 258 | # SQL Server files 259 | *.mdf 260 | *.ldf 261 | *.ndf 262 | 263 | # Business Intelligence projects 264 | *.rdl.data 265 | *.bim.layout 266 | *.bim_*.settings 267 | *.rptproj.rsuser 268 | *- Backup*.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 | # JetBrains Rider 305 | .idea/ 306 | *.sln.iml 307 | 308 | # CodeRush personal settings 309 | .cr/personal 310 | 311 | # Python Tools for Visual Studio (PTVS) 312 | __pycache__/ 313 | *.pyc 314 | 315 | # Cake - Uncomment if you are using it 316 | # tools/** 317 | # !tools/packages.config 318 | 319 | # Tabs Studio 320 | *.tss 321 | 322 | # Telerik's JustMock configuration file 323 | *.jmconfig 324 | 325 | # BizTalk build output 326 | *.btp.cs 327 | *.btm.cs 328 | *.odx.cs 329 | *.xsd.cs 330 | 331 | # OpenCover UI analysis results 332 | OpenCover/ 333 | 334 | # Azure Stream Analytics local run output 335 | ASALocalRun/ 336 | 337 | # MSBuild Binary and Structured Log 338 | *.binlog 339 | 340 | # NVidia Nsight GPU debugger configuration file 341 | *.nvuser 342 | 343 | # MFractors (Xamarin productivity tool) working folder 344 | .mfractor/ 345 | 346 | # Local History for Visual Studio 347 | .localhistory/ 348 | 349 | /logs/ 350 | 351 | -------------------------------------------------------------------------------- /Backend/Api/Api.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | true 11 | 12 | 13 | 14 | true 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | <_Parameter1>Tests 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /Backend/Api/Api.csproj.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | True 3 | True -------------------------------------------------------------------------------- /Backend/Api/ApiResult.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Mime; 3 | using Infrastructure.Extensions; 4 | 5 | namespace Api; 6 | 7 | public class ApiResult : IResult 8 | { 9 | private readonly object _value; 10 | private readonly HttpStatusCode _httpStatusCode; 11 | public ApiResult(object value, HttpStatusCode httpStatusCode) 12 | { 13 | _value = value; 14 | _httpStatusCode = httpStatusCode; 15 | } 16 | 17 | public async Task ExecuteAsync(HttpContext httpContext) 18 | { 19 | httpContext.Response.StatusCode = (int)_httpStatusCode; 20 | httpContext.Response.ContentType = MediaTypeNames.Application.Json; 21 | await httpContext.Response.WriteAsync(_value.ToJson(true)); 22 | } 23 | } -------------------------------------------------------------------------------- /Backend/Api/Apis/ApiBase.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Net; 3 | using System.Runtime.CompilerServices; 4 | using Entities.DataContract.ErrorResponse; 5 | using Entities.Enums; 6 | using Entities.Exceptions; 7 | using Humanizer; 8 | using Infrastructure.DataService; 9 | using Infrastructure.Extensions; 10 | 11 | namespace Api.Apis; 12 | 13 | public abstract class ApiBase 14 | { 15 | private readonly ILogger _logger; 16 | protected readonly IUserService UserService; 17 | 18 | protected ApiBase(ILogger logger, IUserService userService) 19 | { 20 | _logger = logger; 21 | UserService = userService; 22 | } 23 | 24 | protected IResult HandleRequest(Func func, 25 | HttpRequest httpRequest, Role requestedRole, [CallerMemberName] string memberName = "") 26 | { 27 | var stopwatch = new Stopwatch(); 28 | stopwatch.Start(); 29 | 30 | var username = GetUsername(httpRequest); 31 | _logger.LogDebug( 32 | "User: {User}, started processing of {ClassName}.{MemberName}", username, GetType().Name, memberName); 33 | 34 | object? response = null; 35 | try 36 | { 37 | AssertRoleMembership(username, requestedRole); 38 | 39 | return func.Invoke(); 40 | } 41 | catch (ApiCallException apiCallException) 42 | { 43 | _logger.LogError("\n{ApiCallException}", apiCallException.ToJson(true)); 44 | 45 | if ((int)apiCallException.HttpStatusCode is >= 401 and <= 499) 46 | { 47 | response = new ClientErrorResponse(apiCallException, httpRequest.Path); 48 | } 49 | else 50 | { 51 | response = new InternalServerErrorResponse(apiCallException); 52 | } 53 | 54 | return new ApiResult(response, apiCallException.HttpStatusCode); 55 | } 56 | catch (Exception exception) 57 | { 58 | _logger.LogError("{Exception}", exception.ToString()); 59 | response = new InternalServerErrorResponse(exception); 60 | return new ApiResult(response, HttpStatusCode.InternalServerError); 61 | } 62 | finally 63 | { 64 | _logger.LogDebug("{FinalResponse}", response?.ToJson(true)); 65 | _logger.LogDebug("Finished processing of {ClassName}.{MemberName}, {ElapsedTime}", GetType().Name, 66 | memberName, stopwatch.Elapsed.Humanize()); 67 | } 68 | } 69 | 70 | private void AssertRoleMembership(string username, Role requestedRole) 71 | { 72 | var roles = UserService.GetRolesForUser(username).ToList(); 73 | if (!roles.Contains(requestedRole)) 74 | { 75 | throw new ApiCallException($"User {username} are missing required role {requestedRole}, having {roles}", 76 | HttpStatusCode.Forbidden); 77 | } 78 | } 79 | 80 | private static string GetUsername(HttpRequest httpRequest) 81 | { 82 | return httpRequest.HttpContext.User.Claims.Single(x => x.Type == "username").Value; 83 | } 84 | } -------------------------------------------------------------------------------- /Backend/Api/Apis/AuthApi.cs: -------------------------------------------------------------------------------- 1 | using Infrastructure.DataService; 2 | 3 | namespace Api.Apis; 4 | 5 | public class AuthApi : ApiBase, IAuthApi 6 | { 7 | private readonly IJwtGenerator _jwtGenerator; 8 | 9 | public AuthApi(ILogger logger, IJwtGenerator jwtGenerator, IUserService userService) : base(logger, userService) 10 | { 11 | _jwtGenerator = jwtGenerator; 12 | } 13 | 14 | public IResult Login(string username, string password) 15 | { 16 | return UserService.TryLogin(username, password) ? 17 | Results.Ok(_jwtGenerator.GetJwt(username)) : 18 | Results.Unauthorized(); 19 | } 20 | } -------------------------------------------------------------------------------- /Backend/Api/Apis/IAuthApi.cs: -------------------------------------------------------------------------------- 1 | namespace Api.Apis; 2 | 3 | public interface IAuthApi 4 | { 5 | IResult Login(string username, string password); 6 | } -------------------------------------------------------------------------------- /Backend/Api/Apis/IWeatherForecastApi.cs: -------------------------------------------------------------------------------- 1 | using Entities.DataContract; 2 | 3 | namespace Api.Apis; 4 | 5 | public interface IWeatherForecastApi 6 | { 7 | IResult DeleteWeatherEntry(int weatherEntryId, HttpRequest httpRequest); 8 | IResult CreateWeatherEntry(WeatherEntry weatherEntry, HttpRequest httpRequest); 9 | IResult GetWeatherEntry(int weatherEntryId, HttpRequest httpRequest); 10 | IResult GetWeatherEntries(HttpRequest httpRequest); 11 | IResult UpdateWeatherEntry(int entry, WeatherEntry weatherEntry, HttpRequest httpRequest); 12 | } -------------------------------------------------------------------------------- /Backend/Api/Apis/WeatherForecastApi.cs: -------------------------------------------------------------------------------- 1 | using Entities.DataContract; 2 | using Entities.Enums; 3 | using FluentValidation; 4 | using Infrastructure.DataService; 5 | 6 | namespace Api.Apis; 7 | 8 | internal class WeatherForecastApi : ApiBase, IWeatherForecastApi 9 | { 10 | private readonly IWeatherService _weatherService; 11 | private readonly IValidator _weatherEntryValidator; 12 | 13 | public WeatherForecastApi(ILogger logger, IWeatherService weatherService, 14 | IValidator weatherEntryValidator, IUserService userService) : base(logger, userService) 15 | { 16 | _weatherService = weatherService; 17 | _weatherEntryValidator = weatherEntryValidator; 18 | } 19 | 20 | public IResult DeleteWeatherEntry(int weatherEntryId, HttpRequest httpRequest) 21 | { 22 | return HandleRequest( 23 | () => 24 | { 25 | _weatherService.DeleteWeatherEntry(weatherEntryId); 26 | return Results.NoContent(); 27 | }, httpRequest, Role.Write); 28 | } 29 | 30 | public IResult CreateWeatherEntry(WeatherEntry weatherEntry, HttpRequest httpRequest) 31 | { 32 | return HandleRequest( 33 | () => 34 | { 35 | var validationResult = _weatherEntryValidator.Validate(weatherEntry); 36 | if (!validationResult.IsValid) return validationResult.ToValidationProblem(); 37 | 38 | var id = _weatherService.CreateWeatherEntry(weatherEntry); 39 | return Results.Ok(new WeatherEntryResponse(id, weatherEntry)); 40 | }, httpRequest, Role.Write); 41 | } 42 | 43 | public IResult GetWeatherEntry(int weatherEntryId, HttpRequest httpRequest) 44 | { 45 | return HandleRequest( 46 | () => 47 | { 48 | var response = _weatherService.GetWeatherEntry(weatherEntryId); 49 | return Results.Ok(response); 50 | }, httpRequest, Role.Read); 51 | } 52 | 53 | public IResult GetWeatherEntries(HttpRequest httpRequest) 54 | { 55 | return HandleRequest( 56 | () => 57 | { 58 | var weatherEntries = _weatherService.GetWeatherEntries(); 59 | return Results.Ok(weatherEntries); 60 | }, httpRequest, Role.Read); 61 | } 62 | 63 | public IResult UpdateWeatherEntry(int id, WeatherEntry weatherEntry, HttpRequest httpRequest) 64 | { 65 | return HandleRequest( 66 | () => 67 | { 68 | var validationResult = _weatherEntryValidator.Validate(weatherEntry); 69 | if (!validationResult.IsValid) return validationResult.ToValidationProblem(); 70 | 71 | _weatherService.UpdateWeatherEntry(id, weatherEntry); 72 | return Results.NoContent(); 73 | }, httpRequest, Role.Write); 74 | } 75 | } -------------------------------------------------------------------------------- /Backend/Api/ConfigureSwaggerOptions.cs: -------------------------------------------------------------------------------- 1 | using Asp.Versioning.ApiExplorer; 2 | using Microsoft.Extensions.Options; 3 | using Microsoft.Net.Http.Headers; 4 | using Microsoft.OpenApi.Models; 5 | using Swashbuckle.AspNetCore.SwaggerGen; 6 | 7 | namespace Api; 8 | 9 | /// 10 | /// https://www.referbruv.com/blog/posts/integrating-aspnet-core-api-versions-with-swagger-ui 11 | /// 12 | public class ConfigureSwaggerOptions 13 | : IConfigureNamedOptions 14 | { 15 | private readonly IApiVersionDescriptionProvider _provider; 16 | 17 | public ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider) 18 | { 19 | _provider = provider; 20 | } 21 | 22 | public void Configure(SwaggerGenOptions options) 23 | { 24 | // add swagger document for every API version discovered 25 | foreach (var description in _provider.ApiVersionDescriptions) 26 | { 27 | options.SwaggerDoc( 28 | description.GroupName, 29 | CreateVersionInfo(description)); 30 | } 31 | 32 | options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme() 33 | { 34 | Name = HeaderNames.Authorization, 35 | Type = SecuritySchemeType.ApiKey, 36 | Scheme = "Bearer", 37 | BearerFormat = "JWT", 38 | In = ParameterLocation.Header, 39 | Description = 40 | $"JWT Authorization header using the Bearer scheme. {Environment.NewLine}" + 41 | "Enter \"Bearer 'JWT'\" in the text input below." 42 | }); 43 | options.AddSecurityRequirement(new OpenApiSecurityRequirement 44 | { 45 | { 46 | new OpenApiSecurityScheme 47 | { 48 | Reference = new OpenApiReference 49 | { 50 | Type = ReferenceType.SecurityScheme, 51 | Id = "Bearer" 52 | } 53 | }, 54 | Array.Empty() 55 | } 56 | }); 57 | 58 | } 59 | 60 | public void Configure(string name, SwaggerGenOptions options) 61 | { 62 | Configure(options); 63 | } 64 | 65 | private OpenApiInfo CreateVersionInfo( 66 | ApiVersionDescription description) 67 | { 68 | var info = new OpenApiInfo 69 | { 70 | Title = "Swagger - WeatherService", 71 | Version = description.ApiVersion.ToString() 72 | }; 73 | 74 | if (description.IsDeprecated) 75 | { 76 | info.Description += " This API version has been deprecated."; 77 | } 78 | 79 | return info; 80 | } 81 | } -------------------------------------------------------------------------------- /Backend/Api/Extensions/ApplicationBuilderExtension.cs: -------------------------------------------------------------------------------- 1 | using Api.Middleware; 2 | 3 | namespace Api; 4 | 5 | public static class ApplicationBuilderExtension 6 | { 7 | public static IApplicationBuilder UseJwtAuthentication( 8 | this IApplicationBuilder builder) 9 | { 10 | return builder.UseMiddleware(); 11 | } 12 | } -------------------------------------------------------------------------------- /Backend/Api/Extensions/ConfigurationExtension.cs: -------------------------------------------------------------------------------- 1 | namespace Api; 2 | 3 | public static class ConfigurationExtension 4 | { 5 | public static string GetSwaggerBase(this IConfiguration configuration) 6 | { 7 | return configuration.GetValue("SwaggerBase") ?? throw new Exception("SwaggerBase must be set"); 8 | } 9 | 10 | public static string GetSecret(this IConfiguration configuration) 11 | { 12 | return configuration.GetValue("Secret") ?? throw new Exception("Secret must be set"); 13 | } 14 | } -------------------------------------------------------------------------------- /Backend/Api/Extensions/RouteHandlerBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using Asp.Versioning; 2 | using Asp.Versioning.Builder; 3 | using Entities.DataContract.ErrorResponse; 4 | 5 | namespace Api; 6 | 7 | public static class RouteHandlerBuilderExtensions 8 | { 9 | public static void AddDefaultMappingBehaviour(this RouteHandlerBuilder builder, ApiVersionSet versionSet, string tag) 10 | { 11 | builder 12 | .Produces(StatusCodes.Status400BadRequest) 13 | .Produces(StatusCodes.Status401Unauthorized) 14 | .Produces(StatusCodes.Status403Forbidden) 15 | .Produces(StatusCodes.Status404NotFound) 16 | .Produces(StatusCodes.Status500InternalServerError) 17 | .WithTags(tag) 18 | .WithApiVersionSet(versionSet) 19 | .MapToApiVersion(new ApiVersion(1.0)); 20 | } 21 | } -------------------------------------------------------------------------------- /Backend/Api/Extensions/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography; 2 | using Api.Apis; 3 | using Infrastructure.DataAccess; 4 | using Infrastructure.DataService; 5 | using JWT; 6 | using JWT.Algorithms; 7 | using JWT.Extensions.AspNetCore.Factories; 8 | 9 | namespace Api; 10 | 11 | public static class ServiceCollectionExtensions 12 | { 13 | public static void AddDependencyInjection(this IServiceCollection services) 14 | { 15 | services.AddTransient(); 16 | services.AddTransient(); 17 | services.AddTransient(); 18 | services.AddTransient(); 19 | services.AddTransient(); 20 | services.AddSingleton(_ => ECDsa.Create()); 21 | 22 | services.AddJwtDependencies(); 23 | 24 | services.AddSingleton(); 25 | services.AddSingleton(); 26 | } 27 | 28 | private static void AddJwtDependencies(this IServiceCollection services) 29 | { 30 | services.AddSingleton(); 31 | services.AddSingleton(); 32 | services.AddSingleton(); 33 | services.AddSingleton(); 34 | } 35 | } -------------------------------------------------------------------------------- /Backend/Api/Extensions/ValidationResultExtension.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation.Results; 2 | 3 | namespace Api; 4 | 5 | public static class ValidationResultExtension 6 | { 7 | public static IResult ToValidationProblem(this ValidationResult validationResult) 8 | { 9 | return Results.ValidationProblem(validationResult.Errors 10 | .ToLookup(key => key.PropertyName, value => value.ErrorMessage) 11 | .ToDictionary(key => key.Key, value => value.ToArray())); 12 | } 13 | } -------------------------------------------------------------------------------- /Backend/Api/Middleware/JwtAuthentication.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication; 2 | 3 | namespace Api.Middleware; 4 | 5 | public class JwtAuthentication 6 | { 7 | private readonly RequestDelegate _next; 8 | 9 | public JwtAuthentication(RequestDelegate next) 10 | { 11 | _next = next; 12 | } 13 | 14 | public async Task InvokeAsync(HttpContext httpContext) 15 | { 16 | var authenticationResult = await httpContext.AuthenticateAsync(); 17 | if (authenticationResult.Succeeded) 18 | { 19 | await _next(httpContext); 20 | } 21 | else 22 | { 23 | if (httpContext.Request.Path.Equals("/api/v1/login") || 24 | !httpContext.Request.Path.StartsWithSegments("/api")) 25 | { 26 | await _next(httpContext); 27 | } 28 | else 29 | { 30 | await httpContext.ChallengeAsync(); 31 | } 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /Backend/Api/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using System.Text.Json.Serialization; 3 | using Api; 4 | using Api.Routes; 5 | using Api.Validation; 6 | using FluentValidation.AspNetCore; 7 | using JWT.Extensions.AspNetCore; 8 | using Microsoft.AspNetCore.Http.Json; 9 | using Newtonsoft.Json; 10 | using Newtonsoft.Json.Serialization; 11 | 12 | var builder = WebApplication.CreateBuilder(args); 13 | 14 | builder.Services.AddEndpointsApiExplorer(); 15 | builder.Services 16 | .AddApiVersioning(setup => 17 | { 18 | setup.ReportApiVersions = true; 19 | }) 20 | .AddApiExplorer(setup => 21 | { 22 | setup.GroupNameFormat = "'v'VVV"; 23 | setup.SubstituteApiVersionInUrl = true; 24 | }); 25 | 26 | builder.Services.AddDependencyInjection(); 27 | 28 | builder.Services.AddSwaggerGen(); 29 | builder.Services.ConfigureOptions(); 30 | 31 | builder.Logging.SetMinimumLevel(LogLevel.Debug); 32 | builder.Logging.AddFile(builder.Configuration); 33 | 34 | builder.Services.AddFluentValidation(v => v.RegisterValidatorsFromAssemblyContaining()); 35 | 36 | JsonConvert.DefaultSettings = () => new JsonSerializerSettings 37 | { 38 | ContractResolver = new CamelCasePropertyNamesContractResolver(), 39 | Formatting = Formatting.None, 40 | NullValueHandling = NullValueHandling.Ignore 41 | }; 42 | 43 | builder.Services.Configure(options => 44 | { 45 | options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; 46 | options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); 47 | options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; 48 | }); 49 | 50 | builder.Services.AddAuthentication(options => 51 | { 52 | options.DefaultAuthenticateScheme = JwtAuthenticationDefaults.AuthenticationScheme; 53 | options.DefaultChallengeScheme = JwtAuthenticationDefaults.AuthenticationScheme; 54 | }) 55 | .AddJwt(options => 56 | { 57 | options.Keys = new[] { builder.Configuration.GetSecret() }; 58 | //options.Keys = null; 59 | options.VerifySignature = true; 60 | }); 61 | 62 | var app = builder.Build(); 63 | 64 | app.MapWeatherForecastRoutes(); 65 | app.MapAuthRoutes(); 66 | 67 | app.UseAuthentication(); 68 | app.UseJwtAuthentication(); 69 | 70 | if (app.Environment.IsDevelopment()) 71 | { 72 | app.UseSwagger(); 73 | app.UseSwaggerUI(c => 74 | { 75 | c.DocumentTitle = "WeatherService - Swagger"; 76 | c.RoutePrefix = string.Empty; 77 | c.SwaggerEndpoint($"{builder.Configuration.GetSwaggerBase()}/swagger/v1/swagger.json", "v1"); 78 | }); 79 | } 80 | 81 | app.Run(); 82 | 83 | /// 84 | /// We need this to differentiate between the APis in the test 85 | /// 86 | // ReSharper disable once ClassNeverInstantiated.Global 87 | internal class ApiProgram : Program {} -------------------------------------------------------------------------------- /Backend/Api/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "Api": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": false, 8 | "applicationUrl": "http://localhost:48936", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Backend/Api/Routes/AuthRouteConfiguration.cs: -------------------------------------------------------------------------------- 1 | using Api.Apis; 2 | using Asp.Versioning; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace Api.Routes; 6 | 7 | public static class AuthRouteConfiguration 8 | { 9 | public static void MapAuthRoutes(this IEndpointRouteBuilder app) 10 | { 11 | var versionSet = app.NewApiVersionSet() 12 | .HasApiVersion(new ApiVersion(1)) 13 | .ReportApiVersions() 14 | .Build(); 15 | 16 | const string routePrefix = "/api"; 17 | 18 | app.MapGet($"{routePrefix}/v{{version:apiVersion}}/login", 19 | ([FromServices] IAuthApi api, string username, string password) => 20 | api.Login(username, password)) 21 | .Produces() 22 | .AddDefaultMappingBehaviour(versionSet, "Auth"); 23 | } 24 | } -------------------------------------------------------------------------------- /Backend/Api/Routes/WeatherForecastRouteConfiguration.cs: -------------------------------------------------------------------------------- 1 | using Api.Apis; 2 | using Asp.Versioning; 3 | using Entities.DataContract; 4 | using Microsoft.AspNetCore.Mvc; 5 | 6 | namespace Api.Routes; 7 | 8 | public static class WeatherForecastRouteConfiguration 9 | { 10 | public static void MapWeatherForecastRoutes(this IEndpointRouteBuilder app) 11 | { 12 | var versionSet = app.NewApiVersionSet() 13 | .HasApiVersion(new ApiVersion(1)) 14 | .ReportApiVersions() 15 | .Build(); 16 | 17 | const string routePrefix = "/api"; 18 | 19 | app.MapDelete($"{routePrefix}/WeatherForecasts/v{{version:apiVersion}}/{{id:int}}", 20 | ([FromServices] IWeatherForecastApi api, int id, HttpRequest httpRequest) => 21 | api.DeleteWeatherEntry(id, httpRequest)) 22 | .Produces(StatusCodes.Status204NoContent) 23 | .AddDefaultMappingBehaviour(versionSet, "WeatherForecast"); 24 | 25 | app.MapPut($"{routePrefix}/WeatherForecasts/v{{version:apiVersion}}/{{id:int}}", 26 | ([FromServices] IWeatherForecastApi api, int id, 27 | WeatherEntry weatherEntry, HttpRequest httpRequest 28 | ) => api.UpdateWeatherEntry(id, weatherEntry, httpRequest)) 29 | .Produces(StatusCodes.Status204NoContent) 30 | .AddDefaultMappingBehaviour(versionSet, "WeatherForecast"); 31 | 32 | app.MapPost($"{routePrefix}/WeatherForecasts/v{{version:apiVersion}}", 33 | ([FromServices] IWeatherForecastApi api, WeatherEntry weatherForecast, 34 | HttpRequest httpRequest) => 35 | api.CreateWeatherEntry(weatherForecast, httpRequest)) 36 | .Produces(StatusCodes.Status204NoContent) 37 | .AddDefaultMappingBehaviour(versionSet, "WeatherForecast"); 38 | 39 | app.MapGet($"{routePrefix}/WeatherForecasts/v{{version:apiVersion}}/{{id:int}}", 40 | ([FromServices] IWeatherForecastApi api, int id, HttpRequest httpRequest) => 41 | api.GetWeatherEntry(id, httpRequest)) 42 | .Produces() 43 | .AddDefaultMappingBehaviour(versionSet, "WeatherForecast"); 44 | 45 | app.MapGet($"{routePrefix}/WeatherForecasts/v{{version:apiVersion}}", 46 | ([FromServices] IWeatherForecastApi api, HttpRequest httpRequest) => 47 | api.GetWeatherEntries(httpRequest)) 48 | .Produces>() 49 | .AddDefaultMappingBehaviour(versionSet, "WeatherForecast"); 50 | } 51 | } -------------------------------------------------------------------------------- /Backend/Api/Validation/WeatherEntryValidator.cs: -------------------------------------------------------------------------------- 1 | using Entities.DataContract; 2 | using FluentValidation; 3 | 4 | namespace Api.Validation; 5 | 6 | // ReSharper disable once ClassNeverInstantiated.Global - Used internally by FluentValidation 7 | public class WeatherEntryValidator : AbstractValidator 8 | { 9 | public WeatherEntryValidator() 10 | { 11 | RuleFor(weatherEntry => weatherEntry.ObservedTime) 12 | .InclusiveBetween(new DateTime(1900, 1, 1), DateTime.Now); 13 | 14 | RuleFor(weatherEntry => weatherEntry.TemperatureType) 15 | .IsInEnum(); 16 | } 17 | } -------------------------------------------------------------------------------- /Backend/Api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "File": { 9 | "Path": "../../logs/Api/app.log", 10 | "Append": true, 11 | "MinLevel": "Debug", 12 | "FileSizeLimitBytes": 10000000 13 | }, 14 | "Secret" : "aGFycmUgaGF4eGFyIGVuIGd1bGxpZyBzZWNyZXQ", 15 | "AllowedHosts": "*", 16 | "SwaggerBase": "http://localhost:48936" 17 | } 18 | -------------------------------------------------------------------------------- /Backend/ApiClassic/ApiClassic.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | <_Parameter1>Tests 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /Backend/ApiClassic/ApiResult.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Net.Mime; 3 | using Infrastructure.Extensions; 4 | using Microsoft.AspNetCore.Mvc; 5 | 6 | namespace ApiClassic; 7 | 8 | public class ApiResult : IActionResult 9 | { 10 | private readonly object _value; 11 | private readonly HttpStatusCode _httpStatusCode; 12 | public ApiResult(object value, HttpStatusCode httpStatusCode) 13 | { 14 | _value = value; 15 | _httpStatusCode = httpStatusCode; 16 | } 17 | 18 | public async Task ExecuteResultAsync(ActionContext context) 19 | { 20 | context.HttpContext.Response.StatusCode = (int)_httpStatusCode; 21 | context.HttpContext.Response.ContentType = MediaTypeNames.Application.Json; 22 | await context.HttpContext.Response.WriteAsync(_value.ToJson(true)); 23 | } 24 | } -------------------------------------------------------------------------------- /Backend/ApiClassic/Controllers/AuthController.cs: -------------------------------------------------------------------------------- 1 | using Infrastructure.DataService; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace ApiClassic.Controllers; 5 | 6 | [ApiController] 7 | public class AuthController : BaseController 8 | { 9 | private readonly IJwtGenerator _jwtGenerator; 10 | 11 | public AuthController(ILogger logger, IUserService userService, IJwtGenerator jwtGenerator) : base(logger, userService) 12 | { 13 | _jwtGenerator = jwtGenerator; 14 | } 15 | 16 | [HttpGet("/api/v1/login")] 17 | public IActionResult Login(string username, string password) 18 | { 19 | return UserService.TryLogin(username, password) ? 20 | Ok(_jwtGenerator.GetJwt(username)) : 21 | Unauthorized(); 22 | } 23 | } -------------------------------------------------------------------------------- /Backend/ApiClassic/Controllers/BaseController.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Net; 3 | using System.Runtime.CompilerServices; 4 | using Entities.DataContract.ErrorResponse; 5 | using Entities.Enums; 6 | using Entities.Exceptions; 7 | using Humanizer; 8 | using Infrastructure.DataService; 9 | using Infrastructure.Extensions; 10 | using Microsoft.AspNetCore.Mvc; 11 | 12 | namespace ApiClassic.Controllers; 13 | 14 | public abstract class BaseController : ControllerBase 15 | { 16 | private readonly ILogger _logger; 17 | protected readonly IUserService UserService; 18 | 19 | protected BaseController(ILogger logger, IUserService userService) 20 | { 21 | _logger = logger; 22 | UserService = userService; 23 | } 24 | 25 | protected IActionResult HandleRequest(Func func, 26 | HttpRequest httpRequest, Role requestedRole, [CallerMemberName] string memberName = "") 27 | { 28 | var stopwatch = new Stopwatch(); 29 | stopwatch.Start(); 30 | 31 | var username = GetUsername(httpRequest); 32 | _logger.LogDebug( 33 | "User: {User}, started processing of {ClassName}.{MemberName}", username, GetType().Name, memberName); 34 | 35 | object? response = null; 36 | try 37 | { 38 | AssertRoleMembership(username, requestedRole); 39 | 40 | return func.Invoke(); 41 | } 42 | catch (ApiCallException apiCallException) 43 | { 44 | _logger.LogError("\n{ApiCallException}", apiCallException.ToJson(true)); 45 | 46 | if ((int)apiCallException.HttpStatusCode is >= 401 and <= 499) 47 | { 48 | response = new ClientErrorResponse(apiCallException, httpRequest.Path); 49 | } 50 | else 51 | { 52 | response = new InternalServerErrorResponse(apiCallException); 53 | } 54 | 55 | return new ApiResult(response, apiCallException.HttpStatusCode); 56 | } 57 | catch (Exception exception) 58 | { 59 | _logger.LogError("{Exception}", exception.ToString()); 60 | response = new InternalServerErrorResponse(exception); 61 | return new ApiResult(response, HttpStatusCode.InternalServerError); 62 | } 63 | finally 64 | { 65 | _logger.LogDebug("{FinalResponse}", response?.ToJson(true)); 66 | _logger.LogDebug("Finished processing of {ClassName}.{MemberName}, {ElapsedTime}", GetType().Name, 67 | memberName, stopwatch.Elapsed.Humanize()); 68 | } 69 | } 70 | 71 | private void AssertRoleMembership(string username, Role requestedRole) 72 | { 73 | var roles = UserService.GetRolesForUser(username).ToList(); 74 | if (!roles.Contains(requestedRole)) 75 | { 76 | throw new ApiCallException($"User {username} are missing required role {requestedRole}, having {roles}", 77 | HttpStatusCode.Forbidden); 78 | } 79 | } 80 | 81 | private static string GetUsername(HttpRequest httpRequest) 82 | { 83 | return httpRequest.HttpContext.User.Claims.Single(x => x.Type == "username").Value; 84 | } 85 | } -------------------------------------------------------------------------------- /Backend/ApiClassic/Controllers/WeatherForecastsController.cs: -------------------------------------------------------------------------------- 1 | using ApiClassic.Extensions; 2 | using Entities.DataContract; 3 | using Entities.Enums; 4 | using FluentValidation; 5 | using Infrastructure.DataService; 6 | using Microsoft.AspNetCore.Mvc; 7 | 8 | namespace ApiClassic.Controllers; 9 | 10 | [ApiController] 11 | [Route("/api/[controller]/v1/")] 12 | public class WeatherForecastsController : BaseController 13 | { 14 | private readonly IWeatherService _weatherService; 15 | private readonly IValidator _weatherEntryValidator; 16 | 17 | public WeatherForecastsController(ILogger logger, IWeatherService weatherService, 18 | IValidator weatherEntryValidator, IUserService userService) : base(logger, userService) 19 | { 20 | _weatherService = weatherService; 21 | _weatherEntryValidator = weatherEntryValidator; 22 | } 23 | 24 | [HttpDelete("{id:int}")] 25 | public IActionResult DeleteWeatherEntry(int id) 26 | { 27 | return HandleRequest( 28 | () => 29 | { 30 | _weatherService.DeleteWeatherEntry(id); 31 | return NoContent(); 32 | }, Request, Role.Write); 33 | } 34 | 35 | [HttpPost] 36 | public IActionResult CreateWeatherEntry(WeatherEntry weatherEntry) 37 | { 38 | return HandleRequest( 39 | () => 40 | { 41 | var validationResult = _weatherEntryValidator.Validate(weatherEntry); 42 | if (!validationResult.IsValid) return validationResult.ToValidationProblem(); 43 | 44 | var id = _weatherService.CreateWeatherEntry(weatherEntry); 45 | return Ok(new WeatherEntryResponse(id, weatherEntry)); 46 | }, Request, Role.Write); 47 | } 48 | 49 | [HttpGet("{id:int}")] 50 | public IActionResult GetWeatherEntry(int id) 51 | { 52 | return HandleRequest( 53 | () => 54 | { 55 | var response = _weatherService.GetWeatherEntry(id); 56 | return Ok(response); 57 | }, Request, Role.Read); 58 | } 59 | 60 | [HttpGet] 61 | public IActionResult GetWeatherEntries() 62 | { 63 | return HandleRequest( 64 | () => 65 | { 66 | var weatherEntries = _weatherService.GetWeatherEntries(); 67 | return Ok(weatherEntries); 68 | }, Request, Role.Read); 69 | } 70 | 71 | [HttpPut("{id:int}")] 72 | public IActionResult UpdateWeatherEntry(int id, WeatherEntry weatherEntry) 73 | { 74 | return HandleRequest( 75 | () => 76 | { 77 | var validationResult = _weatherEntryValidator.Validate(weatherEntry); 78 | if (!validationResult.IsValid) return validationResult.ToValidationProblem(); 79 | 80 | _weatherService.UpdateWeatherEntry(id, weatherEntry); 81 | return NoContent(); 82 | }, Request, Role.Write); 83 | } 84 | } -------------------------------------------------------------------------------- /Backend/ApiClassic/Extensions/ApplicationBuilderExtension.cs: -------------------------------------------------------------------------------- 1 | using ApiClassic.Middleware; 2 | 3 | namespace ApiClassic.Extensions; 4 | 5 | public static class ApplicationBuilderExtension 6 | { 7 | public static IApplicationBuilder UseJwtAuthentication( 8 | this IApplicationBuilder builder) 9 | { 10 | return builder.UseMiddleware(); 11 | } 12 | } -------------------------------------------------------------------------------- /Backend/ApiClassic/Extensions/ConfigurationExtension.cs: -------------------------------------------------------------------------------- 1 | namespace ApiClassic.Extensions; 2 | 3 | public static class ConfigurationExtension 4 | { 5 | public static string GetSwaggerBase(this IConfiguration configuration) 6 | { 7 | return configuration.GetValue("SwaggerBase") ?? throw new Exception("SwaggerBase must be set"); 8 | } 9 | 10 | public static string GetSecret(this IConfiguration configuration) 11 | { 12 | return configuration.GetValue("Secret") ?? throw new Exception("Secret must be set"); 13 | } 14 | } -------------------------------------------------------------------------------- /Backend/ApiClassic/Extensions/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography; 2 | using Infrastructure.DataAccess; 3 | using Infrastructure.DataService; 4 | using JWT; 5 | using JWT.Algorithms; 6 | using JWT.Extensions.AspNetCore.Factories; 7 | 8 | namespace ApiClassic.Extensions; 9 | 10 | public static class ServiceCollectionExtensions 11 | { 12 | public static void AddDependencyInjection(this IServiceCollection services) 13 | { 14 | services.AddTransient(); 15 | services.AddTransient(); 16 | services.AddTransient(); 17 | services.AddSingleton(_ => ECDsa.Create()); 18 | 19 | services.AddJwtDependencies(); 20 | 21 | services.AddSingleton(); 22 | services.AddSingleton(); 23 | } 24 | 25 | private static void AddJwtDependencies(this IServiceCollection services) 26 | { 27 | services.AddSingleton(); 28 | services.AddSingleton(); 29 | services.AddSingleton(); 30 | services.AddSingleton(); 31 | } 32 | } -------------------------------------------------------------------------------- /Backend/ApiClassic/Extensions/ValidationResultExtension.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation.Results; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace ApiClassic.Extensions; 5 | 6 | public static class ValidationResultExtension 7 | { 8 | public static IActionResult ToValidationProblem(this ValidationResult validationResult) 9 | { 10 | return new BadRequestObjectResult(validationResult.Errors 11 | .ToLookup(key => key.PropertyName, value => value.ErrorMessage) 12 | .ToDictionary(key => key.Key, value => value.ToArray())); 13 | } 14 | } -------------------------------------------------------------------------------- /Backend/ApiClassic/Middleware/JwtAuthentication.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication; 2 | 3 | namespace ApiClassic.Middleware; 4 | 5 | public class JwtAuthentication 6 | { 7 | private readonly RequestDelegate _next; 8 | 9 | public JwtAuthentication(RequestDelegate next) 10 | { 11 | _next = next; 12 | } 13 | 14 | public async Task InvokeAsync(HttpContext httpContext) 15 | { 16 | var authenticationResult = await httpContext.AuthenticateAsync(); 17 | if (authenticationResult.Succeeded) 18 | { 19 | await _next(httpContext); 20 | } 21 | else 22 | { 23 | if (httpContext.Request.Path.Equals("/api/v1/login") || 24 | !httpContext.Request.Path.StartsWithSegments("/api")) 25 | { 26 | await _next(httpContext); 27 | } 28 | else 29 | { 30 | await httpContext.ChallengeAsync(); 31 | } 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /Backend/ApiClassic/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using System.Text.Json.Serialization; 3 | using ApiClassic.Extensions; 4 | using ApiClassic.Validation; 5 | using FluentValidation.AspNetCore; 6 | using JWT.Extensions.AspNetCore; 7 | using Microsoft.Net.Http.Headers; 8 | using Microsoft.OpenApi.Models; 9 | using Newtonsoft.Json; 10 | using Newtonsoft.Json.Serialization; 11 | 12 | var builder = WebApplication.CreateBuilder(args); 13 | 14 | builder.Services.AddDependencyInjection(); 15 | 16 | builder.Services.AddControllers() 17 | .AddJsonOptions(options => 18 | { 19 | options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); 20 | options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; 21 | }); 22 | 23 | 24 | builder.Services.AddEndpointsApiExplorer(); 25 | builder.Services.AddSwaggerGen(options => 26 | { 27 | options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme() 28 | { 29 | Name = HeaderNames.Authorization, 30 | Type = SecuritySchemeType.ApiKey, 31 | Scheme = "Bearer", 32 | BearerFormat = "JWT", 33 | In = ParameterLocation.Header, 34 | Description = 35 | $"JWT Authorization header using the Bearer scheme. {Environment.NewLine}" + 36 | "Enter \"Bearer 'JWT'\" in the text input below." 37 | }); 38 | options.AddSecurityRequirement(new OpenApiSecurityRequirement 39 | { 40 | { 41 | new OpenApiSecurityScheme 42 | { 43 | Reference = new OpenApiReference 44 | { 45 | Type = ReferenceType.SecurityScheme, 46 | Id = "Bearer" 47 | } 48 | }, 49 | Array.Empty() 50 | } 51 | }); 52 | }); 53 | 54 | builder.Logging.SetMinimumLevel(LogLevel.Debug); 55 | builder.Logging.AddFile(builder.Configuration); 56 | 57 | builder.Services.AddFluentValidation(v => v.RegisterValidatorsFromAssemblyContaining()); 58 | 59 | JsonConvert.DefaultSettings = () => new JsonSerializerSettings 60 | { 61 | ContractResolver = new CamelCasePropertyNamesContractResolver(), 62 | Formatting = Formatting.None, 63 | NullValueHandling = NullValueHandling.Ignore 64 | }; 65 | 66 | builder.Services.AddAuthentication(options => 67 | { 68 | options.DefaultAuthenticateScheme = JwtAuthenticationDefaults.AuthenticationScheme; 69 | options.DefaultChallengeScheme = JwtAuthenticationDefaults.AuthenticationScheme; 70 | }) 71 | .AddJwt(options => 72 | { 73 | options.Keys = new[] { builder.Configuration.GetSecret() }; 74 | //options.Keys = null; 75 | options.VerifySignature = true; 76 | }); 77 | 78 | var app = builder.Build(); 79 | 80 | app.UseAuthentication(); 81 | app.UseJwtAuthentication(); 82 | 83 | if (app.Environment.IsDevelopment()) 84 | { 85 | app.UseSwagger(); 86 | app.UseSwaggerUI(c => 87 | { 88 | c.DocumentTitle = "WeatherService - Swagger"; 89 | c.RoutePrefix = string.Empty; 90 | c.SwaggerEndpoint($"{builder.Configuration.GetSwaggerBase()}/swagger/v1/swagger.json", "v1"); 91 | }); 92 | } 93 | 94 | app.MapControllers(); 95 | 96 | app.Run(); 97 | 98 | /// 99 | /// We need this to differentiate between the APis in the test 100 | /// 101 | // ReSharper disable once ClassNeverInstantiated.Global 102 | internal class ApiClassicProgram : Program {} -------------------------------------------------------------------------------- /Backend/ApiClassic/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "ApiClassic": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": false, 8 | "applicationUrl": "http://localhost:5194", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Backend/ApiClassic/Validation/WeatherEntryValidator.cs: -------------------------------------------------------------------------------- 1 | using Entities.DataContract; 2 | using FluentValidation; 3 | 4 | namespace ApiClassic.Validation; 5 | 6 | // ReSharper disable once ClassNeverInstantiated.Global - Used internally by FluentValidation 7 | public class WeatherEntryValidator : AbstractValidator 8 | { 9 | public WeatherEntryValidator() 10 | { 11 | RuleFor(weatherEntry => weatherEntry.ObservedTime) 12 | .InclusiveBetween(new DateTime(1900, 1, 1), DateTime.Now); 13 | 14 | RuleFor(weatherEntry => weatherEntry.TemperatureType) 15 | .IsInEnum(); 16 | } 17 | } -------------------------------------------------------------------------------- /Backend/ApiClassic/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "File": { 9 | "Path": "../../logs/ApiClassic/app.log", 10 | "Append": true, 11 | "MinLevel": "Debug", 12 | "FileSizeLimitBytes": 10000000 13 | }, 14 | "Secret" : "aGFycmUgaGF4eGFyIGVuIGd1bGxpZyBzZWNyZXQ", 15 | "AllowedHosts": "*", 16 | "SwaggerBase": "http://localhost:5194" 17 | } 18 | -------------------------------------------------------------------------------- /Backend/Entities/DataContract/ErrorResponse/BadRequestResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | 3 | namespace Entities.DataContract.ErrorResponse; 4 | 5 | // ReSharper disable once ClassNeverInstantiated.Global - Used for deserializing 6 | public class BadRequestResponse : BaseErrorResponse 7 | { 8 | public BadRequestResponse(Dictionary> errors) : base( 9 | "urn:weather:status:validationError:v1", "Validation error", HttpStatusCode.BadRequest) 10 | { 11 | Errors = errors; 12 | } 13 | 14 | public Dictionary> Errors { get; } 15 | } -------------------------------------------------------------------------------- /Backend/Entities/DataContract/ErrorResponse/BaseErrorResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace Entities.DataContract.ErrorResponse; 5 | 6 | public abstract class BaseErrorResponse 7 | { 8 | protected BaseErrorResponse(string type, string title, HttpStatusCode status) 9 | { 10 | Type = type; 11 | Title = title; 12 | Status = status; 13 | } 14 | 15 | protected BaseErrorResponse() 16 | { 17 | } 18 | 19 | [JsonInclude] 20 | public string Type { get; set; } 21 | 22 | [JsonInclude] 23 | public string Title { get; set; } 24 | 25 | [JsonInclude] 26 | public HttpStatusCode Status { get; set; } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /Backend/Entities/DataContract/ErrorResponse/ClientErrorResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text.Json.Serialization; 3 | using Entities.Exceptions; 4 | 5 | namespace Entities.DataContract.ErrorResponse; 6 | 7 | /// 8 | /// Covers HTTP-status codes 400-499 9 | /// 10 | public class ClientErrorResponse : BaseErrorResponse 11 | { 12 | 13 | // ReSharper disable once UnusedMember.Global - Used for deserializing 14 | public ClientErrorResponse() 15 | { 16 | } 17 | 18 | public ClientErrorResponse(HttpStatusCode status, string detail, string instance) : base( 19 | "urn:weather:status:clientError:v1", 20 | "Client error", status) 21 | { 22 | Detail = detail; 23 | Instance = instance; 24 | } 25 | 26 | public ClientErrorResponse(ApiCallException apiCallException, string instance) : this(apiCallException.HttpStatusCode, 27 | apiCallException.ToString(), instance) 28 | { 29 | } 30 | 31 | [JsonInclude] 32 | public string Detail { get; set; } 33 | 34 | [JsonInclude] 35 | public string Instance { get; set; } 36 | } -------------------------------------------------------------------------------- /Backend/Entities/DataContract/ErrorResponse/ErrorDetail.cs: -------------------------------------------------------------------------------- 1 | namespace Entities.DataContract.ErrorResponse; 2 | 3 | public class ErrorDetail 4 | { 5 | public ErrorDetail(Exception exception) 6 | { 7 | Message = exception.Message; 8 | StackTrace = exception.StackTrace; 9 | FullDetails = exception.ToString(); 10 | } 11 | 12 | public string Message { get; } 13 | public string StackTrace { get; } 14 | public string FullDetails { get; } 15 | } -------------------------------------------------------------------------------- /Backend/Entities/DataContract/ErrorResponse/InternalServerErrorResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace Entities.DataContract.ErrorResponse; 5 | 6 | public class InternalServerErrorResponse : BaseErrorResponse 7 | { 8 | public InternalServerErrorResponse(Exception exception) : base("urn:weather:status:serviceError:v1", 9 | "Service error", HttpStatusCode.InternalServerError) 10 | { 11 | ErrorDetail = new ErrorDetail(exception); 12 | } 13 | 14 | [JsonInclude] 15 | public ErrorDetail ErrorDetail { get; } 16 | } -------------------------------------------------------------------------------- /Backend/Entities/DataContract/WeatherEntry.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using Entities.Enums; 3 | 4 | namespace Entities.DataContract; 5 | 6 | public record WeatherEntry(decimal Temperature, DateTime ObservedTime, TemperatureType TemperatureType) 7 | { 8 | [JsonInclude] 9 | public decimal Temperature { get; set; } = Temperature; 10 | 11 | [JsonInclude] 12 | public DateTime ObservedTime { get; set; } = ObservedTime; 13 | 14 | [JsonInclude] 15 | public TemperatureType TemperatureType { get; set; } = TemperatureType; 16 | 17 | [JsonInclude] 18 | public decimal TemperatureAsCelsius => GetTemperature(TemperatureType.Celsius, TemperatureType, Temperature); 19 | [JsonInclude] 20 | public decimal TemperatureAsFahrenheit => GetTemperature(TemperatureType.Fahrenheit, TemperatureType, Temperature); 21 | [JsonInclude] 22 | public decimal TemperatureAsKelvin => GetTemperature(TemperatureType.Kelvin, TemperatureType, Temperature); 23 | 24 | private static decimal GetTemperature(TemperatureType wanted, TemperatureType actual, decimal temperature) 25 | { 26 | switch (actual) 27 | { 28 | case TemperatureType.Celsius: 29 | return wanted switch 30 | { 31 | TemperatureType.Celsius => temperature, 32 | TemperatureType.Fahrenheit => temperature * 1.8m + 32, 33 | _ => temperature - 273.15m 34 | }; 35 | case TemperatureType.Fahrenheit: 36 | return wanted switch 37 | { 38 | TemperatureType.Fahrenheit => temperature, 39 | TemperatureType.Celsius => (temperature - 32) / 1.8m, 40 | _ => temperature - (temperature + 459.67m) * (5m/9m) 41 | }; 42 | default: // Kelvin 43 | return wanted switch 44 | { 45 | TemperatureType.Kelvin => temperature, 46 | TemperatureType.Celsius => temperature - 273.15m, 47 | _ => temperature - temperature * (9m/5m) - 459.67m 48 | }; 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /Backend/Entities/DataContract/WeatherEntryResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Entities.DataContract; 4 | 5 | public record WeatherEntryResponse : WeatherEntry 6 | { 7 | public WeatherEntryResponse(int id, WeatherEntry weatherEntry) : base(weatherEntry.Temperature, weatherEntry.ObservedTime, weatherEntry.TemperatureType) 8 | { 9 | Id = id; 10 | Temperature = weatherEntry.Temperature; 11 | ObservedTime = weatherEntry.ObservedTime; 12 | TemperatureType = weatherEntry.TemperatureType; 13 | } 14 | 15 | [JsonInclude] public int Id { get; set; } 16 | } -------------------------------------------------------------------------------- /Backend/Entities/Entities.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | disable 7 | 8 | 9 | 10 | true 11 | 12 | 13 | 14 | true 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /Backend/Entities/Enums/Role.cs: -------------------------------------------------------------------------------- 1 | namespace Entities.Enums; 2 | 3 | public enum Role 4 | { 5 | None = 0, 6 | Read = 1, 7 | Write = 2 8 | } -------------------------------------------------------------------------------- /Backend/Entities/Enums/TemperatureType.cs: -------------------------------------------------------------------------------- 1 | namespace Entities.Enums; 2 | 3 | public enum TemperatureType 4 | { 5 | Celsius = 0, 6 | Fahrenheit = 1, 7 | Kelvin = 2 8 | } -------------------------------------------------------------------------------- /Backend/Entities/Exceptions/ApiCallException.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | 3 | namespace Entities.Exceptions; 4 | 5 | public class ApiCallException : Exception 6 | { 7 | public ApiCallException(string message, HttpStatusCode httpStatusCode) : base(message) 8 | { 9 | HttpStatusCode = httpStatusCode; 10 | } 11 | 12 | public HttpStatusCode HttpStatusCode { get; } 13 | } -------------------------------------------------------------------------------- /Backend/Entities/Jwt/WeatherForcastJwtPayload.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Entities.Jwt; 4 | 5 | public record WeatherServiceJwtPayload 6 | { 7 | [JsonInclude] 8 | public string Username { get; set; } 9 | 10 | [JsonInclude] 11 | public List Roles { get; set; } 12 | } -------------------------------------------------------------------------------- /Backend/Infrastructure/DataAccess/IUserRepository.cs: -------------------------------------------------------------------------------- 1 | using Entities.Enums; 2 | 3 | namespace Infrastructure.DataAccess; 4 | 5 | public interface IUserRepository 6 | { 7 | IEnumerable GetRolesForUser(string username); 8 | bool TryLogin(string username, string password); 9 | } -------------------------------------------------------------------------------- /Backend/Infrastructure/DataAccess/IWeatherRepository.cs: -------------------------------------------------------------------------------- 1 | using Entities.DataContract; 2 | 3 | namespace Infrastructure.DataAccess; 4 | 5 | public interface IWeatherRepository 6 | { 7 | WeatherEntry Get(int id); 8 | void Delete(int id); 9 | int Create(WeatherEntry weatherEntry); 10 | IEnumerable GetAll(); 11 | void Update(int id, WeatherEntry weatherEntry); 12 | } -------------------------------------------------------------------------------- /Backend/Infrastructure/DataAccess/UserRepository.cs: -------------------------------------------------------------------------------- 1 | using Entities.Enums; 2 | 3 | namespace Infrastructure.DataAccess; 4 | 5 | public class UserRepository : IUserRepository 6 | { 7 | private readonly Dictionary _usersAndPasswords = new() 8 | { 9 | { "harre", "errah" }, 10 | { "noob", "password" } 11 | }; 12 | 13 | public IEnumerable GetRolesForUser(string username) 14 | { 15 | switch (username) 16 | { 17 | case "harre": 18 | yield return Role.Write; 19 | yield return Role.Read; 20 | yield break; 21 | case "noob": 22 | yield return Role.Read; 23 | yield break; 24 | default: 25 | yield return Role.None; 26 | yield break; 27 | } 28 | } 29 | 30 | public bool TryLogin(string username, string password) 31 | { 32 | return _usersAndPasswords.ContainsKey(username) && _usersAndPasswords[username].Equals(password); 33 | } 34 | } -------------------------------------------------------------------------------- /Backend/Infrastructure/DataAccess/WeatherRepository.cs: -------------------------------------------------------------------------------- 1 | using Entities.DataContract; 2 | 3 | namespace Infrastructure.DataAccess; 4 | 5 | public class WeatherRepository : IWeatherRepository 6 | { 7 | private readonly Dictionary _weatherEntries = new(); 8 | 9 | public WeatherEntry Get(int id) 10 | { 11 | return _weatherEntries.ContainsKey(id) ? _weatherEntries[id] : null; 12 | } 13 | 14 | public void Delete(int id) 15 | { 16 | _weatherEntries.Remove(id); 17 | } 18 | 19 | public int Create(WeatherEntry weatherEntry) 20 | { 21 | var id = _weatherEntries.Keys.Any() ? _weatherEntries.Keys.Max() + 1 : 0; 22 | _weatherEntries.Add(id, weatherEntry); 23 | return id; 24 | } 25 | 26 | public IEnumerable GetAll() 27 | { 28 | return _weatherEntries.Values; 29 | } 30 | 31 | public void Update(int id, WeatherEntry weatherEntry) 32 | { 33 | _weatherEntries[id] = weatherEntry; 34 | } 35 | } -------------------------------------------------------------------------------- /Backend/Infrastructure/DataService/IJwtGenerator.cs: -------------------------------------------------------------------------------- 1 | namespace Infrastructure.DataService; 2 | 3 | public interface IJwtGenerator 4 | { 5 | string GetJwt(string username); 6 | } -------------------------------------------------------------------------------- /Backend/Infrastructure/DataService/IUserService.cs: -------------------------------------------------------------------------------- 1 | using Entities.Enums; 2 | 3 | namespace Infrastructure.DataService; 4 | 5 | public interface IUserService 6 | { 7 | IEnumerable GetRolesForUser(string username); 8 | bool TryLogin(string username, string password); 9 | } -------------------------------------------------------------------------------- /Backend/Infrastructure/DataService/IWeatherService.cs: -------------------------------------------------------------------------------- 1 | using Entities.DataContract; 2 | 3 | namespace Infrastructure.DataService; 4 | 5 | public interface IWeatherService 6 | { 7 | void DeleteWeatherEntry(int id); 8 | int CreateWeatherEntry(WeatherEntry weatherEntry); 9 | WeatherEntry GetWeatherEntry(int id); 10 | IEnumerable GetWeatherEntries(); 11 | void UpdateWeatherEntry(int id, WeatherEntry weatherEntry); 12 | } -------------------------------------------------------------------------------- /Backend/Infrastructure/DataService/JwtGenerator.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography; 2 | using JWT.Algorithms; 3 | using JWT.Builder; 4 | using Microsoft.Extensions.Configuration; 5 | 6 | namespace Infrastructure.DataService; 7 | 8 | public class JwtGenerator : IJwtGenerator 9 | { 10 | private readonly ECDsa _publicKey; 11 | private readonly ECDsa _privateKey; 12 | private readonly string _secret; 13 | 14 | public JwtGenerator(IConfiguration configuration, ECDsa publicKey, ECDsa privateKey) 15 | { 16 | _publicKey = publicKey; 17 | _privateKey = privateKey; 18 | _secret = configuration["Secret"] ?? throw new Exception("Secret missing in configuration"); 19 | } 20 | 21 | public string GetJwt(string username) 22 | { 23 | var token = JwtBuilder.Create() 24 | .WithAlgorithm(new ES512Algorithm(_publicKey, _privateKey)) 25 | .WithSecret(_secret); 26 | token.AddClaim("Username", username); 27 | 28 | return token.Encode(); 29 | } 30 | } -------------------------------------------------------------------------------- /Backend/Infrastructure/DataService/UserService.cs: -------------------------------------------------------------------------------- 1 | using Entities.Enums; 2 | using Infrastructure.DataAccess; 3 | 4 | namespace Infrastructure.DataService; 5 | 6 | public class UserService : IUserService 7 | { 8 | private readonly IUserRepository _userRepository; 9 | 10 | public UserService(IUserRepository userRepository) 11 | { 12 | _userRepository = userRepository; 13 | } 14 | 15 | public IEnumerable GetRolesForUser(string username) 16 | { 17 | return _userRepository.GetRolesForUser(username); 18 | } 19 | 20 | public bool TryLogin(string username, string password) 21 | { 22 | return _userRepository.TryLogin(username, password); 23 | } 24 | } -------------------------------------------------------------------------------- /Backend/Infrastructure/DataService/WeatherService.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using Entities.DataContract; 3 | using Entities.Exceptions; 4 | using Infrastructure.DataAccess; 5 | 6 | namespace Infrastructure.DataService; 7 | 8 | public class WeatherService : IWeatherService 9 | { 10 | private readonly IWeatherRepository _weatherRepository; 11 | 12 | public WeatherService(IWeatherRepository weatherRepository) 13 | { 14 | _weatherRepository = weatherRepository; 15 | } 16 | 17 | public void DeleteWeatherEntry(int id) 18 | { 19 | 20 | if (_weatherRepository.Get(id) == null) 21 | { 22 | throw new ApiCallException($"Weather entry not found, id: {id}", HttpStatusCode.NotFound); 23 | } 24 | 25 | _weatherRepository.Delete(id); 26 | } 27 | 28 | public int CreateWeatherEntry(WeatherEntry weatherEntry) 29 | { 30 | return _weatherRepository.Create(weatherEntry); 31 | } 32 | 33 | public WeatherEntry GetWeatherEntry(int id) 34 | { 35 | var weatherEntry = _weatherRepository.Get(id); 36 | if (weatherEntry == null) 37 | { 38 | throw new ApiCallException($"Weather entry not found, id: {id}", HttpStatusCode.NotFound); 39 | } 40 | 41 | return weatherEntry; 42 | } 43 | 44 | public IEnumerable GetWeatherEntries() 45 | { 46 | return _weatherRepository.GetAll(); 47 | } 48 | 49 | public void UpdateWeatherEntry(int id, WeatherEntry weatherEntry) 50 | { 51 | if (_weatherRepository.Get(id) == null) 52 | { 53 | throw new ApiCallException($"Weather entry not found, id: {id}", HttpStatusCode.NotFound); 54 | } 55 | 56 | _weatherRepository.Update(id, weatherEntry); 57 | } 58 | } -------------------------------------------------------------------------------- /Backend/Infrastructure/Extensions/ObjectExtensions.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using Newtonsoft.Json.Converters; 3 | using Newtonsoft.Json.Serialization; 4 | 5 | namespace Infrastructure.Extensions; 6 | 7 | public static class ObjectExtensions 8 | { 9 | public static string ToJson(this object value, bool indented = false) 10 | { 11 | return JsonConvert 12 | .SerializeObject( 13 | value, 14 | (indented ? Formatting.Indented : Formatting.None), 15 | new JsonSerializerSettings() 16 | { 17 | ContractResolver = new CamelCasePropertyNamesContractResolver(), 18 | Converters = { new StringEnumConverter() } 19 | }); 20 | } 21 | } -------------------------------------------------------------------------------- /Backend/Infrastructure/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using Newtonsoft.Json.Converters; 3 | using Newtonsoft.Json.Serialization; 4 | 5 | namespace Infrastructure.Extensions; 6 | 7 | public static class StringExtensions 8 | { 9 | public static T ToObject(this string json) 10 | { 11 | return JsonConvert 12 | .DeserializeObject( 13 | json, 14 | new JsonSerializerSettings 15 | { 16 | ContractResolver = new CamelCasePropertyNamesContractResolver(), 17 | Converters = { new StringEnumConverter() } 18 | }); 19 | } 20 | } -------------------------------------------------------------------------------- /Backend/Infrastructure/Infrastructure.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | enable 6 | disable 7 | 8 | 9 | 10 | true 11 | 12 | 13 | 14 | true 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ..\..\..\..\..\..\usr\share\dotnet\shared\Microsoft.AspNetCore.App\6.0.2\Microsoft.Extensions.Configuration.Abstractions.dll 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Markus Hartung 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | Simple demo service that uses minimal API. I felt that all demos I found online was a bit lacking on the real-world 3 | usage of minimal API and how to structure it. 4 | 5 | The application is meant to showcase how to setup a simple service using minimal API. 6 | 7 | It uses JWT for authenticating the calls and FluentValidation for validating requests. 8 | 9 | All operations are tested with xunit-tests using testserver. 10 | 11 | The main reason of why to post this repo was to have a more complete solution that showcases how to successfully use 12 | minimal API in your solution. 13 | 14 | # Structure 15 | The service showcases a simple "login" endpoint to get a valid JWT. The users are for now hardcoded in the 16 | UserRepository class. 17 | 18 | Note that the keys for the signing the JWT is generated on startup so old tokens from an old run won't work. 19 | 20 | There are two roles and the user "harre" has write permissions so all operations that does writing, and "noob" has 21 | only read permissions. 22 | 23 | The service persist the data just in memory and is gone when the server restarts. 24 | 25 | -------------------------------------------------------------------------------- /Tests/ApiTests/AuthApiTest.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Threading.Tasks; 3 | using Tests.Extensions; 4 | using Xunit; 5 | using Xunit.Abstractions; 6 | 7 | namespace Tests.ApiTests; 8 | 9 | [Trait("Category", "CI")] 10 | public class AuthApiTest 11 | { 12 | private readonly ITestOutputHelper _testOutputHelper; 13 | 14 | public AuthApiTest(ITestOutputHelper testOutputHelper) 15 | { 16 | _testOutputHelper = testOutputHelper; 17 | } 18 | 19 | [Theory] 20 | [ClassData(typeof(TestDataGenerator))] 21 | public async Task Login_WhenMissingUsernameAndPassword_ShouldReturnBadRequest(TestHost testHost) 22 | { 23 | var (_, statusCode, _) = await testHost.HttpClient.GetExtendedAsync( 24 | $"/api/v1/login", jwt: null); 25 | 26 | Assert.Equal(HttpStatusCode.BadRequest, statusCode); 27 | } 28 | 29 | [Theory] 30 | [ClassData(typeof(TestDataGenerator))] 31 | public async Task Login_WhenMissingUsername_ShouldReturnBadRequest(TestHost testHost) 32 | { 33 | const string password = "foo"; 34 | var (_, statusCode, _) = await testHost.HttpClient.GetExtendedAsync( 35 | $"/api/v1/login?password={password}", jwt: null); 36 | 37 | Assert.Equal(HttpStatusCode.BadRequest, statusCode); 38 | } 39 | 40 | [Theory] 41 | [ClassData(typeof(TestDataGenerator))] 42 | public async Task Login_WhenMissingPassword_ShouldReturnBadRequest(TestHost testHost) 43 | { 44 | const string username = "foo"; 45 | var (_, statusCode, _) = await testHost.HttpClient.GetExtendedAsync( 46 | $"/api/v1/login?username={username}", jwt: null); 47 | 48 | Assert.Equal(HttpStatusCode.BadRequest, statusCode); 49 | } 50 | 51 | [Theory] 52 | [ClassData(typeof(TestDataGenerator))] 53 | public async Task Login_WhenUsingInvalidUser_ShouldReturnUnauthorized(TestHost testHost) 54 | { 55 | const string username = "invalid"; 56 | const string password = "badpass"; 57 | var (content, statusCode, _) = await testHost.HttpClient.GetExtendedAsync( 58 | $"/api/v1/login?username={username}&password={password}", jwt: null); 59 | 60 | Assert.Equal(HttpStatusCode.Unauthorized, statusCode); 61 | _testOutputHelper.WriteLine(content); 62 | } 63 | 64 | [Theory] 65 | [ClassData(typeof(TestDataGenerator))] 66 | public async Task Login_WhenUsingValidUser_ShouldReturnJwt(TestHost testHost) 67 | { 68 | const string username = "harre"; 69 | const string password = "errah"; 70 | var (content, statusCode, _) = await testHost.HttpClient.GetExtendedAsync( 71 | $"/api/v1/login?username={username}&password={password}", jwt: null); 72 | 73 | Assert.Equal(HttpStatusCode.OK, statusCode); 74 | _testOutputHelper.WriteLine(content); 75 | Assert.NotNull(content); 76 | } 77 | } -------------------------------------------------------------------------------- /Tests/ApiTests/TestDataGenerator.cs: -------------------------------------------------------------------------------- 1 | using System.Collections; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Net.Http; 5 | using Infrastructure.DataAccess; 6 | using Microsoft.AspNetCore.Hosting; 7 | using Microsoft.AspNetCore.Mvc.Testing; 8 | using Microsoft.Extensions.Configuration; 9 | using Microsoft.Extensions.DependencyInjection; 10 | using Microsoft.Extensions.Logging; 11 | using NSubstitute; 12 | 13 | namespace Tests.ApiTests; 14 | 15 | public class TestDataGenerator : IEnumerable 16 | { 17 | 18 | public IEnumerator GetEnumerator() 19 | { 20 | var api = new WebApplicationFactory(); 21 | var weatherRepositoryMock = Substitute.For(); 22 | var client = GetHttpClientForProgram(api, weatherRepositoryMock); 23 | yield return new object[] { new TestHost("Api", client, weatherRepositoryMock) }; 24 | 25 | var apiClassic = new WebApplicationFactory(); 26 | weatherRepositoryMock = Substitute.For(); 27 | var clientClassic = GetHttpClientForProgram(apiClassic, weatherRepositoryMock); 28 | yield return new object[] { new TestHost("ApiClassic", clientClassic, weatherRepositoryMock) }; 29 | } 30 | 31 | IEnumerator IEnumerable.GetEnumerator() 32 | { 33 | return GetEnumerator(); 34 | } 35 | 36 | 37 | private HttpClient GetHttpClientForProgram( 38 | WebApplicationFactory program, 39 | IWeatherRepository weatherRepositoryMock) where TProgram : class 40 | { 41 | var host = program 42 | .WithWebHostBuilder(builder => 43 | { 44 | builder 45 | .UseContentRoot(Path.GetDirectoryName(GetType().Assembly.Location)!) 46 | .ConfigureAppConfiguration( 47 | configurationBuilder => 48 | configurationBuilder.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)) 49 | .ConfigureServices(services => 50 | { 51 | services.AddTransient(_ => weatherRepositoryMock); 52 | services.AddLogging(logging => logging.AddXUnit()); 53 | }); 54 | }); 55 | 56 | return host.CreateClient(); 57 | } 58 | } -------------------------------------------------------------------------------- /Tests/ApiTests/TestHost.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http; 2 | using Infrastructure.DataAccess; 3 | 4 | namespace Tests.ApiTests; 5 | 6 | public record TestHost(string Name, HttpClient HttpClient, IWeatherRepository WeatherRepositoryMock) 7 | { 8 | } -------------------------------------------------------------------------------- /Tests/ApiTests/WeatherForecastApiTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Linq; 4 | using System.Net; 5 | using System.Net.Http; 6 | using System.Threading.Tasks; 7 | using Entities.DataContract; 8 | using Entities.Enums; 9 | using Humanizer; 10 | using Infrastructure.Extensions; 11 | using NSubstitute; 12 | using Tests.Extensions; 13 | using Xunit; 14 | using Xunit.Abstractions; 15 | 16 | namespace Tests.ApiTests; 17 | 18 | [Trait("Category", "CI")] 19 | public class WeatherForecastApiTest 20 | { 21 | private readonly ITestOutputHelper _testOutputHelper; 22 | private const string UsernameWithWriteRole = "harre"; 23 | private const string PasswordWithWriteRole = "errah"; 24 | 25 | private const string UsernameWithReadRole = "noob"; 26 | private const string PasswordWithReadRole = "password"; 27 | 28 | public WeatherForecastApiTest(ITestOutputHelper testOutputHelper) 29 | { 30 | _testOutputHelper = testOutputHelper; 31 | } 32 | 33 | private static async Task GetJwt(HttpClient client, string username, string password) 34 | { 35 | var (content, statusCode, _) = await client.GetExtendedAsync( 36 | $"/api/v1/login?username={username}&password={password}", jwt: null); 37 | 38 | Assert.Equal(HttpStatusCode.OK, statusCode); 39 | Assert.NotNull(content); 40 | 41 | return content; 42 | } 43 | 44 | [Theory] 45 | [ClassData(typeof(TestDataGenerator))] 46 | public async Task Delete_ShouldReturnNotFound_WhenMissing(TestHost testHost) 47 | { 48 | const int id = 2; 49 | 50 | var jwt = await GetJwt(testHost.HttpClient, UsernameWithWriteRole, PasswordWithWriteRole); 51 | var (content, httpStatusCode) = 52 | await testHost.HttpClient.DeleteExtendedAsync($"/api/WeatherForecasts/v1/{id}", jwt); 53 | 54 | Assert.Equal(HttpStatusCode.NotFound, httpStatusCode); 55 | Assert.Contains($"Weather entry not found, id: {id}", content); 56 | } 57 | 58 | [Theory] 59 | [ClassData(typeof(TestDataGenerator))] 60 | public async Task Delete_ShouldReturnNoResponse_WhenSuccessful(TestHost testHost) 61 | { 62 | const int id = 2; 63 | 64 | var weatherEntry = new WeatherEntry(0, DateTime.Now.AddDays(1), TemperatureType.Celsius); 65 | testHost.WeatherRepositoryMock.Get(id).Returns(weatherEntry); 66 | 67 | var jwt = await GetJwt(testHost.HttpClient, UsernameWithWriteRole, PasswordWithWriteRole); 68 | var (content, httpStatusCode) = 69 | await testHost.HttpClient.DeleteExtendedAsync($"/api/WeatherForecasts/v1/{id}", jwt); 70 | 71 | Assert.Equal(HttpStatusCode.NoContent, httpStatusCode); 72 | Assert.Empty(content); 73 | } 74 | 75 | [Theory] 76 | [ClassData(typeof(TestDataGenerator))] 77 | public async Task Create_ShouldReturnBadRequest_WhenNewItemIsNotValid(TestHost testHost) 78 | { 79 | var request = new WeatherEntry(0, DateTime.Now.AddDays(1), (TemperatureType)42); 80 | 81 | var jwt = await GetJwt(testHost.HttpClient, UsernameWithWriteRole, PasswordWithWriteRole); 82 | var (content, httpStatusCode, badRequestResponse) = 83 | await testHost.HttpClient.PostExtendedAsync("/api/WeatherForecasts/v1", request, jwt); 84 | 85 | _testOutputHelper.WriteLine(content); 86 | 87 | Assert.Equal(HttpStatusCode.BadRequest, httpStatusCode); 88 | Assert.NotNull(badRequestResponse); 89 | 90 | Assert.Collection(badRequestResponse.Errors.Keys, 91 | x => Assert.Equal("ObservedTime", x), 92 | x => Assert.Equal("TemperatureType", x)); 93 | } 94 | 95 | [Theory] 96 | [ClassData(typeof(TestDataGenerator))] 97 | public async Task Create_ShouldReturnOk_WhenNewItemIsValid(TestHost testHost) 98 | { 99 | var request = new WeatherEntry(42, DateTime.Now.AddDays(-1), TemperatureType.Fahrenheit); 100 | 101 | var jwt = await GetJwt(testHost.HttpClient, UsernameWithWriteRole, PasswordWithWriteRole); 102 | var (content, httpStatusCode, badRequestResponse) = 103 | await testHost.HttpClient.PostExtendedAsync("/api/WeatherForecasts/v1", request, jwt); 104 | 105 | Assert.Equal(HttpStatusCode.OK, httpStatusCode); 106 | Assert.Null(badRequestResponse); 107 | Assert.NotNull(content); 108 | 109 | var weatherEntry = content.ToObject(); 110 | Assert.NotNull(weatherEntry); 111 | Assert.Equal(request.Temperature, weatherEntry.Temperature); 112 | Assert.Equal(request.ObservedTime, weatherEntry.ObservedTime); 113 | Assert.Equal(request.TemperatureType, weatherEntry.TemperatureType); 114 | } 115 | 116 | [Theory] 117 | [ClassData(typeof(TestDataGenerator))] 118 | public async Task Create_ShouldReturnForbidden_WhenUserLacksWriteRole(TestHost testHost) 119 | { 120 | var jwt = await GetJwt(testHost.HttpClient, UsernameWithReadRole, PasswordWithReadRole); 121 | var request = new WeatherEntry(0, DateTime.Now.AddDays(-1), TemperatureType.Celsius); 122 | 123 | var (content, httpStatusCode, _) = await testHost.HttpClient.PostExtendedAsync("/api/WeatherForecasts/v1", request, jwt); 124 | 125 | _testOutputHelper.WriteLine(content); 126 | Assert.Equal(HttpStatusCode.Forbidden, httpStatusCode); 127 | } 128 | 129 | [Theory] 130 | [ClassData(typeof(TestDataGenerator))] 131 | public async Task Create_ShouldReturnUnauthorized_WhenJwtIsBroken(TestHost testHost) 132 | { 133 | const string jwt = "broken"; 134 | var request = new WeatherEntry(0, DateTime.Now.AddDays(-1), TemperatureType.Celsius); 135 | 136 | var (_, httpStatusCode, _) = await testHost.HttpClient.PostExtendedAsync("/api/WeatherForecasts/v1", request, jwt); 137 | 138 | Assert.Equal(HttpStatusCode.Unauthorized, httpStatusCode); 139 | } 140 | 141 | [Theory] 142 | [ClassData(typeof(TestDataGenerator))] 143 | public async Task Get_ShouldReturnNotFound_WhenRequestingNonExistingItem(TestHost testHost) 144 | { 145 | const int id = 1; 146 | 147 | var jwt = await GetJwt(testHost.HttpClient, UsernameWithWriteRole, PasswordWithWriteRole); 148 | var (_, statusCode, _) = 149 | await testHost.HttpClient.GetExtendedAsync($"/api/WeatherForecasts/v1/{id}", jwt); 150 | Assert.Equal(HttpStatusCode.NotFound, statusCode); 151 | } 152 | 153 | [Theory] 154 | [ClassData(typeof(TestDataGenerator))] 155 | public async Task Get_ShouldReturnOk_WhenRequestingExitingItem(TestHost testHost) 156 | { 157 | const int id = 2; 158 | var weatherEntry = new WeatherEntry(0, DateTime.Now.AddDays(1), TemperatureType.Celsius); 159 | testHost.WeatherRepositoryMock.Get(id).Returns(weatherEntry); 160 | 161 | var jwt = await GetJwt(testHost.HttpClient, UsernameWithWriteRole, PasswordWithWriteRole); 162 | var (_, statusCode, _) = 163 | await testHost.HttpClient.GetExtendedAsync($"/api/WeatherForecasts/v1/{id}", jwt); 164 | 165 | Assert.Equal(HttpStatusCode.OK, statusCode); 166 | } 167 | 168 | [Theory] 169 | [ClassData(typeof(TestDataGenerator))] 170 | public async Task Update_ShouldReturnNotFound_WhenTryingToUpdateNoneExistingItem(TestHost testHost) 171 | { 172 | const int id = 1; 173 | 174 | var jwt = await GetJwt(testHost.HttpClient, UsernameWithWriteRole, PasswordWithWriteRole); 175 | var request = new WeatherEntry(0, DateTime.Now.AddDays(-1), TemperatureType.Celsius); 176 | 177 | var (_, statusCode, _) = 178 | await testHost.HttpClient.PutExtendedAsync($"/api/WeatherForecasts/v1/{id}", request, jwt); 179 | Assert.Equal(HttpStatusCode.NotFound, statusCode); 180 | } 181 | 182 | [Theory] 183 | [ClassData(typeof(TestDataGenerator))] 184 | public async Task Update_ShouldReturnNoContent_WhenTryingToUpdateExistingItem(TestHost testHost) 185 | { 186 | const int id = 2; 187 | var weatherEntry = new WeatherEntry(0, DateTime.Now.AddDays(1), TemperatureType.Celsius); 188 | testHost.WeatherRepositoryMock.Get(id).Returns(weatherEntry); 189 | 190 | var jwt = await GetJwt(testHost.HttpClient, UsernameWithWriteRole, PasswordWithWriteRole); 191 | var request = new WeatherEntry(0, DateTime.Now.AddDays(-1), TemperatureType.Celsius); 192 | 193 | var (_, statusCode, _) = 194 | await testHost.HttpClient.PutExtendedAsync($"/api/WeatherForecasts/v1/{id}", request, jwt); 195 | Assert.Equal(HttpStatusCode.NoContent, statusCode); 196 | } 197 | 198 | 199 | private static async Task CreateRandomEntry(TestHost testHost, string jwt) 200 | { 201 | var request = new WeatherEntry(Random.Shared.Next(-50, 50), DateTime.Now.AddDays(Random.Shared.Next(-10, -1)), 202 | (TemperatureType)Random.Shared.Next(0, 2)); 203 | 204 | var (content, httpStatusCode, badRequestResponse) = 205 | await testHost.HttpClient.PostExtendedAsync("/api/WeatherForecasts/v1", request, jwt); 206 | 207 | Assert.Equal(HttpStatusCode.OK, httpStatusCode); 208 | Assert.Null(badRequestResponse); 209 | Assert.NotNull(content); 210 | 211 | var weatherEntry = content.ToObject(); 212 | Assert.NotNull(weatherEntry); 213 | Assert.Equal(request.Temperature, weatherEntry.Temperature); 214 | Assert.Equal(request.ObservedTime, weatherEntry.ObservedTime); 215 | Assert.Equal(request.TemperatureType, weatherEntry.TemperatureType); 216 | } 217 | 218 | [Theory] 219 | [ClassData(typeof(TestDataGenerator))] 220 | public async Task Spam_Create_Test(TestHost testHost) 221 | { 222 | var stopwatch = new Stopwatch(); 223 | stopwatch.Start(); 224 | 225 | var jwt = await GetJwt(testHost.HttpClient, UsernameWithWriteRole, PasswordWithWriteRole); 226 | 227 | const int calls = 1000; 228 | const int threads = 15; 229 | foreach (var _ in Enumerable.Range(1, calls)) 230 | { 231 | var tasks = new Task[threads]; 232 | for (var threadIndex = 0; threadIndex < threads; threadIndex++) 233 | { 234 | tasks[threadIndex] = CreateRandomEntry(testHost, jwt); 235 | } 236 | 237 | Task.WaitAll(tasks); 238 | } 239 | 240 | var elapsed = stopwatch.Elapsed; 241 | _testOutputHelper.WriteLine("Executed {0} calls in {1}", calls*threads, elapsed.Humanize()); 242 | _testOutputHelper.WriteLine("{0} seconds", elapsed.TotalSeconds); 243 | _testOutputHelper.WriteLine("{0} calls/s", calls*threads/elapsed.TotalSeconds); 244 | } 245 | } -------------------------------------------------------------------------------- /Tests/Extensions/HttpClientExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | using System.Net.Http; 4 | using System.Net.Http.Json; 5 | using System.Text.Json; 6 | using System.Text.Json.Serialization; 7 | using System.Threading.Tasks; 8 | using Entities.DataContract.ErrorResponse; 9 | using Microsoft.Net.Http.Headers; 10 | 11 | namespace Tests.Extensions; 12 | 13 | public static class HttpClientExtensions 14 | { 15 | public static async Task<(string Content, HttpStatusCode StatusCode, T ResponseObject)> GetExtendedAsync( 16 | this HttpClient client, string url, string jwt) 17 | { 18 | SetJwtHeader(client, jwt); 19 | 20 | var response = await client.GetAsync(url); 21 | var content = await response.Content.ReadAsStringAsync(); 22 | 23 | var responseObject = default(T); 24 | if (!typeof(T).Name.Equals("string", StringComparison.InvariantCultureIgnoreCase)) 25 | { 26 | responseObject = await response.Content.ReadFromJsonAsync(options: DefaultSerializerOptions); 27 | } 28 | else 29 | { 30 | content = content.TrimStart('\"'); 31 | content = content.TrimEnd('\"'); 32 | } 33 | 34 | return (content, response.StatusCode, responseObject); 35 | } 36 | 37 | public static async Task<(string Content, HttpStatusCode StatusCode, BadRequestResponse BadRequestResponse)> 38 | PostExtendedAsync(this HttpClient client, string url, T request, string jwt) 39 | { 40 | SetJwtHeader(client, jwt); 41 | 42 | var response = await client.PostAsJsonAsync(url, request, DefaultSerializerOptions); 43 | 44 | var content = await response.Content.ReadAsStringAsync(); 45 | 46 | BadRequestResponse badRequestResponse = null; 47 | if (response.StatusCode == HttpStatusCode.BadRequest) 48 | { 49 | badRequestResponse = await response.Content.ReadFromJsonAsync(); 50 | } 51 | 52 | return (content, response.StatusCode, badRequestResponse); 53 | } 54 | 55 | public static async Task<(string Content, HttpStatusCode StatusCode, BadRequestResponse BadRequestResponse)> 56 | PutExtendedAsync(this HttpClient client, string url, T request, string jwt) 57 | { 58 | SetJwtHeader(client, jwt); 59 | 60 | var response = await client.PutAsJsonAsync(url, request, DefaultSerializerOptions); 61 | 62 | var content = await response.Content.ReadAsStringAsync(); 63 | 64 | BadRequestResponse badRequestResponse = null; 65 | if (response.StatusCode == HttpStatusCode.BadRequest) 66 | { 67 | badRequestResponse = await response.Content.ReadFromJsonAsync(); 68 | } 69 | 70 | return (content, response.StatusCode, badRequestResponse); 71 | } 72 | 73 | public static async Task<(string Content, HttpStatusCode StatusCode, BadRequestResponse BadRequestResponse)> 74 | PutExtendedAsync(this HttpClient client, string url, string jwt) 75 | { 76 | SetJwtHeader(client, jwt); 77 | 78 | var response = await client.PutAsync(url, null); 79 | 80 | var content = await response.Content.ReadAsStringAsync(); 81 | 82 | BadRequestResponse badRequestResponse = null; 83 | if (response.StatusCode == HttpStatusCode.BadRequest) 84 | { 85 | badRequestResponse = await response.Content.ReadFromJsonAsync(); 86 | } 87 | 88 | return (content, response.StatusCode, badRequestResponse); 89 | } 90 | 91 | public static async Task<(string Content, HttpStatusCode StatusCode)> DeleteExtendedAsync(this HttpClient client, 92 | string url, string jwt) 93 | { 94 | SetJwtHeader(client, jwt); 95 | 96 | var response = await client.DeleteAsync(url); 97 | 98 | var content = await response.Content.ReadAsStringAsync(); 99 | return (content, response.StatusCode); 100 | } 101 | 102 | private static void SetJwtHeader(HttpClient client, string jwt) 103 | { 104 | if (client.DefaultRequestHeaders.Contains(HeaderNames.Authorization)) 105 | { 106 | client.DefaultRequestHeaders.Remove(HeaderNames.Authorization); 107 | } 108 | 109 | client.DefaultRequestHeaders.Add(HeaderNames.Authorization, "Bearer " + jwt); 110 | } 111 | 112 | private static JsonSerializerOptions DefaultSerializerOptions => new() 113 | { 114 | PropertyNamingPolicy = JsonNamingPolicy.CamelCase, 115 | DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, 116 | Converters = {new JsonStringEnumConverter()} 117 | }; 118 | } -------------------------------------------------------------------------------- /Tests/IntegrationTests/ApiIntegrationTestBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Net.Http; 4 | using Microsoft.AspNetCore.Hosting; 5 | using Microsoft.AspNetCore.Mvc.Testing; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Logging; 9 | using Xunit.Abstractions; 10 | 11 | namespace Tests.IntegrationTests; 12 | 13 | public class ApiIntegrationTestBase : IDisposable 14 | { 15 | protected readonly ITestOutputHelper TestOutputHelper; 16 | 17 | protected ApiIntegrationTestBase(ITestOutputHelper testOutputHelper) 18 | { 19 | TestOutputHelper = testOutputHelper; 20 | InitializeAsync(); 21 | } 22 | 23 | protected HttpClient Client { get; private set; } = null!; 24 | 25 | public void Dispose() 26 | { 27 | GC.SuppressFinalize(this); 28 | Client?.Dispose(); 29 | } 30 | 31 | private void InitializeAsync() 32 | { 33 | var host = new WebApplicationFactory() 34 | .WithWebHostBuilder(builder => 35 | { 36 | builder 37 | .UseContentRoot(Path.GetDirectoryName(GetType().Assembly.Location)!) 38 | .ConfigureAppConfiguration( 39 | configurationBuilder => 40 | configurationBuilder.AddJsonFile("appsettings.json", false, true)) 41 | .ConfigureServices(services => 42 | { 43 | services.AddLogging(logging => 44 | logging.AddXUnit(TestOutputHelper)); 45 | }); 46 | }); 47 | 48 | Client = host.CreateClient(); 49 | } 50 | } -------------------------------------------------------------------------------- /Tests/IntegrationTests/SigningAssignmentIntegrationTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net; 4 | using System.Threading.Tasks; 5 | using Entities.DataContract; 6 | using Entities.Enums; 7 | using Infrastructure.Extensions; 8 | using Tests.Extensions; 9 | using Xunit; 10 | using Xunit.Abstractions; 11 | 12 | namespace Tests.IntegrationTests; 13 | 14 | [Trait("Category", "Integration")] 15 | public class SigningAssignmentIntegrationTest : ApiIntegrationTestBase 16 | { 17 | public SigningAssignmentIntegrationTest(ITestOutputHelper testOutputHelper) : base(testOutputHelper) 18 | { 19 | } 20 | 21 | private async Task GetJwt(string username, string password) 22 | { 23 | var (content, statusCode, _) = await Client.GetExtendedAsync( 24 | $"/api/v1/login?username={username}&password={password}", jwt: null); 25 | 26 | Assert.Equal(HttpStatusCode.OK, statusCode); 27 | Assert.NotNull(content); 28 | 29 | return content; 30 | } 31 | 32 | [Fact] 33 | public async Task AllWeatherForecasts_ShouldWork() 34 | { 35 | var jwt = await GetJwt("harre", "errah"); 36 | 37 | var request = new WeatherEntry(42, DateTime.Now.AddDays(-1), TemperatureType.Fahrenheit); 38 | 39 | var (_, statusCodeCreate, _) = 40 | await Client.PostExtendedAsync("/api/WeatherForecasts/v1", request, jwt); 41 | 42 | Assert.Equal(HttpStatusCode.OK, statusCodeCreate); 43 | 44 | var (_, statusCode, responseObject) = 45 | await Client.GetExtendedAsync>( 46 | "/api/WeatherForecasts/v1", jwt); 47 | 48 | Assert.Equal(HttpStatusCode.OK, statusCode); 49 | 50 | TestOutputHelper.WriteLine(responseObject.ToJson(true)); 51 | Assert.Single(responseObject); 52 | } 53 | } -------------------------------------------------------------------------------- /Tests/Startup.cs: -------------------------------------------------------------------------------- 1 | using Api.Validation; 2 | using FluentValidation.AspNetCore; 3 | using Infrastructure.DataService; 4 | using Microsoft.Extensions.Configuration; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Logging; 7 | using Newtonsoft.Json; 8 | using Newtonsoft.Json.Serialization; 9 | using Xunit; 10 | 11 | [assembly: CollectionBehavior(CollectionBehavior.CollectionPerAssembly)] 12 | 13 | namespace Tests; 14 | 15 | public class Startup 16 | { 17 | private static IConfiguration Configuration 18 | { 19 | get 20 | { 21 | var builder = new ConfigurationBuilder() 22 | .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); 23 | 24 | return builder.Build(); 25 | } 26 | } 27 | 28 | public void ConfigureServices(IServiceCollection services) 29 | { 30 | services.AddLogging(logging => 31 | { 32 | logging.SetMinimumLevel(LogLevel.Debug); 33 | }); 34 | 35 | SetupDependencyInjection(services); 36 | services.AddFluentValidation(v => v.RegisterValidatorsFromAssemblyContaining()); 37 | 38 | services.AddSingleton(_ => services.BuildServiceProvider()); 39 | } 40 | 41 | private static void SetupDependencyInjection(IServiceCollection services) 42 | { 43 | JsonConvert.DefaultSettings = () => new JsonSerializerSettings 44 | { 45 | ContractResolver = new CamelCasePropertyNamesContractResolver(), 46 | Formatting = Formatting.None, 47 | NullValueHandling = NullValueHandling.Ignore 48 | }; 49 | services.AddTransient(_ => Configuration); 50 | services.AddTransient(); 51 | } 52 | } -------------------------------------------------------------------------------- /Tests/Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net6.0 5 | disable 6 | 7 | false 8 | 9 | 10 | 11 | true 12 | 13 | 14 | 15 | true 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | runtime; build; native; contentfiles; analyzers; buildtransitive 31 | all 32 | 33 | 34 | runtime; build; native; contentfiles; analyzers; buildtransitive 35 | all 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | true 50 | PreserveNewest 51 | PreserveNewest 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /Tests/UnitTests/WeatherEntryValidationTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using Entities.DataContract; 4 | using Entities.Enums; 5 | using FluentValidation; 6 | using Infrastructure.Extensions; 7 | using Xunit; 8 | using Xunit.Abstractions; 9 | 10 | namespace Tests.UnitTests; 11 | 12 | [Trait("Category", "CI")] 13 | public class WeatherEntryValidationTest 14 | { 15 | private readonly ITestOutputHelper _testOutputHelper; 16 | private readonly IValidator _validator; 17 | 18 | public WeatherEntryValidationTest(ITestOutputHelper testOutputHelper, IValidator validator) 19 | { 20 | _testOutputHelper = testOutputHelper; 21 | _validator = validator; 22 | } 23 | 24 | [Fact] 25 | public void Validate_WeatherEntry_ShouldBeInvalid_WhenHavingObservedTimeInFuture() 26 | { 27 | var weatherEntry = new WeatherEntry(0, DateTime.Now.AddDays(1), TemperatureType.Celsius); 28 | 29 | var validationResult = _validator.Validate(weatherEntry); 30 | _testOutputHelper.WriteLine(validationResult.Errors.ToJson(true)); 31 | 32 | Assert.False(validationResult.IsValid, "Unexpected valid"); 33 | 34 | Assert.Single(validationResult.Errors.Where(x => x.PropertyName == nameof(weatherEntry.ObservedTime))); 35 | } 36 | 37 | [Fact] 38 | public void Validate_WeatherEntry_ShouldBeValid_WhenObjectIsValid() 39 | { 40 | var weatherEntry = new WeatherEntry(0, DateTime.Now.AddDays(-1), TemperatureType.Celsius); 41 | 42 | var validationResult = _validator.Validate(weatherEntry); 43 | _testOutputHelper.WriteLine(validationResult.Errors.ToJson(true)); 44 | 45 | Assert.True(validationResult.IsValid, "Unexpected valid"); 46 | 47 | Assert.Empty(validationResult.Errors); 48 | } 49 | } -------------------------------------------------------------------------------- /Tests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft.AspNetCore": "Debug" 6 | } 7 | }, 8 | "Secret" : "aGFycmUgaGF4eGFyIGVuIGd1bGxpZyBzZWNyZXQ", 9 | "SwaggerBase": "http://localhost:48936" 10 | } 11 | -------------------------------------------------------------------------------- /WeatherService.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.1.32319.34 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure", "Backend\Infrastructure\Infrastructure.csproj", "{118AA048-2EFA-4197-9EA2-A0664D1B4741}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Entities", "Backend\Entities\Entities.csproj", "{E25D2B90-6A7D-476C-94D7-7B90EC771E0D}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tests", "Tests\Tests.csproj", "{7C131798-10A5-40DB-9CD2-335E0B15BFA7}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Api", "Backend\Api\Api.csproj", "{C433A06A-EABD-4161-A235-08FC1360FFB4}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C0A60899-E2FE-47D8-A323-4734AC907108}" 15 | ProjectSection(SolutionItems) = preProject 16 | .gitignore = .gitignore 17 | README.md = README.md 18 | EndProjectSection 19 | EndProject 20 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ApiClassic", "Backend\ApiClassic\ApiClassic.csproj", "{03FA9FDC-E53E-4B22-8A8A-584D3E5C34E2}" 21 | EndProject 22 | Global 23 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 24 | Debug|Any CPU = Debug|Any CPU 25 | Release|Any CPU = Release|Any CPU 26 | EndGlobalSection 27 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 28 | {118AA048-2EFA-4197-9EA2-A0664D1B4741}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {118AA048-2EFA-4197-9EA2-A0664D1B4741}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {118AA048-2EFA-4197-9EA2-A0664D1B4741}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {118AA048-2EFA-4197-9EA2-A0664D1B4741}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {E25D2B90-6A7D-476C-94D7-7B90EC771E0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {E25D2B90-6A7D-476C-94D7-7B90EC771E0D}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {E25D2B90-6A7D-476C-94D7-7B90EC771E0D}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {E25D2B90-6A7D-476C-94D7-7B90EC771E0D}.Release|Any CPU.Build.0 = Release|Any CPU 36 | {7C131798-10A5-40DB-9CD2-335E0B15BFA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 37 | {7C131798-10A5-40DB-9CD2-335E0B15BFA7}.Debug|Any CPU.Build.0 = Debug|Any CPU 38 | {7C131798-10A5-40DB-9CD2-335E0B15BFA7}.Release|Any CPU.ActiveCfg = Release|Any CPU 39 | {7C131798-10A5-40DB-9CD2-335E0B15BFA7}.Release|Any CPU.Build.0 = Release|Any CPU 40 | {C433A06A-EABD-4161-A235-08FC1360FFB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 41 | {C433A06A-EABD-4161-A235-08FC1360FFB4}.Debug|Any CPU.Build.0 = Debug|Any CPU 42 | {C433A06A-EABD-4161-A235-08FC1360FFB4}.Release|Any CPU.ActiveCfg = Release|Any CPU 43 | {C433A06A-EABD-4161-A235-08FC1360FFB4}.Release|Any CPU.Build.0 = Release|Any CPU 44 | {03FA9FDC-E53E-4B22-8A8A-584D3E5C34E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 45 | {03FA9FDC-E53E-4B22-8A8A-584D3E5C34E2}.Debug|Any CPU.Build.0 = Debug|Any CPU 46 | {03FA9FDC-E53E-4B22-8A8A-584D3E5C34E2}.Release|Any CPU.ActiveCfg = Release|Any CPU 47 | {03FA9FDC-E53E-4B22-8A8A-584D3E5C34E2}.Release|Any CPU.Build.0 = Release|Any CPU 48 | EndGlobalSection 49 | GlobalSection(SolutionProperties) = preSolution 50 | HideSolutionNode = FALSE 51 | EndGlobalSection 52 | GlobalSection(NestedProjects) = preSolution 53 | EndGlobalSection 54 | GlobalSection(ExtensibilityGlobals) = postSolution 55 | SolutionGuid = {6E5E364A-0F1A-4AC2-93BA-37729235AD87} 56 | EndGlobalSection 57 | EndGlobal 58 | -------------------------------------------------------------------------------- /WeatherService.sln.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | True --------------------------------------------------------------------------------