├── 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 | [](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 | }
--------------------------------------------------------------------------------