As mentioned in my introductory post on this topic, there are three main areas that I see specific concerns regarding approaching this type of a problem.
- The database and its data: its access/security.
- The server side portion of the application.
- The client side portion of the application.
I outlined the high level decisions I made regarding the database in the last post and hope to address, a little more in depth, the server side decisions/implementations in this post.
Due to the fact that the company this application is being developed for is a '.NET shop', the decision was made to use ASP.NET Core. We could have just as easily utilized the full ASP.NET Framework and MVC 5.*, with the same basic techniques. The future of .NET development seems to be in Core and being that this was a greenfield project with minimal dependencies(all of which have a .NET Core dll), it seemed like the perfect place and time to dip our toes into this brave new world.
Here is the code for a bare bones MVC controller that would be provided with one of Visual Studio's built-in templates.
public class HomeController : Controller
{
public IActionResult Index()
{
return View();
}
}
The default conventions will cause the engine to look for/route-to/render the view file that would be found at ~/Views/Home/Index.cshtml
. Now if one wanted to be more explicit, then they could return View("Index");
instead. What is interesting about this is that the argument passed in is just a string. The views are dynamically compiled and if one wanted to, they could do something like return View("About");
just as easily. As long as the view engine finds a view at ~/Views/Home/About.cshtml
, then everything will continue to execute properly and when the user explicitly or implicitly goes to the /Home/Index
route, they will be shown the 'About' page. Taking this one small step forward, the string that is passed into the View method could include '/' which will allow the view rendering engine to navigate into directories as well to locate it's view.
With this in mind, we made the decision to create a directory under Home called "LicensedOrganizations". The reason we created a dedicated directory for this was to enable us to have a directory ignore rule in place in our .gitignore
file to exclude this directory. As will soon be discussed, the .cshtml files that will end up in this directory will be created by the build machine, based on the index.html
file output by Angular's cli. While all of the JavaScript files will remain the same regardless of the accessing application, the look and feel of each licensed organization will be different due to the differing linked style sheets, favicon files and logo files. At the moment, there hasn't been a need for it, but this also provides us the flexibility of creating custom .cshtml file templates that could provide a branded skeleton for each 'site', including custom navigation headers and footers that match the rest of the licensed organization's other websites.
Most of the front-end concerns for this application will be discussed in another post, but the one area that seems to cross this boundary are the initial view pages that the ASP.NET engine will be relied on to serve. Since this piece of the equation has already been mentioned, I figured I could continue to elaborate on it while glossing over(for now) all of the rest of the front-end build processes. The structure of a default Angular application created by the cli will look as follows:
/node_modules
/src
/app
/environments
favicon.ico
index.html
main.ts
polyfills.ts
styles.css
tsconfig.app.json
typings.d.ts
.angular-cli.json
.editorconfig
.gitignore
package.json
tsconfig.json
tslint.json
The build process can be invoked through executing the command node_modules/.bin/ng build
. The default cli project will have a bit of a short-hand for that command already wired up in the npm scripts section of the package.json
file. The same action can be achieved then through the command npm run build
. Upon invoking the cli's build process, an additional directory will be made as a sibling of src
called dist
.
The dist
directory will contain all of the compiled js files(the css files are included in the main.*.js file) as well as an index.html
file containing the proper script links to those compiled js files. This last piece is key. When running the build in 'prod' mode, a hash value is added to each of the created js files that is meant to help with 'cache-busting'. This is great, but is a bit unpredictable as theoretically the hash is based on the compiled Angular js files, not the source typescript files. Those script tags are inserted right before the closing body
tag on the page. What I did at this point was to insert a few comment tags(e.g. <!--FAVICON-->
, <!--STYLES-->
and <!--LOGO-->
). I then created a small js script and placed it in a special build directory.
import { readdir, readFile, writeFile } from 'fs';
const value: IThemeJson = require('./licensed-organizations.json');
value.orgs.forEach(org => {
const cssFilesDirectoryPath = `./dist/styles/${org}`;
createStylesLinks(org, cssFilesDirectoryPath, createIndexFile);
});
function createStylesLinks(org: string, dir: string, cb: (org: string, stylesLinks: string) => void) {
readdir(dir, (err, directoryItems) => {
var stylesLinks = '';
directoryItems.forEach((directoryItem, $index, $array) => {
if (directoryItem.startsWith(org) && directoryItem.endsWith('.min.css')) {
stylesLinks += `<link type="text/css" rel="stylesheet" href="./styles/${org}/${directoryItem}" media="all" />`;
}
if ($array.length - 1 === $index) {
cb(org, stylesLinks)
}
});
});
}
function createIndexFile(org: string, stylesLinks: string) {
const indexTemplatePath = `./dist/index.html`;
const outputDir = `./dist`;
readFile(indexTemplatePath, 'utf8', (err, indexTemplateContents) => {
if (err) return console.error(err);
var organizationIndexContents = `@{Layout=null;}\r\n${indexTemplateContents}`
.replace('<!--FAVICON-->', `<link rel="shortcut icon" href="./assets/icons/${org}.ico" />`)
.replace('<!--STYLES-->', stylesLinks)
.replace('<!--LOGO-->', `<img class="app loading logo" src="./assets/logos/${org}.png" />`);
var organizationIndexPath = `${outputDir}/${org}.cshtml`;
writeFile(organizationIndexPath, organizationIndexContents, 'utf8', err => {
if (err) return console.error(err);
});
});
}
This script is written in Typescript and is run using an npm package called ts-node. I plan to go into more detail with this in the post on the front-end.
This small script loops through all entries in the licensed-organizations.json
file(more to come on this in the next installment) and replaces those comment tags in the index.html
with the desired branding information for that organization and then creates a .cshtml file for that organization and places it in the dist
directory. Yes, I know, ASP.NET Core by default will look for its views in its Views
directory. There are ways to change this default behavior, but in the end, we decided just to copy those files into our Views/Home/LicensedOrganizations
directory at a later step in the build process, as well as place most of the other files from the dist
directory into the wwwroot
directory, which is where ASP.NET Core, by default, will look for all static files to be served by the application.
In the package.json
file, I then added another npm script called postbuild
with a value of ts-node build/createIndexFiles.js
. If the reader isn't familiar with node scripts, one is able to define that one script should run before or after another script by prefacing the script name with pre
or post
. With the npm script that we just defined, when we execute npm run build
now from the command line, if there is a script named prebuild
(there isn't at this point), it would get executed, when it completes the npm script named build
will get executed and upon completion of that script, a script named postbuild
(if one is defined) will then get executed. With what we have defined, ng build will get called and upon completion of that script our custom script shown above will execute, creating a .cshtml file for each organization and place it in the dist
directory.
Now with the custom index files being built, the only remaining question becomes, how do we know in the server-side portion of the application, which view to route the user to. As mentioned in the previous post, this is relatively simple as each organization is going to route to the application through a unique url. In the HttpContext that comes along with every request, we can access the URL through context.Request.Host.Host
. In our instance, we save each organizations configured URL in a database along with other organization specific information. We perform some caching so this lookup doesn't have to happen for every request and also create a new ClaimsPrinciple object upon a new visit that is passed along with the request as part of the cookie. This is one of the factors that enables us to setup a demo site where an internal employee can demonstrate the power of the system by demonstrating multiple different client implementations.
With that we have the back-end concerns taken care of for a single web application to serve individualized themes per accessing organization.
UPDATE
Upon completion of this build process, I found a blog post that discusses the ability in ASP.NET Core to include wild-cards in script include in a View. He has a more full example there, but my createIndexFile
method mentioned above can largely go away given this approach. Instead of having to get these values at build time through reading the file names, one can leverage TagHelpers in ASP.NET Core to handle this for you.
To take advantage of this, in your view, you should add:
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
<environment names="Development,Production">
<script type="text/javascript" asp-src-include="~/inline.*.bundle.js"></script>
<script type="text/javascript" asp-src-include="~/polyfills.*.bundle.js"></script>
<script type="text/javascript" asp-src-include="~/styles.*.bundle.js"></script>
<script type="text/javascript" asp-src-include="~/vendor.*.bundle.js"></script>
<script type="text/javascript" asp-src-include="~/main.*.bundle.js"></script>
</environment>
NOTE: The only catch with this approach is that you need to ensure that your build/deploy process cleans out the previously built *.js files before adding the new ones or your application will break. I think this is a best practice in any case, but just wanted to make the reader aware of this pitfall.