Developer Reference Document for eShopOnContainers
Introduction
This post is a reference for developers to understand how the code has been implemented.
The idea is that once you walkthrough this document with the developers, the important questions of how the code is setup, how it works and how the developer can go about making changes to the code are understood.
The reference code can be downloaded from here.
This post is part of the microservices architecture series.
Configuration
The configuration for all the projects in the solution can be found in appsettings.json
, appsettings.Development.json
, appsettings.localhost.json
and launchSettings.json
.
When executing with docker-compose
, the configuration is read from the docker-compose.override.yaml
files and the ENV
file.
Following is the code from the Startup
class of one of the Bff projects where appsettings.localhost.json
is explicitly added to the configuration.
.ConfigureAppConfiguration(cb =>
{
var sources = cb.Sources;
sources.Insert(3, new Microsoft.Extensions.Configuration.Json.JsonConfigurationSource()
{
Optional = true,
Path = "appsettings.localhost.json",
ReloadOnChange = false
});
})
The following code adds custom configuration in one of the Bff projects.
public static IServiceCollection AddCustomConfiguration(this IServiceCollection services,
IConfiguration configuration)
{
services.AddOptions(); (1)
services.Configure<UrlsConfig>(configuration.GetSection("urls")); (2)
return services;
}
1 | Add services for using IOptions . |
2 | Registers a UrlsConfig object to bind to the urls section of the configuration. |
The following code shows how IOptions
can be used to read configuration values.
var basketApi = services.GetRequiredService<IOptions<UrlsConfig>>().Value.GrpcBasket; (1)
public OrderApiClient(HttpClient httpClient, ILogger<OrderApiClient> logger,
IOptions<UrlsConfig> config) (2)
{
var value = _urls.Orders + config.Value.GrpcBasket; (3)
}
1 | Ask the service directly for an instance using GetRequiredService and read the configuration values Or |
2 | Use the DI framework to inject it into the class And |
3 | Read the configuration values |
The following code is from one of the API projects:-
public static IServiceCollection AddConfiguration(this IServiceCollection services,
IConfigurationBuilder configuration)
{
services.AddOptions();
configuration.SetBasePath(Directory.GetCurrentDirectory());
configuration.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); (1)
configuration.AddEnvironmentVariables(); (2)
return services;
}
1 | Adds appsettings.json |
2 | Reads the environment variables from launchsettings.json |
The configuration object can also be directly accessed using DI:-
public HomeController(IConfiguration configuration)
{
_configuration = configuration;
}
When executing with docker-compose
, the environment variables and any overlapping configuration values are overwritten by the values from the docker-compose.override.yml
files.
It is preferable to use the IOptions
method for reading configuration values because this way you do not depend on magic string keys to read configuration values.
Authentication
In the IdentityServer.API
project, the Config.cs
class is where all the scopes, resources and configuration of the clients are defined.
All the projects have Authentication configured in their ServiceCollectionExtensions.cs
file as follows:-
public static IServiceCollection AddCustomAuthentication(this IServiceCollection services,
IConfiguration configuration)
{
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Remove("sub");
var identityUrl = configuration.GetValue<string>("urls:identity");
services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.Authority = identityUrl;
options.RequireHttpsMetadata = false;
options.Audience = "mobileshoppingagg"; (1)
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false
};
});
return services;
}
1 | The audience value changes for each project. |
Authorization
In the IdentityServer.API
project, the Config.cs
class is where all the scopes, resources and configuration of the client is defined.
All the projects have Authorization configured in their ServiceCollectionExtensions.cs
file as follows:-
public static IServiceCollection AddCustomAuthorization(this IServiceCollection services, IConfiguration configuration)
{
services.AddAuthorization(options =>
{
options.AddPolicy("ApiScope", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "mobileshoppingagg"); (1)
});
});
return services;
}
1 | The scope changes based on what is required by the Api. |
The web projects and the Bff projects have a handler that adds the authorization code whenever they are communicating with an API.
The handler is called HttpClientAuthorizationDelegatingHandler
:-
public class HttpClientAuthorizationDelegatingHandler : DelegatingHandler
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<HttpClientAuthorizationDelegatingHandler> _logger;
public HttpClientAuthorizationDelegatingHandler(IHttpContextAccessor httpContextAccessor,
ILogger<HttpClientAuthorizationDelegatingHandler> logger)
{
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken) (1)
{
request.Version = new System.Version(2, 0);
request.Method = HttpMethod.Get;
var authorizationHeader = _httpContextAccessor.HttpContext
.Request.Headers["Authorization"];
if (!string.IsNullOrEmpty(authorizationHeader))
{
request.Headers.Add("Authorization", new List<string>() { authorizationHeader });
}
var token = await GetToken();
if (token != null)
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
return await base.SendAsync(request, cancellationToken);
}
async Task<string> GetToken()
{
const string ACCESS_TOKEN = "access_token";
return await _httpContextAccessor.HttpContext
.GetTokenAsync(ACCESS_TOKEN);
}
}
1 | The SendAsync method is overridden and this gets called everytime a SendAsync request is sent to the Api’s. |
The following code shows how and where it can be configured:-
public static IServiceCollection AddHttpServices(this IServiceCollection services)
{
//register delegating handlers
services.AddTransient<HttpClientAuthorizationDelegatingHandler>();
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
//register http services
services.AddHttpClient<IOrderApiClient, OrderApiClient>()
.AddHttpMessageHandler<HttpClientAuthorizationDelegatingHandler>(); (1)
return services;
}
1 | Everytime a call is made to the OrderApi , the authorization code is added to the request by the handler. |
Swagger
Swagger allows you to describe the structure of your APIs so that machines can read them.
Swagger is accessible using the /swagger
endpoint.
In case the api requires authentication and authorization, then the swagger url and the UI must be setup in a way that it can authorize itself.
This needs to be done so that you can test your api’s using the Swagger UI otherwise the api’s will always return NotAuthorized.
This blog post gives a good summary of the problem and its solution.
Swagger is setup as follows in all the projects:-
public static IServiceCollection AddCustomSwagger(this IServiceCollection services, IConfiguration configuration)
{
services.AddSwaggerGen(options =>
{
//options.DescribeAllEnumsAsStrings();
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Shopping Aggregator for Mobile Clients",
Version = "v1",
Description = "Shopping Aggregator for Mobile Clients"
});
options.AddSecurityDefinition("oauth2", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows()
{
Implicit = new OpenApiOAuthFlow()
{
AuthorizationUrl = new Uri($"{configuration.GetValue<string>("IdentityUrlExternal")}/connect/authorize"),
TokenUrl = new Uri($"{configuration.GetValue<string>("IdentityUrlExternal")}/connect/token"),
Scopes = new Dictionary<string, string>()
{
{ "mobileshoppingagg", "Shopping Aggregator for Mobile Clients" }
}
}
}
});
options.OperationFilter<AuthorizeCheckOperationFilter>();
});
return services;
}
app.UseSwagger().UseSwaggerUI(c =>
{
c.SwaggerEndpoint($"{ (!string.IsNullOrEmpty(pathBase) ? pathBase : string.Empty) }/swagger/v1/swagger.json", "Purchase BFF V1");
c.OAuthClientId("webshoppingaggswaggerui"); (1)
c.OAuthClientSecret(string.Empty);
c.OAuthRealm(string.Empty);
c.OAuthAppName("web shopping bff Swagger UI");
});
1 | The swagger ui client needs to be setup in the Config.cs file of the Identity.Api project. |
Cors
Cors is setup for all the projects as follows:-
public static IServiceCollection AddCustomCors(this IServiceCollection services)
{
services.AddCors(options =>
{
options.AddPolicy("CorsPolicy",
builder => builder
.AllowAnyMethod()
.AllowAnyHeader()
.SetIsOriginAllowed((host) => true)
.AllowCredentials());
});
return services;
}
HttpClient
In case you need to call an API or a service using the HttpClient, this is where it needs to be setup in the project:-
public static IServiceCollection AddHttpServices(this IServiceCollection services)
{
//register delegating handlers
services.AddTransient<HttpClientAuthorizationDelegatingHandler>();
services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
//register http services
services.AddHttpClient<IOrderApiClient, OrderApiClient>() (1)
.AddHttpMessageHandler<HttpClientAuthorizationDelegatingHandler>();
return services;
}
1 | A typed HttpClient is created for OrderApi. This can be directly injected into any controller or service. |
Controllers
For web and bff projects, the controller setup is quite simple:-
public static IServiceCollection AddCustomControllers(this IServiceCollection services)
{
services.AddControllers()
.AddJsonOptions(options => options.JsonSerializerOptions.WriteIndented = true);
return services;
}
For Api projects, there are two filters that modify the response in case of a business domain exception or a validation exception.
public static IServiceCollection AddCustomControllers(this IServiceCollection services)
{
services.AddControllers(options =>
{
options.Filters.Add(typeof(HttpGlobalExceptionFilter));
options.Filters.Add(typeof(ValidateModelStateFilter));
})
// Added for functional tests
.AddApplicationPart(typeof(BasketController).Assembly)
.AddJsonOptions(options => options.JsonSerializerOptions.WriteIndented = true);
return services;
}
Grpc
In the case of Api projects, only Grpc services are exposed, there are no clients, so the setup is quite simple.
public static IServiceCollection AddCustomGrpc(this IServiceCollection services)
{
services.AddGrpc(options =>
{
options.EnableDetailedErrors = true;
});
return services;
}
In the case of the Bff projects, we connect to other Grpc Services so it acts as a client.
There is also a GrpcExceptionInterceptor
that cleans up the response in case of any exception thrown by the service.
public static IServiceCollection AddGrpcServices(this IServiceCollection services)
{
services.AddTransient<GrpcExceptionInterceptor>();
services.AddScoped<IBasketService, BasketService>();
services.AddGrpcClient<Basket.BasketClient>((services, options) =>
{
var basketApi = services.GetRequiredService<IOptions<UrlsConfig>>().Value.GrpcBasket;
options.Address = new Uri(basketApi);
}).AddInterceptor<GrpcExceptionInterceptor>();
services.AddScoped<ICatalogService, CatalogService>();
services.AddGrpcClient<Catalog.CatalogClient>((services, options) =>
{
var catalogApi = services.GetRequiredService<IOptions<UrlsConfig>>().Value.GrpcCatalog;
options.Address = new Uri(catalogApi);
}).AddInterceptor<GrpcExceptionInterceptor>();
services.AddScoped<IOrderingService, OrderingService>();
services.AddGrpcClient<OrderingGrpc.OrderingGrpcClient>((services, options) =>
{
var orderingApi = services.GetRequiredService<IOptions<UrlsConfig>>().Value.GrpcOrdering;
options.Address = new Uri(orderingApi);
}).AddInterceptor<GrpcExceptionInterceptor>();
return services;
}
Logging
Logging is handled using Serilog
.UseSerilog((builderContext, config) =>
{
config
.MinimumLevel.Information()
.Enrich.FromLogContext()
.WriteTo.Console();
})
"Logging": {
"IncludeScopes": false,
"Debug": {
"LogLevel": {
"Default": "Debug"
}
},
"Console": {
"LogLevel": {
"Default": "Debug"
}
}
}
Exception Handling
There are 3 global exception handlers configured in the application.
-
GrpcExceptionInterceptor
-
This is setup when configuring Grpc clients. It cleans up the response in case the service throws an exception.
public class GrpcExceptionInterceptor : Interceptor { private readonly ILogger<GrpcExceptionInterceptor> _logger; public GrpcExceptionInterceptor(ILogger<GrpcExceptionInterceptor> logger) { _logger = logger; } public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>( TRequest request, ClientInterceptorContext<TRequest, TResponse> context, AsyncUnaryCallContinuation<TRequest, TResponse> continuation) { var call = continuation(request, context); return new AsyncUnaryCall<TResponse>(HandleResponse(call.ResponseAsync), call.ResponseHeadersAsync, call.GetStatus, call.GetTrailers, call.Dispose); } private async Task<TResponse> HandleResponse<TResponse>(Task<TResponse> t) { try { var response = await t; return response; } catch (RpcException e) { _logger.LogError("Error calling via grpc: {Status} - {Message}", e.Status, e.Message); return default; } } }
-
-
HttpGlobalExceptionFilter
-
This is setup when configuring the controllers for the web and api projects. It cleans up the response when there is an exception.
public class HttpGlobalExceptionFilter : IExceptionFilter { private readonly IWebHostEnvironment env; private readonly ILogger<HttpGlobalExceptionFilter> logger; public HttpGlobalExceptionFilter(IWebHostEnvironment env, ILogger<HttpGlobalExceptionFilter> logger) { this.env = env; this.logger = logger; } public void OnException(ExceptionContext context) { logger.LogError(new EventId(context.Exception.HResult), context.Exception, context.Exception.Message); if (context.Exception.GetType() == typeof(CatalogDomainException)) { var problemDetails = new ValidationProblemDetails() { Instance = context.HttpContext.Request.Path, Status = StatusCodes.Status400BadRequest, Detail = "Please refer to the errors property for additional details." }; problemDetails.Errors.Add("DomainValidations", new string[] { context.Exception.Message.ToString() }); context.Result = new BadRequestObjectResult(problemDetails); context.HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; } else { var json = new JsonErrorResponse { Messages = new[] { "An error ocurred." } }; if (env.IsDevelopment()) { json.DeveloperMessage = context.Exception; } context.Result = new InternalServerErrorObjectResult(json); context.HttpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError; } context.ExceptionHandled = true; } private class JsonErrorResponse { public string[] Messages { get; set; } public object DeveloperMessage { get; set; } } }
-
-
ValidateModelStateFilter
-
This validates the model sent to the controller and gracefully handles any failure if the model is invalid.
public class HttpGlobalExceptionFilter : IExceptionFilter { private readonly IWebHostEnvironment env; private readonly ILogger<HttpGlobalExceptionFilter> logger; public HttpGlobalExceptionFilter(IWebHostEnvironment env, ILogger<HttpGlobalExceptionFilter> logger) { this.env = env; this.logger = logger; } public void OnException(ExceptionContext context) { logger.LogError(new EventId(context.Exception.HResult), context.Exception, context.Exception.Message); if (context.Exception.GetType() == typeof(CatalogDomainException)) { var problemDetails = new ValidationProblemDetails() { Instance = context.HttpContext.Request.Path, Status = StatusCodes.Status400BadRequest, Detail = "Please refer to the errors property for additional details." }; problemDetails.Errors.Add("DomainValidations", new string[] { context.Exception.Message.ToString() }); context.Result = new BadRequestObjectResult(problemDetails); context.HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; } else { var json = new JsonErrorResponse { Messages = new[] { "An error ocurred." } }; if (env.IsDevelopment()) { json.DeveloperMessage = context.Exception; } context.Result = new InternalServerErrorObjectResult(json); context.HttpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError; } context.ExceptionHandled = true; } private class JsonErrorResponse { public string[] Messages { get; set; } public object DeveloperMessage { get; set; } } }
-
Testing
Unit Testing
Unit testing projects are straightforward. xUnit
and Moq
are the frameworks used for testing and creating mocks.
Functional Testing
To perform functional testing of the api’s,
-
a base class sets up the TestServer.
-
an authorization middleware simulates an authorized request.
-
an HttpClient extension to simulates idempotent requests.
xUnit
and Moq
are the frameworks used for testing and creating mocks.
Microsoft.AspNetCore.TestHost
is the package that contains the TestServer that is used.
class AutoAuthorizeMiddleware
{
public const string IDENTITY_ID = "9e3163b9-1ae6-4652-9dc6-7898ab7b7a00";
private readonly RequestDelegate _next;
public AutoAuthorizeMiddleware(RequestDelegate rd)
{
_next = rd;
}
public async Task Invoke(HttpContext httpContext)
{
var identity = new ClaimsIdentity("cookies");
identity.AddClaim(new Claim("sub", IDENTITY_ID));
identity.AddClaim(new Claim("unique_name", IDENTITY_ID));
identity.AddClaim(new Claim(ClaimTypes.Name, IDENTITY_ID));
httpContext.User.AddIdentity(identity);
await _next.Invoke(httpContext);
}
}
static class HttpClientExtensions
{
public static HttpClient CreateIdempotentClient(this TestServer server)
{
var client = server.CreateClient();
client.DefaultRequestHeaders.Add("x-requestid", Guid.NewGuid().ToString());
return client;
}
}
public class BasketScenariosBase
{
private const string ApiUrlBase = "api/v1/basket";
public TestServer CreateServer()
{
var path = Assembly.GetAssembly(typeof(BasketScenariosBase))
.Location;
var hostBuilder = new WebHostBuilder()
.UseContentRoot(Path.GetDirectoryName(path))
.ConfigureAppConfiguration(cb =>
{
cb.AddJsonFile("Services/Basket/appsettings.json", optional: false)
.AddEnvironmentVariables();
});
return new TestServer(hostBuilder);
}
public static class Get
{
public static string GetBasket(int id)
{
return $"{ApiUrlBase}/{id}";
}
public static string GetBasketByCustomer(string customerId)
{
return $"{ApiUrlBase}/{customerId}";
}
}
public static class Post
{
public static string CreateBasket = $"{ApiUrlBase}/";
public static string CheckoutOrder = $"{ApiUrlBase}/checkout";
}
}