AWS Developer Tools Blog

Tips & Tricks: Delaying AWS Service configuration when using .NET Dependency Injection

Tips & Tricks: Delaying AWS Service configuration when using .NET Dependency Injection

The AWSSDK.Extensions.NETCore.Setup package provides extensions for enabling AWS Service Client creation to work with native .NET Dependency Injection. Bindings for one or more services can be registered via the included AddAWSService<TService> method and a shared configuration can be added and customized via the AddDefaultAWSOptions method.

public class Startup
{    
    public void ConfigureServices(IServiceCollection services)
    {
       // support injecting email client
       services.AddAWSService<IAmazonSimpleEmailServiceV2>();
       // customize amazon clients
       services.AddDefaultAWSOptions(new AWSOptions
       {
           Region = RegionEndpoint.USWest2
       });
   }
}

Recently, several customers reported in the AWS .NET SDK GitHub repository that they wanted to setup Dependency Injection (DI) for AWS Services and customize their configuration. In a traditional .NET Core application the DI Container, IServiceCollection, is initialized early during app startup in the Startup class. However, what if you wanted to defer initializing the AWSOptions object until later in the application lifecycle? What if you had an ASP.NET Core application and wanted to customize AWSOptions based on the incoming request as was requested in this issue?

public void ConfigureServices(IServiceCollection services)
{
    services.AddDefaultAWSOptions(new AWSOptions
    {
        // My app doesn't _yet_ have the data needed to configure AWSOptions!
    });
}

While it’s always been technically possible to add the necessary deferred binding, doing so required a deep understanding of IServiceCollection. Fortunately, isdaniel recently sent the AWS .NET SDK team a PR that added an overload to the AddDefaultAWSOptions method to greatly simplify adding a deferred binding:

public static class ServiceCollectionExtensions
{
   public static IServiceCollection AddDefaultAWSOptions(
       this IServiceCollection collection, 
       Func<IServiceProvider, AWSOptions> implementationFactory, 
       ServiceLifetime lifetime = ServiceLifetime.Singleton)
   {
       collection.Add(new ServiceDescriptor(typeof(AWSOptions), implementationFactory, lifetime));
            
       return collection;
   }
}

Customizing AWSOptions based on an incoming HttpRequest

With isdaniel’s new extension method to setup the IServiceCollection, the next question is how to use deferred binding to customize AWSOptions based on an incoming HttpRequest.

We can define and register a custom ASP.NET Middleware class to hook into the ASP.NET request pipeline and inspect the incoming request before the DI container is asked to construct any Controller classes. This is key, as the Controller depends on AWS Services, so in order for our customization to work it must execute before any AWS Service objects are built:

public class Startup
{
   // Bulk of Startup removed for brevity.  See full example at the end of this
   // blog post for a complete working example.
          
   public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
   {
       app.UseMiddleware<lRequestSpecificAWSOptionsMiddleware>();
   }
}

public class RequestSpecificAWSOptionsMiddleware
{
    private readonly RequestDelegate _next;

    public RequestSpecificAWSOptionsMiddleware(RequestDelegate next)
    {
       _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        // TODO: inspect context and then somehow set AWSOptions ...
    }
}

Connecting ASP.NET Middleware with a DI Factory
How can middleware influence the IServiceCollection, which still must be initialized in the Startup.ConfigureServices method? The approach I came up with is to use an intermediate object to store a reference to a Factory function that will build the AWSOptions object. This function will be bound at Startup, but it won’t be defined until the RequestSpecificAWSOptionsMiddleware executes. The key is that the object containing this function must also have a binding in the IServiceCollection, so that it can be injected into the middleware:

public interface IAWSOptionsFactory
{
    /// <summary>
    /// Factory function for building AWSOptions that will be defined
    /// in a custom ASP.NET middleware.
    /// </summary>
    Func<AWSOptions> AWSOptionsBuilder { get; set; }
}

public class AWSOptionsFactory : IAWSOptionsFactory
{
    public Func<AWSOptions> AWSOptionsBuilder { get; set; }
}

Now we can update our RequestSpecificAWSOptionsMiddlewareobject to consume IAWSOptionsFactory. Because we want the IAWSOptionsFactory to be Request Scope specific we can’t constructor inject the dependency, as Middleware objects have Singleton lifetimes. If we instead make it a method parameter, the ASP.NET runtime will know to treat the dependency as a Scoped lifetime, and generate a new object on every request:

public class RequestSpecificAWSOptionsMiddleware
{
   public async Task InvokeAsync(
        HttpContext context,
        IAWSOptionsFactory optionsFactory)
    {
        optionsFactory.AWSOptionsBuilder = () =>
        {
            var awsOptions = new AWSOptions();

            // SAMPLE: configure AWSOptions based on HttpContext,
            // get the region endpoint from the query string 'regionEndpoint' parameter
            if (context.Request.Query.TryGetValue("regionEndpoint", out var regionEndpoint))
            {
                awsOptions.Region = RegionEndpoint.GetBySystemName(regionEndpoint);
            }

            return awsOptions;
        };

        await _next(context);
    }
}

The middleware now assigns an optionsFactory.AWSOptionsBuilder function to return a new AWSOptions object where the Region property is set by looking for a query string parameter named regionEndpoint in the incoming HttpRequest.

Configuring Bindings

To finalize the plumbing, we’ll need to add two new bindings.

First we’ll need to bind IAWSOptionsFactory with a Scoped Lifecycle so that a new instance is created on every incoming HttpRequest, and also so said instance can be injected both into the middleware’s invoke methods as well as into the AddDefaultAWSOptions factory method.

Then we’ll bind AWSOptions using the new AddDefaultAWSOptions overload. We’ll use the passed-in reference to a ServiceProvider to get an instance of the IAWSOptionsFactory. This is the same instance that will be passed to ResolveMutitenantMiddleware, and we can then invoke AWSOptionsBuilder to build our request specific AWSOptions object!

Finally, to prove it all works, I added a binding call to AddAWSService<IAmazonSimpleEmailServiceV2>() to provide me with an AWS Service that can be injected into my API Controller. However, the lifetime must be explicitly set to Scoped as this instructs the .NET Service Collection to always create a new instance of the Client when requested, which means it will re-evaluate the AWSOptions dependency used to build the Client:

public class Startup
{    
    public void ConfigureServices(IServiceCollection services)
    {
        // note: AWSOptionsFactory.AWSOptionsBuilder func will be populated in middleware
        services.AddScoped<IAWSOptionsFactory, AWSOptionsFactory>();
        services.AddDefaultAWSOptions(sp => sp.GetService<IAWSOptionsFactory>().AWSOptionsBuilder(), ServiceLifetime.Scoped));
        
        // also, for test purposes, register an AWSService that will consume the AWSOptions
        services.AddAWSService<IAmazonSimpleEmailServiceV2>(lifetime: ServiceLifetime.Scoped);
     }
 }

Time for an API Controller

To show the whole app working we’ll need an API Controller. I’ve modified the ValuesController class that comes with the ASP.NET API template to inject an IAmazonSimpleEmailServiceV2 and return the region that the Service is configured to use:

[Route("api/[controller]")]
public class ValuesController : ControllerBase
{
    private readonly IAmazonSimpleEmailServiceV2 _amazonSimpleEmailService;

    public ValuesController(IAmazonSimpleEmailServiceV2 amazonSimpleEmailService)
    {
        _amazonSimpleEmailService = amazonSimpleEmailService;
    }

    // GET api/values
    [HttpGet]
    public IEnumerable Get()
    {
        return new string[]
        {
            _amazonSimpleEmailService.Config.RegionEndpoint.DisplayName
        };
    }
}

If we fire up the debugger we can now see the results of our hard work. Sending the sample requests below, we can see that our AWS Services are uniquely configured based on the incoming http request! Specifically, setting the regionEndpoint query string parameter changes the Region the Email Service is configured to use:

Relative Url Result
/api/Values?regionEndpoint=”us-east-1″ [“US East (N. Virginia)”]
/api/Values?regionEndpoint=”us-east-2″ [“US East (Ohio)”]
/api/values?regionEndpoint=eu-west-1 [“Europe (Ireland)”]

Full Example

public interface IAWSOptionsFactory
{
	/// <summary>
	/// Factory function for building AWSOptions that will be defined
	/// in a custom ASP.NET middleware.
	/// </summary>
	Func<AWSOptions> AWSOptionsBuilder { get; set; }
}

public class AWSOptionsFactory : IAWSOptionsFactory
{
	public Func<IServiceProvider, AWSOptions> AWSOptionsBuilder { get; set; }
}


public class Startup
{
	public Startup(IConfiguration configuration)
	{
		Configuration = configuration;
	}

	public static IConfiguration Configuration { get; private set; }

	// This method gets called by the runtime. Use this method to add services to the container
	public void ConfigureServices(IServiceCollection services)
	{
		// note: AWSOptionsFactory.AWSOptionsBuilder func will be populated in middleware
		services.AddScoped<IAWSOptionsFactory, AWSOptionsFactory>();
		services.AddDefaultAWSOptions(sp => sp.GetService<IAWSOptionsFactory>().AWSOptionsBuilder(), ServiceLifetime.Scoped));
		
		// also, for test purposes, register an AWSService that will consume the AWSOptions
		services.AddAWSService<IAmazonSimpleEmailServiceV2>(lifetime: ServiceLifetime.Scoped);

		services.AddControllers();
	}

	// This method gets called by the runtime. Use this method to configure the HTTP request pipeline
	public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
	{
		if (env.IsDevelopment())
		   app.UseDeveloperExceptionPage();
		

		app.UseHttpsRedirection();

		app.UseRouting();

		app.UseAuthorization();

		// Use Custom Middleware to build AWSOptions
		app.UseMiddleware<RequestSpecificAWSOptionsMiddleware>();

		app.UseEndpoints(endpoints =>
		{
			endpoints.MapControllers();
			endpoints.MapGet("/", async context =>
			{
				await context.Response.WriteAsync("Welcome to running ASP.NET Core on AWS Lambda");
			});
		});
	}
}

public class RequestSpecificAWSOptionsMiddleware
{
	public async Task InvokeAsync(
		HttpContext context,
		IAWSOptionsFactory optionsFactory)
	{
		optionsFactory.AWSOptionsBuilder = () =>
		{
			var awsOptions = new AWSOptions();

			// SAMPLE: configure AWSOptions based on HttpContext,
			// get the region endpoint from the query string 'regionEndpoint' parameter
			if (context.Request.Query.TryGetValue("regionEndpoint", out var regionEndpoint))
			{
				awsOptions.Region = RegionEndpoint.GetBySystemName(regionEndpoint);
			}

			return awsOptions;
		};

		await _next(context);
	}
}

[Route("api/[controller]")]
public class ValuesController : ControllerBase
{
	private readonly IAmazonSimpleEmailServiceV2 _amazonSimpleEmailService;

	public ValuesController(IAmazonSimpleEmailServiceV2 amazonSimpleEmailService)
	{
		_amazonSimpleEmailService = amazonSimpleEmailService;
	}

	// GET api/values
	// Return the RegionEndpoint DisplayName.  This visualizes that
	// the AmazonSimpleEmailService config is set in 
	// RequestSpecificAWSOptionsMiddleware
	[HttpGet]
	public IEnumerable<string> Get()
	{
		return new string[]
		{
			_amazonSimpleEmailService.Config.RegionEndpoint.DisplayName
		};
	}
}

Special Thanks

Special thanks to isdaniel for the initial question and of course for the PR (with unit tests!!). And thanks to IgorPietraszko for further engaging with us and providing the ASP.NET middleware use-case.

– PJ