├── GenericHostConsoleApp ├── Helpers │ ├── TemperatureUnit.cs │ ├── TemperatureConverter.cs │ └── LoggerExtensions.cs ├── HttpClient │ ├── HttpClientName.cs │ ├── HttpClientPolicy.cs │ └── HttpClientConfiguration.cs ├── Models │ └── WeatherForecast │ │ ├── Clouds.cs │ │ ├── Wind.cs │ │ ├── Coordinates.cs │ │ ├── Weather.cs │ │ ├── Sys.cs │ │ ├── Main.cs │ │ └── WeatherResponse.cs ├── Services │ ├── ApplicationLifetimeEvent.cs │ ├── Interfaces │ │ ├── IMainService.cs │ │ └── IWeatherForecastService.cs │ ├── WeatherForecastService.cs │ ├── MainService.cs │ └── ApplicationHostedService.cs ├── Configuration │ └── WeatherForecastServiceOptions.cs ├── Exceptions │ └── WeatherForecastException.cs ├── ExitCode.cs ├── appsettings.json ├── Program.cs └── GenericHostConsoleApp.csproj ├── qodana.yaml ├── .github └── workflows │ ├── dotnet.yml │ └── codeql-analysis.yml ├── GenericHostConsoleApp.UnitTests ├── GenericHostConsoleApp.UnitTests.csproj └── ApplicationHostedServiceTests.cs ├── LICENSE ├── GenericHostConsoleApp.sln ├── README.md └── .gitignore /GenericHostConsoleApp/Helpers/TemperatureUnit.cs: -------------------------------------------------------------------------------- 1 | namespace GenericHostConsoleApp.Helpers; 2 | 3 | /// 4 | /// Represents the unit of temperature measurement. 5 | /// 6 | public enum TemperatureUnit 7 | { 8 | Kelvin, 9 | Celsius, 10 | Fahrenheit 11 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp/HttpClient/HttpClientName.cs: -------------------------------------------------------------------------------- 1 | namespace GenericHostConsoleApp.HttpClient; 2 | 3 | /// 4 | /// An enumeration that represents predefined HTTP client names used 5 | /// within the application for configuring and consuming external APIs. 6 | /// 7 | public enum HttpClientName 8 | { 9 | OpenWeather 10 | } -------------------------------------------------------------------------------- /qodana.yaml: -------------------------------------------------------------------------------- 1 | version: "1.0" 2 | profile: 3 | name: qodana.starter 4 | include: 5 | - name: HeapView.BoxingAllocation 6 | - name: HeapView.ClosureAllocation 7 | - name: HeapView.DelegateAllocation 8 | - name: HeapView.ObjectAllocation 9 | - name: HeapView.ObjectAllocation.Evident 10 | - name: UnusedMember.Global 11 | - name: UnusedParameter.Global 12 | -------------------------------------------------------------------------------- /GenericHostConsoleApp/Models/WeatherForecast/Clouds.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace GenericHostConsoleApp.Models.WeatherForecast; 4 | 5 | // ReSharper disable once ClassNeverInstantiated.Global 6 | // ReSharper disable UnusedMember.Global 7 | // ReSharper disable UnusedAutoPropertyAccessor.Global 8 | public record Clouds 9 | { 10 | [JsonPropertyName("all")] public int All { get; init; } 11 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp/Models/WeatherForecast/Wind.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace GenericHostConsoleApp.Models.WeatherForecast; 4 | 5 | // ReSharper disable once ClassNeverInstantiated.Global 6 | // ReSharper disable UnusedMember.Global 7 | // ReSharper disable UnusedAutoPropertyAccessor.Global 8 | public record Wind 9 | { 10 | [JsonPropertyName("speed")] public double Speed { get; init; } 11 | [JsonPropertyName("deg")] public int Deg { get; init; } 12 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp/Models/WeatherForecast/Coordinates.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace GenericHostConsoleApp.Models.WeatherForecast; 4 | 5 | // ReSharper disable once ClassNeverInstantiated.Global 6 | // ReSharper disable UnusedMember.Global 7 | // ReSharper disable UnusedAutoPropertyAccessor.Global 8 | public record Coordinates 9 | { 10 | [JsonPropertyName("lon")] public double Lon { get; init; } 11 | [JsonPropertyName("lat")] public double Lat { get; init; } 12 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp/Services/ApplicationLifetimeEvent.cs: -------------------------------------------------------------------------------- 1 | namespace GenericHostConsoleApp.Services; 2 | 3 | /// 4 | /// Represents the various lifetime events of an application's lifecycle. 5 | /// These events are triggered at different points during the application's lifetime, 6 | /// such as when the application starts, stops, or is in the process of stopping. 7 | /// 8 | public enum ApplicationLifetimeEvent 9 | { 10 | ApplicationStarted, 11 | ApplicationStopping, 12 | ApplicationStopped 13 | } -------------------------------------------------------------------------------- /.github/workflows/dotnet.yml: -------------------------------------------------------------------------------- 1 | name: .NET 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Setup .NET 17 | uses: actions/setup-dotnet@v1 18 | with: 19 | dotnet-version: 8.0.x 20 | - name: Restore dependencies 21 | run: dotnet restore 22 | - name: Build 23 | run: dotnet build --no-restore 24 | - name: Test 25 | run: dotnet test --no-build --verbosity normal 26 | -------------------------------------------------------------------------------- /GenericHostConsoleApp/Configuration/WeatherForecastServiceOptions.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace GenericHostConsoleApp.Configuration; 4 | 5 | /// 6 | /// Represents the options for the WeatherForecastService. 7 | /// 8 | // ReSharper disable UnusedAutoPropertyAccessor.Global 9 | public record WeatherForecastServiceOptions 10 | { 11 | /// 12 | /// Represents the API key used for authentication in the WeatherForecastService. 13 | /// 14 | [Required] 15 | public required string ApiKey { get; init; } 16 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp/Exceptions/WeatherForecastException.cs: -------------------------------------------------------------------------------- 1 | namespace GenericHostConsoleApp.Exceptions; 2 | 3 | /// 4 | /// Represents an exception that occurs during weather forecast retrieval. 5 | /// 6 | // ReSharper disable UnusedMember.Global 7 | public class WeatherForecastException : Exception 8 | { 9 | public WeatherForecastException() 10 | { 11 | } 12 | 13 | public WeatherForecastException(string message) : base(message) 14 | { 15 | } 16 | 17 | public WeatherForecastException(string message, Exception innerException) : base(message, innerException) 18 | { 19 | } 20 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp/Models/WeatherForecast/Weather.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace GenericHostConsoleApp.Models.WeatherForecast; 4 | 5 | // ReSharper disable once ClassNeverInstantiated.Global 6 | // ReSharper disable UnusedMember.Global 7 | // ReSharper disable UnusedAutoPropertyAccessor.Global 8 | public record Weather 9 | { 10 | [JsonPropertyName("id")] public int Id { get; init; } 11 | [JsonPropertyName("main")] public string? Main { get; init; } 12 | [JsonPropertyName("description")] public string? Description { get; init; } 13 | [JsonPropertyName("icon")] public string? Icon { get; init; } 14 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp/Models/WeatherForecast/Sys.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace GenericHostConsoleApp.Models.WeatherForecast; 4 | 5 | // ReSharper disable once ClassNeverInstantiated.Global 6 | // ReSharper disable UnusedMember.Global 7 | // ReSharper disable UnusedAutoPropertyAccessor.Global 8 | public record Sys 9 | { 10 | [JsonPropertyName("type")] public int Type { get; init; } 11 | [JsonPropertyName("id")] public int Id { get; init; } 12 | [JsonPropertyName("country")] public string? Country { get; init; } 13 | [JsonPropertyName("sunrise")] public long Sunrise { get; init; } 14 | [JsonPropertyName("sunset")] public long Sunset { get; init; } 15 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp/ExitCode.cs: -------------------------------------------------------------------------------- 1 | namespace GenericHostConsoleApp; 2 | 3 | /// 4 | /// Indicates that the application failed to complete successfully. 5 | /// 6 | public enum ExitCode 7 | { 8 | /// 9 | /// Indicates that the application succeeded. 10 | /// 11 | Success = 0, 12 | 13 | /// 14 | /// Indicates that the application was cancelled. 15 | /// 16 | Cancelled, 17 | 18 | /// 19 | /// Indicates that the application was aborted. 20 | /// 21 | Aborted, 22 | 23 | /// 24 | /// Indicates that the application failed to complete successfully. 25 | /// 26 | Failed 27 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp/Services/Interfaces/IMainService.cs: -------------------------------------------------------------------------------- 1 | namespace GenericHostConsoleApp.Services.Interfaces; 2 | 3 | /// 4 | /// Main application service interface. 5 | /// 6 | public interface IMainService 7 | { 8 | /// 9 | /// Executes the main functionality of the application. 10 | /// 11 | /// An array of command-line arguments passed to the application. 12 | /// A token that can be used to signal the operation should be canceled. 13 | /// Returns an indicating the result of the execution. 14 | // ReSharper disable once UnusedParameter.Global 15 | Task ExecuteMainAsync(string[] args, CancellationToken cancellationToken); 16 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp/Services/Interfaces/IWeatherForecastService.cs: -------------------------------------------------------------------------------- 1 | using GenericHostConsoleApp.Models.WeatherForecast; 2 | 3 | namespace GenericHostConsoleApp.Services.Interfaces; 4 | 5 | /// 6 | /// A service for fetching weather forecasts from an external API. 7 | /// 8 | public interface IWeatherForecastService 9 | { 10 | /// 11 | /// Fetches the weather forecast for a specified name from an external API asynchronously. 12 | /// 13 | /// The name of the place for which to fetch the weather forecast. 14 | /// A token to monitor for cancellation requests. 15 | /// A Task representing the asynchronous operation, which upon completion contains the weather forecast response. 16 | Task FetchWeatherForecastAsync(string name, CancellationToken cancellationToken); 17 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp/Models/WeatherForecast/Main.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace GenericHostConsoleApp.Models.WeatherForecast; 4 | 5 | // ReSharper disable once ClassNeverInstantiated.Global 6 | // ReSharper disable UnusedMember.Global 7 | // ReSharper disable UnusedAutoPropertyAccessor.Global 8 | public record Main 9 | { 10 | [JsonPropertyName("temp")] public double Temp { get; init; } 11 | [JsonPropertyName("feels_like")] public double FeelsLike { get; init; } 12 | [JsonPropertyName("temp_min")] public double TempMin { get; init; } 13 | [JsonPropertyName("temp_max")] public double TempMax { get; init; } 14 | [JsonPropertyName("pressure")] public int Pressure { get; init; } 15 | [JsonPropertyName("humidity")] public int Humidity { get; init; } 16 | [JsonPropertyName("sea_level")] public int SeaLevel { get; init; } 17 | [JsonPropertyName("grnd_level")] public int GroundLevel { get; init; } 18 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp.UnitTests/GenericHostConsoleApp.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | all 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Eduardo Garcia-Prieto 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 | -------------------------------------------------------------------------------- /GenericHostConsoleApp/Models/WeatherForecast/WeatherResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace GenericHostConsoleApp.Models.WeatherForecast; 4 | 5 | // ReSharper disable once ClassNeverInstantiated.Global 6 | // ReSharper disable UnusedMember.Global 7 | // ReSharper disable UnusedAutoPropertyAccessor.Global 8 | // ReSharper disable CollectionNeverUpdated.Global 9 | public record WeatherResponse 10 | { 11 | [JsonPropertyName("coord")] public Coordinates? Coord { get; init; } 12 | [JsonPropertyName("weather")] public List? Weather { get; init; } 13 | [JsonPropertyName("base")] public string? Base { get; init; } 14 | [JsonPropertyName("main")] public Main? Main { get; init; } 15 | [JsonPropertyName("visibility")] public int Visibility { get; init; } 16 | [JsonPropertyName("wind")] public Wind? Wind { get; init; } 17 | [JsonPropertyName("clouds")] public Clouds? Clouds { get; init; } 18 | [JsonPropertyName("dt")] public long Dt { get; init; } 19 | [JsonPropertyName("sys")] public Sys? Sys { get; init; } 20 | [JsonPropertyName("timezone")] public int Timezone { get; init; } 21 | [JsonPropertyName("id")] public int Id { get; init; } 22 | [JsonPropertyName("name")] public string? Name { get; init; } 23 | [JsonPropertyName("cod")] public int Cod { get; init; } 24 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "TemperatureUnit": "Celsius", 3 | "Logging": { 4 | "LogLevel": { 5 | "Default": "Debug", 6 | "Microsoft.Hosting.Lifetime": "Warning", 7 | "Microsoft.Extensions.Hosting.Internal.Host": "Warning" 8 | } 9 | }, 10 | "Serilog": { 11 | "MinimumLevel": { 12 | "Default": "Information", 13 | "Override": { 14 | "Microsoft": "Warning", 15 | "System": "Warning" 16 | } 17 | }, 18 | "WriteTo": [ 19 | { 20 | "Name": "Console", 21 | "Args": { 22 | "OutputTemplate": "[{Timestamp} {Level}] {Message} {Properties}{NewLine}{Exception}" 23 | } 24 | }, 25 | { 26 | "Name": "File", 27 | "Args": { 28 | "Path": ".//Logs//GenericHostConsoleApp-.log", 29 | "RollingInterval": "Day", 30 | "OutputTemplate": "{Timestamp} [{Level}] {Message} {Properties}{NewLine}{Exception}" 31 | } 32 | } 33 | ], 34 | "Enrich": [ 35 | "FromLogContext", 36 | "WithThreadId", 37 | "WithExceptionDetails" 38 | ] 39 | }, 40 | "HttpClients": { 41 | "OpenWeather": { 42 | "BaseAddress": "https://api.openweathermap.org/", 43 | "Retries": 5, 44 | "EventsBeforeBreaking": 5, 45 | "DurationOfBreak": "00:01:00", 46 | "Timeout": "00:00:15" 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30114.105 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenericHostConsoleApp", "GenericHostConsoleApp\GenericHostConsoleApp.csproj", "{D801A4DC-75FF-4138-91AF-3FA4E9EFE036}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenericHostConsoleApp.UnitTests", "GenericHostConsoleApp.UnitTests\GenericHostConsoleApp.UnitTests.csproj", "{1168D5CD-95C7-46CE-8EE9-8C9946811594}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Release|Any CPU = Release|Any CPU 14 | EndGlobalSection 15 | GlobalSection(SolutionProperties) = preSolution 16 | HideSolutionNode = FALSE 17 | EndGlobalSection 18 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 19 | {D801A4DC-75FF-4138-91AF-3FA4E9EFE036}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 20 | {D801A4DC-75FF-4138-91AF-3FA4E9EFE036}.Debug|Any CPU.Build.0 = Debug|Any CPU 21 | {D801A4DC-75FF-4138-91AF-3FA4E9EFE036}.Release|Any CPU.ActiveCfg = Release|Any CPU 22 | {D801A4DC-75FF-4138-91AF-3FA4E9EFE036}.Release|Any CPU.Build.0 = Release|Any CPU 23 | {1168D5CD-95C7-46CE-8EE9-8C9946811594}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {1168D5CD-95C7-46CE-8EE9-8C9946811594}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {1168D5CD-95C7-46CE-8EE9-8C9946811594}.Release|Any CPU.ActiveCfg = Release|Any CPU 26 | {1168D5CD-95C7-46CE-8EE9-8C9946811594}.Release|Any CPU.Build.0 = Release|Any CPU 27 | EndGlobalSection 28 | EndGlobal 29 | -------------------------------------------------------------------------------- /GenericHostConsoleApp/Program.cs: -------------------------------------------------------------------------------- 1 | using GenericHostConsoleApp.Configuration; 2 | using GenericHostConsoleApp.HttpClient; 3 | using GenericHostConsoleApp.Services; 4 | using GenericHostConsoleApp.Services.Interfaces; 5 | using Microsoft.Extensions.Configuration; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Microsoft.Extensions.Hosting; 8 | using Microsoft.Extensions.Logging; 9 | using Serilog; 10 | 11 | // Create the host builder 12 | var builder = Host.CreateApplicationBuilder(args); 13 | 14 | // Configure configuration sources 15 | builder.Configuration.AddEnvironmentVariables(); 16 | 17 | // Add command-line arguments with mappings 18 | builder.Configuration.AddCommandLine(args, new Dictionary 19 | { 20 | { "-n", "Name" }, 21 | { "--name", "Name" } 22 | }); 23 | 24 | // Configure logging 25 | builder.Logging.ClearProviders(); // Remove default providers 26 | builder.Services.AddSerilog((_, configuration) => 27 | configuration.ReadFrom.Configuration(builder.Configuration)); 28 | 29 | // Configure services 30 | builder.Services.AddHostedService(); 31 | builder.Services.AddTransient(); 32 | builder.Services.AddTransient(); 33 | 34 | // Register HttpClients with policies, passing the Configuration 35 | builder.Services.AddHttpClientsWithPolicies(builder.Configuration); 36 | 37 | // Configure options and validation 38 | builder.Services 39 | .AddOptions() 40 | .Bind(builder.Configuration.GetSection(nameof(WeatherForecastServiceOptions))) 41 | .ValidateDataAnnotations() 42 | .ValidateOnStart(); 43 | 44 | // Build and run the host 45 | using var host = builder.Build(); 46 | 47 | try 48 | { 49 | await host.RunAsync(); 50 | } 51 | finally 52 | { 53 | Log.CloseAndFlush(); 54 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp/Helpers/TemperatureConverter.cs: -------------------------------------------------------------------------------- 1 | namespace GenericHostConsoleApp.Helpers; 2 | 3 | /// 4 | /// A static utility class for converting temperature values between different units. 5 | /// 6 | public static class TemperatureConverter 7 | { 8 | /// 9 | /// Converts a temperature value from one temperature unit to another. 10 | /// 11 | /// The temperature value to convert. 12 | /// The unit of the temperature value to convert from. 13 | /// The unit of the temperature value to convert to. 14 | /// Unsupported temperature conversion requested. 15 | /// The converted temperature value. 16 | public static double ConvertTemperature(double value, TemperatureUnit fromUnit, TemperatureUnit toUnit) 17 | { 18 | if (fromUnit == toUnit) return value; 19 | 20 | // Convert from the source unit to Celsius as an intermediate step 21 | var valueInCelsius = fromUnit switch 22 | { 23 | TemperatureUnit.Celsius => value, 24 | TemperatureUnit.Fahrenheit => (value - 32) * 5 / 9, 25 | TemperatureUnit.Kelvin => value - 273.15, 26 | _ => throw new InvalidOperationException("Unsupported temperature conversion requested.") 27 | }; 28 | 29 | // Convert from Celsius to the target unit 30 | return toUnit switch 31 | { 32 | TemperatureUnit.Celsius => valueInCelsius, 33 | TemperatureUnit.Fahrenheit => valueInCelsius * 9 / 5 + 32, 34 | TemperatureUnit.Kelvin => valueInCelsius + 273.15, 35 | _ => throw new InvalidOperationException("Unsupported temperature conversion requested.") 36 | }; 37 | } 38 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp/GenericHostConsoleApp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | enable 7 | enable 8 | EA26CDDD-A6DE-4D53-A8B5-C2A7834D8DC8 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | Always 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '35 10 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'csharp' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /GenericHostConsoleApp/Services/WeatherForecastService.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text.Json; 3 | using GenericHostConsoleApp.Configuration; 4 | using GenericHostConsoleApp.Exceptions; 5 | using GenericHostConsoleApp.HttpClient; 6 | using GenericHostConsoleApp.Models.WeatherForecast; 7 | using GenericHostConsoleApp.Services.Interfaces; 8 | using Microsoft.Extensions.Logging; 9 | using Microsoft.Extensions.Options; 10 | 11 | namespace GenericHostConsoleApp.Services; 12 | 13 | /// 14 | /// A service for fetching weather forecasts from an external API. 15 | /// 16 | public class WeatherForecastService( 17 | IOptions options, 18 | ILogger logger, 19 | IHttpClientFactory httpClientFactory) 20 | : IWeatherForecastService 21 | { 22 | /// 23 | /// Fetches the weather forecast for a specified name from an external API asynchronously. 24 | /// 25 | /// The name of the place for which to fetch the weather forecast. 26 | /// A token to monitor for cancellation requests. 27 | /// WeatherForecastServiceOptions are not properly configured. 28 | /// A Task representing the asynchronous operation, which upon completion contains the weather forecast response. 29 | public async Task FetchWeatherForecastAsync(string name, CancellationToken cancellationToken) 30 | { 31 | cancellationToken.ThrowIfCancellationRequested(); 32 | 33 | const string httpClientName = nameof(HttpClientName.OpenWeather); 34 | var httpClient = httpClientFactory.CreateClient(httpClientName); 35 | 36 | const string relativePath = "/data/2.5/weather"; 37 | 38 | var uriBuilder = new UriBuilder(new Uri(httpClient.BaseAddress!, relativePath)) 39 | { 40 | Query = $"q={name}&appid={options.Value.ApiKey}" 41 | }; 42 | 43 | var openWeatherUrl = uriBuilder.ToString(); 44 | 45 | // Log the URL but obfuscate the key for security 46 | logger.LogDebug("OpenWeather Url: {Url}", openWeatherUrl.Replace(options.Value.ApiKey, "*****")); 47 | 48 | using var response = await httpClient.GetAsync(openWeatherUrl, cancellationToken).ConfigureAwait(false); 49 | 50 | if (!response.IsSuccessStatusCode) 51 | { 52 | if (response.StatusCode == HttpStatusCode.NotFound) 53 | throw new WeatherForecastException($"The name \"{name}\" was not found."); 54 | 55 | throw new WeatherForecastException( 56 | $"Failed to fetch weather data: Status: {response.StatusCode}; {response.Content}"); 57 | } 58 | 59 | // Process the response 60 | var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); 61 | 62 | try 63 | { 64 | var weatherResponse = JsonSerializer.Deserialize(responseContent); 65 | if (weatherResponse == null) throw new WeatherForecastException("Failed to deserialize weather response."); 66 | 67 | return weatherResponse; 68 | } 69 | catch (JsonException ex) 70 | { 71 | throw new WeatherForecastException("Failed to parse weather data.", ex); 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp/Helpers/LoggerExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | 3 | namespace GenericHostConsoleApp.Helpers; 4 | 5 | /// 6 | /// Contains logger messages used throughout the application. 7 | /// 8 | public static partial class LoggerExtensions 9 | { 10 | private const string UnhandledExceptionMessage = "An unhandled exception has occurred"; 11 | private const string AppCanceledMessage = "Application was canceled"; 12 | private const string AppStartingMessage = "Application is starting"; 13 | private const string AppStartedMessage = "Application has started with args: \"{Args}\""; 14 | private const string AppStoppingMessage = "Application is stopping"; 15 | private const string AppStoppedMessage = "Application has stopped"; 16 | private const string AppExitingMessage = "Application is exiting with exit code: {ExitCode} ({ExitCodeAsInt})"; 17 | private const string AggregateExceptionMessage = "An aggregate exception has occurred"; 18 | 19 | /// 20 | /// Logs the details of an unhandled exception. 21 | /// 22 | /// The to use. 23 | /// The unhandled exception. 24 | [LoggerMessage(1, LogLevel.Critical, UnhandledExceptionMessage)] 25 | public static partial void LogUnhandledException(this ILogger logger, Exception exception); 26 | 27 | /// 28 | /// Logs a message indicating that the application was cancelled. 29 | /// 30 | /// The to use. 31 | [LoggerMessage(2, LogLevel.Information, AppCanceledMessage)] 32 | public static partial void LogApplicationCanceled(this ILogger logger); 33 | 34 | /// 35 | /// Logs a message indicating that the application is starting. 36 | /// 37 | /// The to use. 38 | [LoggerMessage(3, LogLevel.Debug, AppStartingMessage)] 39 | public static partial void LogApplicationStarting(this ILogger logger); 40 | 41 | /// 42 | /// Logs a message indicating that the application has started. 43 | /// 44 | /// The to use. 45 | /// The command line arguments. 46 | [LoggerMessage(4, LogLevel.Debug, AppStartedMessage)] 47 | public static partial void LogApplicationStarted(this ILogger logger, string[] args); 48 | 49 | /// 50 | /// Logs a message indicating that the application is stopping. 51 | /// 52 | /// The to use. 53 | [LoggerMessage(5, LogLevel.Debug, AppStoppingMessage)] 54 | public static partial void LogApplicationStopping(this ILogger logger); 55 | 56 | /// 57 | /// Logs a message indicating that the application has stopped. 58 | /// 59 | /// The to use. 60 | [LoggerMessage(6, LogLevel.Debug, AppStoppedMessage)] 61 | public static partial void LogApplicationStopped(this ILogger logger); 62 | 63 | /// 64 | /// Logs a message indicating that the application is exiting. 65 | /// 66 | /// The to use. 67 | /// The exit code as an enum. 68 | /// The exit code as an integer. 69 | [LoggerMessage(7, LogLevel.Debug, AppExitingMessage)] 70 | public static partial void LogApplicationExiting(this ILogger logger, ExitCode exitCode, int exitCodeAsInt); 71 | 72 | /// 73 | /// Logs a message indicating that an aggregate exception has occurred. 74 | /// 75 | /// The to use. 76 | /// The aggregate exception. 77 | [LoggerMessage(8, LogLevel.Critical, AggregateExceptionMessage)] 78 | public static partial void LogAggregateException(this ILogger logger, Exception exception); 79 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp.UnitTests/ApplicationHostedServiceTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using GenericHostConsoleApp.Services; 5 | using GenericHostConsoleApp.Services.Interfaces; 6 | using Microsoft.Extensions.Hosting.Internal; 7 | using Microsoft.Extensions.Logging.Abstractions; 8 | using Moq; 9 | using Xunit; 10 | 11 | namespace GenericHostConsoleApp.UnitTests; 12 | 13 | public class ApplicationHostedServiceTests 14 | { 15 | /// 16 | /// Initializes the test with necessary instances and mocks. 17 | /// 18 | /// Application lifetime. 19 | /// Logger instance for ApplicationHostedService. 20 | /// Mocked Main Service instance. 21 | /// Application Hosted Service using the provided instances. 22 | private static void InitializeTest( 23 | out ApplicationLifetime applicationLifeTime, 24 | // ReSharper disable once OutParameterValueIsAlwaysDiscarded.Local 25 | out NullLogger logger, 26 | out IMainService mainService, 27 | out ApplicationHostedService applicationHostedService) 28 | { 29 | applicationLifeTime = new ApplicationLifetime(new NullLogger()); 30 | logger = new NullLogger(); 31 | mainService = Mock.Of(); 32 | applicationHostedService = new ApplicationHostedService(applicationLifeTime, logger, mainService); 33 | } 34 | 35 | [Fact] 36 | public async Task StartAsync_StopAsync_OnSuccess_Invokes_MainService_Main() 37 | { 38 | // Arrange 39 | var cancellationToken = CancellationToken.None; 40 | 41 | InitializeTest(out var applicationLifeTime, out _, out var mainService, out var applicationHostedService); 42 | 43 | // Act 44 | await applicationHostedService.StartAsync(cancellationToken); 45 | applicationLifeTime.NotifyStarted(); 46 | await applicationHostedService.StopAsync(cancellationToken); 47 | 48 | // Assert 49 | Mock.Get(mainService) 50 | .Verify(service => 51 | service.ExecuteMainAsync(It.IsAny(), It.IsAny()), 52 | Times.Once); 53 | } 54 | 55 | [Fact] 56 | public async Task StartAsync_StopAsync_OnSuccess_ExitCode_Is_Success() 57 | { 58 | // Arrange 59 | var cancellationToken = CancellationToken.None; 60 | 61 | InitializeTest(out var applicationLifeTime, out _, out var mainService, out var applicationHostedService); 62 | 63 | Mock.Get(mainService) 64 | .Setup(service => service.ExecuteMainAsync(It.IsAny(), It.IsAny())) 65 | .ReturnsAsync(ExitCode.Success); 66 | 67 | // Act 68 | await applicationHostedService.StartAsync(cancellationToken); 69 | applicationLifeTime.NotifyStarted(); 70 | await applicationHostedService.StopAsync(cancellationToken); 71 | 72 | // Assert 73 | Assert.Equal((int)ExitCode.Success, Environment.ExitCode); 74 | } 75 | 76 | [Fact] 77 | public async Task StartAsync_StopAsync_OnEarlyCancel_Never_Invokes_MainService_Main() 78 | { 79 | // Arrange 80 | var cancellationTokenSource = new CancellationTokenSource(); 81 | var cancellationToken = cancellationTokenSource.Token; 82 | 83 | InitializeTest(out var applicationLifeTime, out _, out var mainService, out var applicationHostedService); 84 | 85 | // Act 86 | await cancellationTokenSource.CancelAsync(); 87 | await applicationHostedService.StartAsync(cancellationToken); 88 | applicationLifeTime.NotifyStarted(); 89 | await applicationHostedService.StopAsync(cancellationToken); 90 | 91 | // Assert 92 | Mock.Get(mainService).Verify(service => 93 | service.ExecuteMainAsync(It.IsAny(), It.IsAny()), 94 | Times.Never); 95 | } 96 | 97 | [Fact] 98 | public async Task StartAsync_StopAsync_OnEarlyCancel_ExitCode_Is_Aborted() 99 | { 100 | // Arrange 101 | var cancellationTokenSource = new CancellationTokenSource(); 102 | var cancellationToken = cancellationTokenSource.Token; 103 | 104 | InitializeTest(out var applicationLifeTime, out _, out _, out var applicationHostedService); 105 | 106 | // Act 107 | await cancellationTokenSource.CancelAsync(); 108 | await applicationHostedService.StartAsync(cancellationToken); 109 | applicationLifeTime.NotifyStarted(); 110 | await applicationHostedService.StopAsync(cancellationToken); 111 | 112 | // Assert 113 | Assert.Equal((int)ExitCode.Aborted, Environment.ExitCode); 114 | } 115 | 116 | [Fact] 117 | public async Task StartAsync_StopAsync_OnTaskCancelledException_ExitCode_Is_Cancelled() 118 | { 119 | // Arrange 120 | var cancellationToken = CancellationToken.None; 121 | 122 | InitializeTest(out var applicationLifeTime, out _, out var mainService, out var applicationHostedService); 123 | 124 | Mock.Get(mainService) 125 | .Setup(service => service.ExecuteMainAsync(It.IsAny(), It.IsAny())) 126 | .Throws(); 127 | 128 | // Act 129 | await applicationHostedService.StartAsync(cancellationToken); 130 | applicationLifeTime.NotifyStarted(); 131 | await applicationHostedService.StopAsync(cancellationToken); 132 | 133 | // Assert 134 | Assert.Equal((int)ExitCode.Cancelled, Environment.ExitCode); 135 | } 136 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp/HttpClient/HttpClientPolicy.cs: -------------------------------------------------------------------------------- 1 | using Polly; 2 | using Polly.Extensions.Http; 3 | using Polly.Timeout; 4 | 5 | namespace GenericHostConsoleApp.HttpClient; 6 | 7 | /// 8 | /// Provides a set of Polly-based policies for handling transient errors, implementing circuit breaker patterns, and 9 | /// managing timeouts in HTTP-based operations. 10 | /// 11 | public static class HttpClientPolicy 12 | { 13 | /// 14 | /// Creates and returns a Polly retry policy for handling transient HTTP errors. 15 | /// The policy retries failed HTTP requests with exponential backoff and supports customizable retry logic. 16 | /// 17 | /// The number of retry attempts to execute before failing. Defaults to 3 if not provided. 18 | /// 19 | /// Optional user-defined action invoked between retries. Provides details on the outcome, wait duration, retry 20 | /// attempt, and execution context. 21 | /// 22 | /// An asynchronous retry policy configured to handle transient HTTP errors and retry specified failures. 23 | public static IAsyncPolicy GetRetryPolicy( 24 | int retryCount = 3, 25 | Action, TimeSpan, int, Context>? sleepDurationProvider = null) 26 | { 27 | return HttpPolicyExtensions 28 | .HandleTransientHttpError() // Handles 5xx and 408 status codes 29 | .OrResult(msg => (int)msg.StatusCode == 429) // Handle Too Many Requests (429) 30 | .WaitAndRetryAsync(retryCount, retryAttempt => 31 | TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)), // Exponential backoff 32 | sleepDurationProvider); 33 | } 34 | 35 | /// 36 | /// Creates and returns a Polly circuit breaker policy for handling transient HTTP errors and timeout exceptions. 37 | /// The policy breaks the circuit after a specified number of consecutive failures and remains open for a defined 38 | /// duration. 39 | /// 40 | /// 41 | /// The number of consecutive failures to allow before breaking the circuit. Defaults to 5 if not provided. 42 | /// 43 | /// 44 | /// The duration of time the circuit will remain open before resetting. Defaults to 30 seconds if not provided. 45 | /// 46 | /// 47 | /// An optional callback executed when the circuit transitions to an open state. 48 | /// 49 | /// 50 | /// An optional callback executed to modify context information when handling circuit breaker events. 51 | /// 52 | /// An asynchronous circuit breaker policy for managing transient HTTP errors and timeout exceptions. 53 | public static IAsyncPolicy GetCircuitBreakerPolicy( 54 | int handledEventsAllowedBeforeBreaking = 5, 55 | TimeSpan durationOfBreak = default, 56 | Action, TimeSpan, Context>? onBreak = null, 57 | Action? onReset = null) 58 | { 59 | return HttpPolicyExtensions 60 | .HandleTransientHttpError() // Handles 5xx and 408 status codes 61 | .Or() // Add timeout exceptions to the circuit breaker 62 | .CircuitBreakerAsync( 63 | handledEventsAllowedBeforeBreaking, 64 | durationOfBreak == TimeSpan.Zero ? TimeSpan.FromSeconds(30) : durationOfBreak, 65 | onBreak, 66 | onReset); 67 | } 68 | 69 | /// 70 | /// Creates and returns a Polly timeout policy for handling HTTP requests with specified timeouts. 71 | /// The policy enforces a timeout duration, handles timeout scenarios, and applies fallback logic for timed-out requests. 72 | /// 73 | /// The maximum duration allowed for a request before it times out. 74 | /// Specifies the timeout strategy to use, such as optimistic or pessimistic. 75 | /// 76 | /// An asynchronous callback invoked when a timeout occurs, providing details about the request context, 77 | /// timeout duration, the timed-out task, and the associated exception. 78 | /// 79 | /// 80 | /// A fallback action executed when a timeout is handled. This action generates an alternative 81 | /// to use after a timeout. 82 | /// 83 | /// 84 | /// An asynchronous callback triggered when the fallback action is executed, providing details about the fallback result. 85 | /// 86 | /// 87 | /// An asynchronous timeout policy configured to enforce a timeout, manage timeout scenarios, and apply a fallback logic 88 | /// for handling timed-out requests. 89 | /// 90 | public static IAsyncPolicy GetTimeoutPolicy( 91 | TimeSpan timeoutDuration, 92 | TimeoutStrategy timeoutStrategy, 93 | Func onTimeoutAsync, 94 | Func> fallbackAction, 95 | Func, Task> onFallbackAsync) 96 | { 97 | return Policy 98 | .Handle() // Handle timeout exceptions 99 | .FallbackAsync(fallbackAction, onFallbackAsync) 100 | .WrapAsync( 101 | Policy.TimeoutAsync( 102 | timeoutDuration, 103 | timeoutStrategy, 104 | onTimeoutAsync 105 | ) 106 | ); 107 | } 108 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GenericHostConsoleApp 2 | 3 | A console app example using .NET Generic Host. 4 | 5 | This code is derived from [David Federman's](https://github.com/dfederm) original 6 | code: https://github.com/dfederm/GenericHostConsoleApp 7 | 8 | For more details, refer to his original [blog post](https://dfederm.com/building-a-console-app-with-.net-generic-host/). 9 | 10 | This version adds a few extra bells and whistles such as: 11 | 12 | * Separating the main application logic from the boilerplate startup code. There is now a separate MainService class, 13 | which can be easily modified to implement custom logic without having to worry about all the application plumbing 14 | required to wire up the application hosted service. 15 | * Moved classes into subdirectories corresponding to the class' area of concern. E.g. Configuration, Services, 16 | Interfaces. 17 | * Using an **ExitCode** enum to define return values. 18 | * [Compile-time logging source generation](https://docs.microsoft.com/en-us/dotnet/core/extensions/logger-message-generator) 19 | . 20 | * [Validation of configuration options](https://docs.microsoft.com/en-us/dotnet/core/extensions/options#options-validation) 21 | . 22 | * [Serilog](https://serilog.net) as the logging provider. 23 | * Unit tests using xUnit and Moq. 24 | * This reference implementation uses a weather forecast service that fetches the weather from [Open Weather](https://openweathermap.org). 25 | 26 | ## Program.cs 27 | 28 | ```C# 29 | using GenericHostConsoleApp.Configuration; 30 | using GenericHostConsoleApp.Services; 31 | using GenericHostConsoleApp.Services.Interfaces; 32 | using Microsoft.Extensions.Configuration; 33 | using Microsoft.Extensions.DependencyInjection; 34 | using Microsoft.Extensions.Hosting; 35 | using Microsoft.Extensions.Logging; 36 | using Serilog; 37 | 38 | // Create the host builder 39 | var builder = Host.CreateApplicationBuilder(args); 40 | 41 | // Configure configuration sources 42 | builder.Configuration.AddEnvironmentVariables(); 43 | 44 | // Add command-line arguments with mappings 45 | builder.Configuration.AddCommandLine(args, new Dictionary 46 | { 47 | { "-n", "Name" }, 48 | { "--name", "Name" } 49 | }); 50 | 51 | // Configure logging 52 | builder.Logging.ClearProviders(); // Remove default providers 53 | builder.Services.AddSerilog((_, configuration) => 54 | configuration.ReadFrom.Configuration(builder.Configuration)); 55 | 56 | // Configure services 57 | builder.Services.AddHostedService(); 58 | builder.Services.AddTransient(); 59 | builder.Services.AddTransient(); 60 | builder.Services.AddHttpClient(); 61 | 62 | // Configure options and validation 63 | builder.Services 64 | .AddOptions() 65 | .Bind(builder.Configuration.GetSection(nameof(WeatherForecastServiceOptions))) 66 | .ValidateDataAnnotations() 67 | .ValidateOnStart(); 68 | 69 | // Build and run the host 70 | using var host = builder.Build(); 71 | 72 | try 73 | { 74 | await host.RunAsync(); 75 | } 76 | finally 77 | { 78 | Log.CloseAndFlush(); 79 | } 80 | ``` 81 | 82 | This code snippet sets up a HostApplicationBuilder and adds a hosted service called ApplicationHostedService. This service will run in the background and perform tasks as defined in its implementation. You can reuse ApplicationHostedService to handle the application's lifecycle and background tasks. 83 | This example also demonstrates how to use dependency injection to inject the IMainService and IWeatherForecastService dependencies into the ApplicationHostedService. 84 | Running the Application 85 | 86 | ## Implementing the main application logic in IMainService.ExecuteMainAsync 87 | 88 | The MainService class contains the main application logic. Here's an example of what the IMainService.ExecuteMainAsync method might look like: 89 | 90 | ```C# 91 | public async Task ExecuteMainAsync(string[] args) 92 | { 93 | // 1. Get the weather forecast 94 | var weatherForecast = await _weatherForecastService.GetWeatherForecastAsync(args); 95 | 96 | // 2. Log the weather forecast 97 | _logger.LogInformation("Weather forecast for {City}: {Forecast}", args[0], weatherForecast); 98 | 99 | // 3. Do something with the weather forecast... 100 | } 101 | ``` 102 | 103 | This method retrieves the weather forecast from the IWeatherForecastService, logs it, and then performs some action with the data. You can modify this method to implement your own application logic. 104 | 105 | ## Notes: 106 | * When you run the project in the Development environment (`DOTNET_ENVIRONMENT=Development`), be sure to specify your [Open Weather](https://openweathermap.org) API key in a .NET User Secrets file. 107 | 108 | ```json 109 | { 110 | "WeatherForecastServiceOptions": { 111 | "ApiKey": "123456789123456789" 112 | } 113 | } 114 | ``` 115 | 116 | When running in the Production environment (`DOTNET_ENVIRONMENT=Production`), you can specify the API key by setting the following environment variable: 117 | 118 | ```bash 119 | WeatherForecastServiceOptions__ApiKey=123456789123456789 120 | ``` 121 | 122 | * To run, specify the name of the place to get the weather forecast using the command line as follows: 123 | 124 | ```bash 125 | dotnet run -- --name Berlin 126 | ``` 127 | 128 | If you want to use .NET User Secrets, you might want to specify the environment name either via the DOTNET_ENVIRONMENT variable, or via the --environment option of dotnet: 129 | 130 | ```bash 131 | dotnet run --environment "Development" -- --name Berlin 132 | ``` 133 | 134 | The output should look as follows: 135 | ``` 136 | [01/08/2025 21:18:28 +10:00 Information] Weather forecast for "Berlin", "DE": Temperature: 4"ºC" (feels like -1"ºC"), Min: 2"ºC", Max: 4"ºC". Weather: "few clouds" {SourceContext="GenericHostConsoleApp.Services.MainService", ThreadId=10} 137 | ``` 138 | 139 | [![.NET](https://github.com/egarcia74/GenericHostConsoleApp/actions/workflows/dotnet.yml/badge.svg)](https://github.com/egarcia74/GenericHostConsoleApp/actions/workflows/dotnet.yml) 140 | -------------------------------------------------------------------------------- /GenericHostConsoleApp/HttpClient/HttpClientConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using Microsoft.Extensions.Configuration; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Logging; 5 | using Polly.Timeout; 6 | 7 | namespace GenericHostConsoleApp.HttpClient; 8 | 9 | /// 10 | /// A static class that provides methods for configuring HTTP clients 11 | /// with resilience and transient fault-handling policies using Polly integration. 12 | /// 13 | // ReSharper disable UnusedType.Global 14 | // ReSharper disable UnusedMember.Global 15 | // ReSharper disable UnusedMethodReturnValue.Local 16 | public static class HttpClientConfiguration 17 | { 18 | /// 19 | /// Registers HTTP clients with resilience and transient fault-handling policies using Polly. 20 | /// 21 | /// The to add the HTTP clients and policies to. 22 | /// 23 | /// The application's to provide necessary settings for the HTTP 24 | /// client setup. 25 | /// 26 | /// The same instance so that multiple calls can be chained. 27 | // ReSharper disable once UnusedMethodReturnValue.Global 28 | public static IServiceCollection AddHttpClientsWithPolicies( 29 | this IServiceCollection services, 30 | IConfiguration configuration) 31 | { 32 | services.AddOpenWeatherHttpClient(configuration); 33 | 34 | return services; 35 | } 36 | 37 | /// 38 | /// Configures the OpenWeather HTTP client with a base address, default request headers, 39 | /// and applies resilience and transient fault-handling policies such as retry, circuit breaker, and timeout. 40 | /// 41 | /// The to which the OpenWeather HTTP client is added. 42 | /// The used to retrieve the base address for the HTTP client. 43 | /// The same instance to allow method chaining. 44 | private static IServiceCollection AddOpenWeatherHttpClient( 45 | this IServiceCollection services, 46 | IConfiguration configuration) 47 | { 48 | // Retrieve the BaseAddress for OpenWeatherHttpClient from the configuration 49 | const string clientName = nameof(HttpClientName.OpenWeather); 50 | 51 | var baseAddress = configuration.GetValue($"HttpClients:{clientName}:BaseAddress"); 52 | 53 | if (string.IsNullOrWhiteSpace(baseAddress)) 54 | throw new ArgumentNullException(nameof(configuration), $"{clientName} BaseAddress is not configured."); 55 | 56 | var retries = configuration.GetValue($"HttpClients:{clientName}:Retries") ?? 5; 57 | 58 | var handledEventsBeforeBreaking = 59 | configuration.GetValue($"HttpClients:{clientName}:EventsBeforeBreaking") ?? 5; 60 | 61 | var durationOfBreak = 62 | configuration.GetValue($"HttpClients:{clientName}:DurationOfBreak") ?? 63 | TimeSpan.FromMinutes(5); 64 | 65 | var timeout = 66 | configuration.GetValue($"HttpClients:{clientName}:Timeout") ?? 67 | TimeSpan.FromMinutes(5); 68 | 69 | var logger = services.BuildServiceProvider().GetRequiredService>(); 70 | 71 | services 72 | .AddHttpClient(clientName, client => 73 | { 74 | client.BaseAddress = new Uri(baseAddress); 75 | client.DefaultRequestHeaders.Add("Accept", "application/json"); 76 | }) 77 | .AddPolicyHandler(HttpClientPolicy.GetTimeoutPolicy(timeout, TimeoutStrategy.Pessimistic, 78 | async (context, timespan, _, exception) => 79 | { 80 | // Log the timeout exception here if needed (optional) 81 | logger.LogWarning( 82 | "Policy {ContextPolicyKey} timeout occurred after {TotalSeconds} seconds. Exception: {ExceptionMessage}", 83 | context.PolicyKey, 84 | timespan.TotalSeconds, 85 | exception.Message); 86 | 87 | await Task.CompletedTask; 88 | }, 89 | async cancellationToken => 90 | { 91 | cancellationToken.ThrowIfCancellationRequested(); 92 | 93 | logger.LogWarning("Request timed out after {TotalSeconds}", timeout.TotalSeconds); 94 | 95 | return await Task.FromResult(new HttpResponseMessage(HttpStatusCode.RequestTimeout) 96 | { 97 | Content = new StringContent("The request timed out.") 98 | }); 99 | }, 100 | async result => 101 | { 102 | logger.LogWarning("Fallback executed for {ClientName}. Exception: {Message}", clientName, 103 | result.Exception?.Message); 104 | 105 | await Task.CompletedTask; 106 | })) 107 | .AddPolicyHandler(HttpClientPolicy.GetRetryPolicy( 108 | retries, 109 | (outcome, timespan, retryAttempt, context) => 110 | { 111 | logger.LogWarning( 112 | "Policy {ContextPolicyKey} retry {RetryAttempt} for {ClientName}. Waiting {TotalSeconds} seconds. Exception: {ExceptionMessage}", 113 | context.PolicyKey, 114 | retryAttempt, 115 | clientName, 116 | timespan.TotalSeconds, 117 | outcome.Exception?.Message); 118 | })) 119 | .AddPolicyHandler(HttpClientPolicy.GetCircuitBreakerPolicy(handledEventsBeforeBreaking, durationOfBreak, 120 | (response, timespan, context) => 121 | { 122 | logger.LogWarning( 123 | "Policy {ContextPolicyKey} circuit broken for {TotalSeconds} seconds. Exception: {ExceptionMessage}", 124 | context.PolicyKey, 125 | timespan.TotalSeconds, 126 | response.Exception.Message); 127 | }, 128 | context => { logger.LogInformation("Policy {ContextPolicyKey} circuit reset", context.PolicyKey); })) 129 | ; 130 | 131 | return services; 132 | } 133 | } -------------------------------------------------------------------------------- /GenericHostConsoleApp/Services/MainService.cs: -------------------------------------------------------------------------------- 1 | using GenericHostConsoleApp.Helpers; 2 | using GenericHostConsoleApp.Models.WeatherForecast; 3 | using GenericHostConsoleApp.Services.Interfaces; 4 | using Microsoft.Extensions.Configuration; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace GenericHostConsoleApp.Services; 8 | 9 | /// 10 | /// Main service responsible for executing the core application logic. 11 | /// 12 | public sealed class MainService( 13 | IConfiguration configuration, 14 | ILogger logger, 15 | IWeatherForecastService weatherForecastService) : IMainService 16 | { 17 | /// 18 | /// Retrieves the name of the place to get the weather for from the application's configuration. 19 | /// 20 | /// 21 | /// Returns the name as a string if it exists in the configuration; otherwise, throws an 22 | /// InvalidOperationException. 23 | /// 24 | /// 25 | /// Thrown when the name configuration key is not specified or the value is 26 | /// empty. 27 | /// 28 | private string Name => GetConfigurationValue("Name"); 29 | 30 | /// 31 | /// Retrieves the temperature unit configuration value from the application settings. 32 | /// 33 | /// 34 | /// Returns the configured temperature unit of type . 35 | private TemperatureUnit TemperatureUnit 36 | { 37 | get 38 | { 39 | const string key = "TemperatureUnit"; 40 | 41 | var temperatureUnit = configuration.GetValue(key); 42 | 43 | if (string.IsNullOrEmpty(temperatureUnit)) 44 | throw new InvalidOperationException($"Configuration key '{key}' not specified or is empty."); 45 | 46 | if (!Enum.TryParse(temperatureUnit, true, out var decodedTemperatureUnit)) 47 | throw new InvalidOperationException( 48 | $"The value '{temperatureUnit}' for the key '{key}' is not a valid TemperatureUnit."); 49 | 50 | return decodedTemperatureUnit; 51 | } 52 | } 53 | 54 | /// 55 | /// Executes the main functionality of the application. 56 | /// 57 | /// An array of command-line arguments passed to the application. 58 | /// A token that can be used to signal the operation should be canceled. 59 | /// Returns an indicating the result of the execution. 60 | public async Task ExecuteMainAsync(string[] args, CancellationToken cancellationToken) 61 | { 62 | var weatherForecast = await weatherForecastService 63 | .FetchWeatherForecastAsync(Name, cancellationToken) 64 | .ConfigureAwait(false); 65 | 66 | ProcessWeatherForecast(weatherForecast); 67 | 68 | return ExitCode.Success; 69 | } 70 | 71 | /// 72 | /// Retrieves the value of a specified configuration key. 73 | /// 74 | /// The configuration key whose value needs to be retrieved. 75 | /// The value of the specified configuration key. 76 | /// Thrown if the configuration key is not specified or the value is empty. 77 | private string GetConfigurationValue(string key) 78 | { 79 | var value = configuration.GetValue(key); 80 | 81 | if (string.IsNullOrEmpty(value)) 82 | throw new InvalidOperationException( 83 | $"Configuration key '{key}' not specified or is empty. Please specify a value for '{key}'."); 84 | 85 | return value; 86 | } 87 | 88 | /// 89 | /// Processes the weather forecast data by converting temperature values 90 | /// from Kelvin to Celsius and logging the information. 91 | /// 92 | /// The weather response data containing the forecast details. 93 | /// Weather response does not contain necessary data. 94 | private void ProcessWeatherForecast(WeatherResponse weatherResponse) 95 | { 96 | ArgumentNullException.ThrowIfNull(weatherResponse); 97 | 98 | if (weatherResponse.Main is null || weatherResponse.Weather is null || weatherResponse.Weather.Count == 0 || 99 | weatherResponse.Sys is null) 100 | throw new InvalidOperationException("Weather response does not contain necessary data."); 101 | 102 | LogWeather(weatherResponse, TemperatureUnit); 103 | } 104 | 105 | /// 106 | /// Logs the weather forecast details in the specified temperature unit. 107 | /// 108 | /// The weather response data to be logged. 109 | /// The unit in which the temperature should be logged (Celsius, Fahrenheit, or Kelvin). 110 | /// Unknown temperature unit. 111 | private void LogWeather(WeatherResponse weatherResponse, TemperatureUnit temperatureUnit) 112 | { 113 | var temperature = 114 | TemperatureConverter.ConvertTemperature( 115 | weatherResponse.Main!.Temp, 116 | TemperatureUnit.Kelvin, 117 | temperatureUnit); 118 | 119 | var feelsLike = TemperatureConverter.ConvertTemperature( 120 | weatherResponse.Main.FeelsLike, 121 | TemperatureUnit.Kelvin, 122 | temperatureUnit); 123 | 124 | var tempMin = 125 | TemperatureConverter.ConvertTemperature( 126 | weatherResponse.Main.TempMin, 127 | TemperatureUnit.Kelvin, 128 | temperatureUnit); 129 | 130 | var tempMax = 131 | TemperatureConverter.ConvertTemperature( 132 | weatherResponse.Main.TempMax, 133 | TemperatureUnit.Kelvin, 134 | temperatureUnit); 135 | 136 | var unitSymbol = temperatureUnit switch 137 | { 138 | TemperatureUnit.Celsius => "ºC", 139 | TemperatureUnit.Fahrenheit => "ºF", 140 | TemperatureUnit.Kelvin => "ºK", 141 | _ => throw new InvalidOperationException($"Unknown temperature unit: {temperatureUnit}") 142 | }; 143 | 144 | logger.LogInformation( 145 | // ReSharper disable TemplateDuplicatePropertyProblem 146 | "Weather forecast for {Name}, {Country}: Temperature: {Temperature:0}{UnitSymbol} (feels like {FeelsLike:0}{UnitSymbol}), Min: {TempMin:0}{UnitSymbol}, Max: {TempMax:0}{UnitSymbol}. Weather: {WeatherDescription}", 147 | weatherResponse.Name ?? "Unknown", 148 | weatherResponse.Sys?.Country ?? "Unknown", 149 | temperature, 150 | unitSymbol, 151 | feelsLike, 152 | unitSymbol, 153 | tempMin, 154 | unitSymbol, 155 | tempMax, 156 | unitSymbol, 157 | weatherResponse.Weather?.First().Description); 158 | } 159 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio 2015/2017 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # Visual Studio 2017 auto generated files 40 | Generated\ Files/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUnit 47 | *.VisualState.xml 48 | TestResult.xml 49 | nunit-*.xml 50 | 51 | # Build Results of an ATL Project 52 | [Dd]ebugPS/ 53 | [Rr]eleasePS/ 54 | dlldata.c 55 | 56 | # Benchmark Results 57 | BenchmarkDotNet.Artifacts/ 58 | 59 | # .NET Core 60 | project.lock.json 61 | project.fragment.lock.json 62 | artifacts/ 63 | 64 | # StyleCop 65 | StyleCopReport.xml 66 | 67 | # Files built by Visual Studio 68 | *_i.c 69 | *_p.c 70 | *_h.h 71 | *.ilk 72 | *.meta 73 | *.obj 74 | *.iobj 75 | *.pch 76 | *.pdb 77 | *.ipdb 78 | *.pgc 79 | *.pgd 80 | *.rsp 81 | *.sbr 82 | *.tlb 83 | *.tli 84 | *.tlh 85 | *.tmp 86 | *.tmp_proj 87 | *_wpftmp.csproj 88 | *.log 89 | *.vspscc 90 | *.vssscc 91 | .builds 92 | *.pidb 93 | *.svclog 94 | *.scc 95 | 96 | # Chutzpah Test files 97 | _Chutzpah* 98 | 99 | # Visual C++ cache files 100 | ipch/ 101 | *.aps 102 | *.ncb 103 | *.opendb 104 | *.opensdf 105 | *.sdf 106 | *.cachefile 107 | *.VC.db 108 | *.VC.VC.opendb 109 | 110 | # Visual Studio profiler 111 | *.psess 112 | *.vsp 113 | *.vspx 114 | *.sap 115 | 116 | # Visual Studio Trace Files 117 | *.e2e 118 | 119 | # TFS 2012 Local Workspace 120 | $tf/ 121 | 122 | # Guidance Automation Toolkit 123 | *.gpState 124 | 125 | # ReSharper is a .NET coding add-in 126 | _ReSharper*/ 127 | *.[Rr]e[Ss]harper 128 | *.DotSettings.user 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | *.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # NuGet Symbol Packages 188 | *.snupkg 189 | # The packages folder can be ignored because of Package Restore 190 | **/[Pp]ackages/* 191 | # except build/, which is used as an MSBuild target. 192 | !**/[Pp]ackages/build/ 193 | # Uncomment if necessary however generally it will be regenerated when needed 194 | #!**/[Pp]ackages/repositories.config 195 | # NuGet v3's project.json files produces more ignorable files 196 | *.nuget.props 197 | *.nuget.targets 198 | 199 | # Microsoft Azure Build Output 200 | csx/ 201 | *.build.csdef 202 | 203 | # Microsoft Azure Emulator 204 | ecf/ 205 | rcf/ 206 | 207 | # Windows Store app package directories and files 208 | AppPackages/ 209 | BundleArtifacts/ 210 | Package.StoreAssociation.xml 211 | _pkginfo.txt 212 | *.appx 213 | *.appxbundle 214 | *.appxupload 215 | 216 | # Visual Studio cache files 217 | # files ending in .cache can be ignored 218 | *.[Cc]ache 219 | # but keep track of directories ending in .cache 220 | !?*.[Cc]ache/ 221 | 222 | # Others 223 | ClientBin/ 224 | ~$* 225 | *~ 226 | *.dbmdl 227 | *.dbproj.schemaview 228 | *.jfm 229 | *.pfx 230 | *.publishsettings 231 | orleans.codegen.cs 232 | 233 | # Including strong name files can present a security risk 234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 235 | #*.snk 236 | 237 | # Since there are multiple workflows, uncomment next line to ignore bower_components 238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 239 | #bower_components/ 240 | 241 | # RIA/Silverlight projects 242 | Generated_Code/ 243 | 244 | # Backup & report files from converting an old project file 245 | # to a newer Visual Studio version. Backup files are not needed, 246 | # because we have git ;-) 247 | _UpgradeReport_Files/ 248 | Backup*/ 249 | UpgradeLog*.XML 250 | UpgradeLog*.htm 251 | ServiceFabricBackup/ 252 | *.rptproj.bak 253 | 254 | # SQL Server files 255 | *.mdf 256 | *.ldf 257 | *.ndf 258 | 259 | # Business Intelligence projects 260 | *.rdl.data 261 | *.bim.layout 262 | *.bim_*.settings 263 | *.rptproj.rsuser 264 | *- [Bb]ackup.rdl 265 | *- [Bb]ackup ([0-9]).rdl 266 | *- [Bb]ackup ([0-9][0-9]).rdl 267 | 268 | # Microsoft Fakes 269 | FakesAssemblies/ 270 | 271 | # GhostDoc plugin setting file 272 | *.GhostDoc.xml 273 | 274 | # Node.js Tools for Visual Studio 275 | .ntvs_analysis.dat 276 | node_modules/ 277 | 278 | # Visual Studio 6 build log 279 | *.plg 280 | 281 | # Visual Studio 6 workspace options file 282 | *.opt 283 | 284 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 285 | *.vbw 286 | 287 | # Visual Studio LightSwitch build output 288 | **/*.HTMLClient/GeneratedArtifacts 289 | **/*.DesktopClient/GeneratedArtifacts 290 | **/*.DesktopClient/ModelManifest.xml 291 | **/*.Server/GeneratedArtifacts 292 | **/*.Server/ModelManifest.xml 293 | _Pvt_Extensions 294 | 295 | # Paket dependency manager 296 | .paket/paket.exe 297 | paket-files/ 298 | 299 | # FAKE - F# Make 300 | .fake/ 301 | 302 | # CodeRush personal settings 303 | .cr/personal 304 | 305 | # Python Tools for Visual Studio (PTVS) 306 | __pycache__/ 307 | *.pyc 308 | 309 | # Cake - Uncomment if you are using it 310 | # tools/** 311 | # !tools/packages.config 312 | 313 | # Tabs Studio 314 | *.tss 315 | 316 | # Telerik's JustMock configuration file 317 | *.jmconfig 318 | 319 | # BizTalk build output 320 | *.btp.cs 321 | *.btm.cs 322 | *.odx.cs 323 | *.xsd.cs 324 | 325 | # OpenCover UI analysis results 326 | OpenCover/ 327 | 328 | # Azure Stream Analytics local run output 329 | ASALocalRun/ 330 | 331 | # MSBuild Binary and Structured Log 332 | *.binlog 333 | 334 | # NVidia Nsight GPU debugger configuration file 335 | *.nvuser 336 | 337 | # MFractors (Xamarin productivity tool) working folder 338 | .mfractor/ 339 | 340 | # Local History for Visual Studio 341 | .localhistory/ 342 | 343 | # BeatPulse healthcheck temp database 344 | healthchecksdb 345 | 346 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 347 | MigrationBackup/ 348 | 349 | # Ionide (cross platform F# VS Code tools) working folder 350 | .ionide/ 351 | 352 | .run/ 353 | 354 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 355 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 356 | 357 | .idea/ 358 | 359 | # User-specific stuff 360 | .idea/**/workspace.xml 361 | .idea/**/tasks.xml 362 | .idea/**/usage.statistics.xml 363 | .idea/**/dictionaries 364 | .idea/**/shelf 365 | 366 | # AWS User-specific 367 | .idea/**/aws.xml 368 | 369 | # Generated files 370 | .idea/**/contentModel.xml 371 | 372 | # Sensitive or high-churn files 373 | .idea/**/dataSources/ 374 | .idea/**/dataSources.ids 375 | .idea/**/dataSources.local.xml 376 | .idea/**/sqlDataSources.xml 377 | .idea/**/dynamic.xml 378 | .idea/**/uiDesigner.xml 379 | .idea/**/dbnavigator.xml 380 | 381 | # Gradle 382 | .idea/**/gradle.xml 383 | .idea/**/libraries 384 | 385 | # Gradle and Maven with auto-import 386 | # When using Gradle or Maven with auto-import, you should exclude module files, 387 | # since they will be recreated, and may cause churn. Uncomment if using 388 | # auto-import. 389 | # .idea/artifacts 390 | # .idea/compiler.xml 391 | # .idea/jarRepositories.xml 392 | # .idea/modules.xml 393 | # .idea/*.iml 394 | # .idea/modules 395 | # *.iml 396 | # *.ipr 397 | 398 | # CMake 399 | cmake-build-*/ 400 | 401 | # Mongo Explorer plugin 402 | .idea/**/mongoSettings.xml 403 | 404 | # File-based project format 405 | *.iws 406 | 407 | # IntelliJ 408 | out/ 409 | 410 | # mpeltonen/sbt-idea plugin 411 | .idea_modules/ 412 | 413 | # JIRA plugin 414 | atlassian-ide-plugin.xml 415 | 416 | # Cursive Clojure plugin 417 | .idea/replstate.xml 418 | 419 | # SonarLint plugin 420 | .idea/sonarlint/ 421 | 422 | # Crashlytics plugin (for Android Studio and IntelliJ) 423 | com_crashlytics_export_strings.xml 424 | crashlytics.properties 425 | crashlytics-build.properties 426 | fabric.properties 427 | 428 | # Editor-based Rest Client 429 | .idea/httpRequests 430 | 431 | # Android studio 3.1+ serialized cache file 432 | .idea/caches/build_file_checksums.ser 433 | .vscode/launch.json 434 | .vscode/tasks.json 435 | -------------------------------------------------------------------------------- /GenericHostConsoleApp/Services/ApplicationHostedService.cs: -------------------------------------------------------------------------------- 1 | using GenericHostConsoleApp.Helpers; 2 | using GenericHostConsoleApp.Services.Interfaces; 3 | using Microsoft.Extensions.Hosting; 4 | using Microsoft.Extensions.Logging; 5 | using Polly.Timeout; 6 | 7 | namespace GenericHostConsoleApp.Services; 8 | 9 | /// 10 | /// Represents a hosted service in an ASP.NET Core application or a .NET Core worker. 11 | /// A hosted service is a service that runs long-running background tasks, which typically run over the lifetime of 12 | /// your application. 13 | /// 14 | public sealed class ApplicationHostedService : IHostedService, IDisposable 15 | { 16 | // Service dependencies. 17 | private readonly IHostApplicationLifetime _hostApplicationLifetime; 18 | private readonly ILogger _logger; 19 | private readonly IMainService _mainService; 20 | 21 | // Cancellation token source used to submit a cancellation request. 22 | private CancellationTokenSource? _cancellationTokenSource; 23 | 24 | // Task that executes the main service. 25 | private Task? _mainTask; 26 | 27 | /// 28 | /// Initialises the using the specified dependencies. 29 | /// 30 | /// Notifies of application lifetime events. 31 | /// The logger to use within this service. 32 | /// The main service. 33 | public ApplicationHostedService( 34 | IHostApplicationLifetime hostApplicationLifetime, 35 | ILogger logger, 36 | IMainService mainService) 37 | { 38 | _hostApplicationLifetime = hostApplicationLifetime; 39 | _logger = logger; 40 | _mainService = mainService; 41 | } 42 | 43 | /// 44 | /// Disposes the resources. 45 | /// 46 | public void Dispose() 47 | { 48 | _cancellationTokenSource?.Cancel(); 49 | _cancellationTokenSource?.Dispose(); 50 | } 51 | 52 | /// 53 | /// Triggered when the application host is ready to start the service. 54 | /// 55 | /// Indicates that the start process has been aborted. 56 | public Task StartAsync(CancellationToken cancellationToken) 57 | { 58 | _logger.LogApplicationStarting(); 59 | 60 | // Initialise the cancellation token source so that it is in the cancelled state if the 61 | // supplied token is in the cancelled state. 62 | _cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); 63 | 64 | // Bail if the start process has been aborted. 65 | if (_cancellationTokenSource.IsCancellationRequested) 66 | { 67 | _logger.LogWarning("StartAsync was aborted due to cancellation"); 68 | return Task.CompletedTask; 69 | } 70 | 71 | // 72 | // Register callbacks to handle the application lifetime events. 73 | // 74 | 75 | RegisterEventHandler(_hostApplicationLifetime, () => 76 | { 77 | var args = Environment.GetCommandLineArgs(); 78 | 79 | _logger.LogApplicationStarted(args); 80 | 81 | // Execute the main service but do not await it here. The task will be awaited in StopAsync(). 82 | _mainTask = ExecuteMainAsync(args, _cancellationTokenSource.Token); 83 | }, ApplicationLifetimeEvent.ApplicationStarted); 84 | 85 | RegisterEventHandler( 86 | _hostApplicationLifetime, 87 | _logger.LogApplicationStopping, 88 | ApplicationLifetimeEvent.ApplicationStopping); 89 | 90 | RegisterEventHandler( 91 | _hostApplicationLifetime, 92 | _logger.LogApplicationStopped, 93 | ApplicationLifetimeEvent.ApplicationStopped); 94 | 95 | return Task.CompletedTask; 96 | } 97 | 98 | /// 99 | /// Triggered when the application host is performing a graceful shutdown. 100 | /// 101 | /// Indicates that the shutdown process should no longer be graceful. 102 | public async Task StopAsync(CancellationToken cancellationToken) 103 | { 104 | ExitCode exitCode; 105 | 106 | if (_mainTask is not null) 107 | { 108 | // If the main service is still running or the passed in cancellation token is in the cancelled state 109 | // then request a cancellation. 110 | if (!_mainTask.IsCompleted || cancellationToken.IsCancellationRequested) 111 | await _cancellationTokenSource! 112 | .CancelAsync() 113 | .ConfigureAwait(false); 114 | 115 | // Wait for the main service to fully complete any cleanup tasks. 116 | // Note that this relies on the cancellation token to be properly used in the application. 117 | exitCode = await _mainTask.ConfigureAwait(false); 118 | } 119 | else 120 | { 121 | // The main service task never started. 122 | exitCode = ExitCode.Aborted; 123 | } 124 | 125 | Environment.ExitCode = (int)exitCode; 126 | 127 | _logger.LogApplicationExiting(exitCode, (int)exitCode); 128 | } 129 | 130 | /// 131 | /// Executes the main service of the application asynchronously. 132 | /// 133 | /// Command-line arguments passed to the main service. 134 | /// A token to signal the operation to cancel. 135 | /// 136 | /// A representing the result of the asynchronous operation. 137 | /// The result contains an indicating the exit status of the application. 138 | /// 139 | /// 140 | /// This method calls the Main method of the main service defined by _mainService, 141 | /// passing in command-line arguments and cancellation token. 142 | /// In case of an exception, the exception is passed to HandleException method 143 | /// to handle it and return an appropriate exit code. 144 | /// Regardless of success or failure, the application is then stopped by calling StopApplication 145 | /// method of _hostApplicationLifetime. 146 | /// 147 | private async Task ExecuteMainAsync(string[] args, CancellationToken cancellationToken) 148 | { 149 | try 150 | { 151 | return await _mainService.ExecuteMainAsync(args, cancellationToken).ConfigureAwait(false); 152 | } 153 | catch (Exception ex) 154 | { 155 | return HandleException(ex); 156 | } 157 | finally 158 | { 159 | // Stop the application once the work is done. 160 | _hostApplicationLifetime.StopApplication(); 161 | } 162 | } 163 | 164 | /// 165 | /// Handles the given exception, logs it accordingly, and determines the appropriate exit code. 166 | /// 167 | /// The exception that was thrown 168 | /// The corresponding exit code 169 | private ExitCode HandleException(Exception ex) 170 | { 171 | switch (ex) 172 | { 173 | case TaskCanceledException: 174 | case OperationCanceledException: 175 | case TimeoutRejectedException: 176 | _logger.LogApplicationCanceled(); 177 | return ExitCode.Cancelled; 178 | 179 | case AggregateException aggregateException: 180 | aggregateException.Handle(exception => 181 | { 182 | _logger.LogAggregateException(exception); 183 | return true; 184 | }); 185 | return ExitCode.Failed; 186 | 187 | default: 188 | _logger.LogUnhandledException(ex); 189 | return ExitCode.Failed; 190 | } 191 | } 192 | 193 | /// 194 | /// Registers an event handler for a specified application lifetime event. 195 | /// 196 | /// The instance. 197 | /// The action to be executed when the event is triggered. 198 | /// 199 | /// The name of the application lifetime event ("ApplicationStarted", "ApplicationStopping", or 200 | /// "ApplicationStopped"). 201 | /// 202 | private static void RegisterEventHandler( 203 | IHostApplicationLifetime hostApplicationLifetime, 204 | Action action, 205 | ApplicationLifetimeEvent lifetimeEvent) 206 | { 207 | ArgumentNullException.ThrowIfNull(hostApplicationLifetime); 208 | 209 | ArgumentNullException.ThrowIfNull(action); 210 | 211 | switch (lifetimeEvent) 212 | { 213 | case ApplicationLifetimeEvent.ApplicationStarted: 214 | hostApplicationLifetime.ApplicationStarted.Register(action); 215 | break; 216 | case ApplicationLifetimeEvent.ApplicationStopping: 217 | hostApplicationLifetime.ApplicationStopping.Register(action); 218 | break; 219 | case ApplicationLifetimeEvent.ApplicationStopped: 220 | hostApplicationLifetime.ApplicationStopped.Register(action); 221 | break; 222 | default: 223 | throw new ArgumentOutOfRangeException(nameof(lifetimeEvent), lifetimeEvent, "Unknown event name."); 224 | } 225 | } 226 | } --------------------------------------------------------------------------------