Environments
IHardenedEnvironment is the environment the application runs in: a name, the arguments it was given, values it reads by name, and custom data. A handler takes it as a parameter, like any service:
using Hardened.Shared.Runtime.Application;
using Hardened.Web.Runtime.Attributes;
namespace Todos;
public class EnvironmentController
{
[Get("/environment")]
public string Name(IHardenedEnvironment environment) => environment.Name;
}The examples on this page are in a hardened-web template application named Todos. Its library module, TodosLibrary, has [BasePath("/todos")], so the handler above answers /todos/environment.
The name comes from the environment variable HARDENED_ENVIRONMENT:
HARDENED_ENVIRONMENT=production dotnet run --project src/Todos.HostGET /todos/environment
HTTP/1.1 200 OK
Content-Type: application/json
"production"The name is development when the variable is unset:
GET /todos/environment
HTTP/1.1 200 OK
Content-Type: application/json
"development"On AWS Lambda the name is production when the variable is unset. The Lambda service sets AWS_LAMBDA_RUNTIME_API before the function starts, and that decides it. A deployed function that leaves HARDENED_ENVIRONMENT out of its configuration therefore does not serve /docs. The template's Program.cs builds its environment before it starts the local Lambda emulator, which sets the same variable, so a function run from an IDE or dotnet run is still development.
The interface is in the Hardened.Shared.Runtime package, in the namespace Hardened.Shared.Runtime.Application.
Set the name
The name keeps the case it is given. HARDENED_ENVIRONMENT=Development gives the name Development. A HARDENED_ENVIRONMENT set to an empty string gives an empty name, not development.
ASPNETCORE_ENVIRONMENT and DOTNET_ENVIRONMENT do not change the name. A command-line argument does not change it either. After dotnet run --project src/Todos.Host -- --environment production, the name is development. Arguments holds --environment and production.
Four features read the name:
| Feature | Page |
|---|---|
Matches, in application code | Match the name |
[IfEnvironment] and [IfNotEnvironment] on a registered class | Registering services |
AppConfig.Amend given an environment name | Configuration |
[HardenedOpenApiUi(Environments = ...)] | The OpenAPI document |
Register the environment
AddHardenedEnvironment registers the environment. It is an extension method on IServiceCollection, in Hardened.Shared.Runtime.Application. The template's Program.cs calls it before PopulateServiceCollection.
The method registers one instance under two service types: IHardenedEnvironment, and IModuleEnvironment from DependencyModules. IModuleEnvironment is in the namespace DependencyModules.Runtime.Interfaces. Through either service type, the environment gives the same name and values. Application code and configuration models read IHardenedEnvironment. The module system reads IModuleEnvironment while PopulateServiceCollection runs, to decide the registrations marked [IfEnvironment] and [IfNotEnvironment]. Registering services describes both attributes.
An application that registers no environment fails as it starts, with an InvalidOperationException:
Unable to resolve service for type 'Hardened.Shared.Runtime.Application.IHardenedEnvironment' while attempting to activate 'Hardened.Shared.Runtime.Configuration.ConfigurationManager'.WARNING
Register the environment with AddHardenedEnvironment, before PopulateServiceCollection. Any other registration leaves IModuleEnvironment unregistered while PopulateServiceCollection runs. AddSingleton<IHardenedEnvironment>(environment), AddTransient<IHardenedEnvironment>(...) and a call to AddHardenedEnvironment after PopulateServiceCollection all do this.
The application then runs with two environments. The module system uses an environment of its own. Its name comes from ASPNETCORE_ENVIRONMENT, then DOTNET_ENVIRONMENT. It is Production when neither is set. [IfEnvironment], [IfNotEnvironment] and the reference page at /docs follow that environment. Application code follows HARDENED_ENVIRONMENT. Nothing reports the difference.
Each host registers the environment in one place:
| Host | Registered by |
|---|---|
| Kestrel, ASP.NET Core, AWS Lambda, Google Cloud Run | The template's Program.cs, with new EnvironmentImpl(arguments: args), then AddHardenedEnvironment(environment) before PopulateServiceCollection |
| Azure Functions | The template's Program.cs passes the environment to UseHardened<Application>(environment). UseHardened calls AddHardenedEnvironment before PopulateServiceCollection. Without the argument, it builds new EnvironmentImpl(arguments: Environment.GetCommandLineArgs()) |
| Google Cloud Functions | HardenedFunctionsStartup<TApplication>, which registers new EnvironmentImpl(arguments: Environment.GetCommandLineArgs()) |
| A test | The test runner, described in Tests |
AddHardenedEnvironment has two overloads:
| Call | Registers |
|---|---|
AddHardenedEnvironment(arguments) | A new EnvironmentImpl named from HARDENED_ENVIRONMENT, with arguments as its Arguments. arguments is optional |
AddHardenedEnvironment(environment) | The environment it is given |
Either line below can replace services.AddHardenedEnvironment(environment); in the template's Program.cs. That file has using Hardened.Shared.Runtime.Application;. The first line calls AddHardenedEnvironment(arguments):
services.AddHardenedEnvironment(args);The second calls AddHardenedEnvironment(environment):
services.AddHardenedEnvironment(new EnvironmentImpl("staging"));EnvironmentImpl is the implementation every host registers. It is in Hardened.Shared.Runtime.Application. Every constructor parameter is optional:
| Parameter | Type | When omitted | Sets |
|---|---|---|---|
name | string? | HARDENED_ENVIRONMENT, or when it is unset production where AWS_LAMBDA_RUNTIME_API is set and development elsewhere. EnvironmentImpl.DefaultName decides it | Name |
environmentValues | IDictionary<string, string>? | No values | The values Value reads before the process's environment variables |
arguments | IReadOnlyList<string>? | Empty | Arguments |
customData | IDictionary<string, object>? | No custom data | The objects CustomData returns |
This line can replace var environment = new EnvironmentImpl(arguments: args); in the template's Program.cs:
var environment = new EnvironmentImpl(
name: "staging",
environmentValues: new Dictionary<string, string> { ["FEATURE_EXPORT"] = "true" },
arguments: args,
customData: new Dictionary<string, object> { ["startedAt"] = DateTimeOffset.UtcNow }
);Match the name
Matches returns true when the name equals any of the names passed to it, ignoring case. MatchesVariable(variable, value) returns true when the value of variable equals value, ignoring case. An unset variable reads as an empty string. Both are extension methods in Hardened.Shared.Runtime.Application.
The template's Program.cs uses Matches:
if (environment.Matches("development"))
{
logger.LogInformation("Browse http://localhost:{Port}/docs to access your API.", port);
}Read values
Value<T>(name, defaultValue) looks for name in the values the environment was given, then in the process's environment variables. It returns defaultValue when it finds no value. An empty value counts as unset, at each step.
This handler reads FEATURE_EXPORT:
using Hardened.Shared.Runtime.Application;
using Hardened.Web.Runtime.Attributes;
namespace Todos;
public class FeatureController
{
[Get("/features/export")]
public bool Export(IHardenedEnvironment environment) => environment.Value("FEATURE_EXPORT", false);
}FEATURE_EXPORT=true dotnet run --project src/Todos.HostGET /todos/features/export
HTTP/1.1 200 OK
Content-Type: application/json
trueWithout the variable, the body is false.
Value returns a string as it is. It converts any other type with Convert.ChangeType, using the invariant culture, as route, query and header values are read. A nullable type, such as int?, converts as its underlying type:
T | Converts | When it cannot |
|---|---|---|
string | Returned as it is | Always succeeds |
int, long, double | Parsed with the invariant culture | FormatException. OverflowException for a number out of range |
bool | true or false | FormatException. 1 throws |
DateTime | Parsed with the invariant culture | FormatException |
An enum, TimeSpan, Guid, Uri | Never | InvalidCastException |
For a type that cannot convert, Value throws only when a value is present. With no value, Value returns defaultValue without a conversion.
The exception names the variable and the type, followed by the message of the exception Convert.ChangeType threw. That exception is its InnerException. Value<int>("RETENTION_DAYS") with RETENTION_DAYS=ninety throws FormatException with this message:
The environment variable RETENTION_DAYS could not be read as Int32: The input string 'ninety' was not in a correct format.A configuration model reads its [FromEnvironmentVariable] fields through Value. The same rules apply to those fields. Configuration describes configuration models.
Arguments holds the arguments the environment was given. In the template, they are args. Nothing in the framework reads them.
Read custom data
CustomData<T>(name, defaultValue) returns the object stored under name in the environment's custom data, cast to T. It returns defaultValue when there is none. It throws InvalidCastException when the object is of another type. Custom data comes only from the customData argument. Nothing reads it from the process.
With the environment built in Register the environment, this line in Program.cs reads startedAt:
var startedAt = environment.CustomData<DateTimeOffset>("startedAt");Tests
A test's environment is named test. In a test, Value reads only the values the test declares. It does not read the process's environment variables. It converts a value that is not already of the type asked for by the rules above, so a test sees the exception a deployment would. Arguments is empty. CustomData returns defaultValue.
The test runner registers the environment under both service types before it applies the modules. [IfEnvironment] and [IfNotEnvironment] therefore see test.
[EnvironmentName] changes the name of a test's environment. [EnvironmentValue] gives the test's environment a value. Writing a test covers both.
Next
- Configuration: typed models over environment variables, and amendments for one environment
- Registering services: registrations for one environment with
[IfEnvironment]and[IfNotEnvironment] - Writing a test:
[EnvironmentName]and[EnvironmentValue] - Hosts: each host's
Program.cs