Implemented the following solution using Axios, Redux and .Net Core REST API.
During initial Axios configuration a custom header is added to the defaults. This results in all client GET requests including the client version number (defined in package.json) as a request header.
axios.defaults.headers.get["x-version"] = process.env.VERSION?.toString()??"0";
Within the REST API a middleware message handler intercepts all HTTP requests and checks if the request includes the x-version header. If so the x-header value is checked against a value defined in the appsettings.json. If these values do not match then the header x-client-incompatable is added to the response.
Middleware configuration in Startup.cs:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
...
app.UseMiddleware<VersionMessageHandler>();
...
}
VersionMessageHandler.cs
public class VersionMessageHandler
{
private readonly RequestDelegate _next;
private readonly IConfiguration _configuration;
public VersionMessageHandler(RequestDelegate next, IConfiguration configuration)
{
_next = next;
_configuration = configuration;
}
public async Task Invoke(HttpContext context)
{
if (context.Request.Method.Equals("GET", StringComparison.OrdinalIgnoreCase))
{
var version = context.Request.Headers["x-version"].ToString();
if (string.IsNullOrEmpty(version) == false)
{
context.Response.OnStarting(() =>
{
var compatableClientVersion = _configuration.GetValue<string>("CompatableClientVersion");
if (string.IsNullOrEmpty(compatableClientVersion) == false && version != compatableClientVersion)
{
context.Response.Headers.Add("x-client-incompatable", "true");
}
return Task.FromResult(0);
});
}
}
await _next(context);
}
}
When the response is received by the client an Axios interceptor (also defined as part of the initial Axios configuration) is used to check the response for the x-client-compatable header. If the header is found then a redux state update is dispatched.
Axios interceptor (Interceptors):
axios.interceptors.response.use(
(response: AxiosResponse) => {
// If we have the x-client-incompatable then the client is incompatable with the API.
if(response.headers && response.headers["x-client-incompatable"]) {
// Check if we already know before dispatching state update
const appState: ApplicationState = store.getState();
if (appState.compatability === undefined || appState.compatability.incompatable === false) {
store.dispatch({ type: 'COMPATABILITY_CHECK', incompatable: response.headers["x-client-incompatable"] });
}
}
return response;
}
);
From here its just standard react functionality - the change in redux state causes a notification component to be displayed in the header of the app (user can still continue working). The notification has an onClick handler which calls window.location.reload() which results in the client being reloaded from the server.
Note. CORS may restrict response headers - if it does then you will need to configure CORS WithExposedHeaders in your API (startup.cs/ConfigureServices)
services.AddCors(options =>
{
options.AddPolicy("AllowedDomains",
policy => policy
.WithExposedHeaders("x-client-incompatable")
);
});