An Approach to Using Hyper, qb, Logbox, and Other ColdBox Modules with FW/1
I'll begin by saying that a substantial portion of this post is deeply indebted to Tony Junkes, who has blogged quite a bit about using ColdBox modules with FW/1. The approach I ended up taking is slightly different than his, so I thought it worth documenting.
Background
I have a number of applications using FW/1; while not the most popular ColdFusion framework, I appreciate its relative simplicity and reliability. I'm also a huge fan of ForgeBox, which provides an ever-growing library of fantastic CFML modules (packages of code that provide functionality to an application).
Relevant to this post, the good folks at Ortus Solutions have built some really impressive modules, including the three referenced in this post's title: Hyper, qb, and LogBox.[1] Another one of their modules, the recently released totp, provides the ability to generate one-time passwords with CFML! That's so cool!
Obviously, I want to leverage the functionality of these modules in my FW/1 apps, but their ColdBox roots can make this process a bit confusing.[2] So, let's take a look at how that's done.
The Subsystem Approach
Tony's approach utilizes FW/1's subsystems to hold the modules. He explains this process in several posts:
- Working With FW/1 & QB - Steps to Integrate Using Subsystems
- Using LogBox For Logging In FW/1
- Implementing RuleBox In Your FW/1 Applications
These guides were my jumping off point, and without them I probably wouldn't have gotten the modules working. Thanks Tony!
For the purposes of this post, the most important aspect of the subsystem approach is that each module is installed as a separate subsystem in FW/1. Within a standard FW/1 app, the subsystem folder structure ends up looking like this:
fwl-coldbox-modules
├── controllers, framework, views, etc.
└── subsystems
├── hyper
├── logbox
└── qb
Each module is then configured within its own section of the variables.framework.subsystems
struct in Application.cfc
. And finally, to retrieve and use the modules, you need to use their subsystem-specific bean factories, like this:
StarWarsClient = getBeanFactory("hyper").getBean("StarWarsClient");
A clear benefit of the subsystem approach is that it operates entirely within the existing "subsystem" conventions of FW/1.
Why I Don't Use Subsystems for Modules
FW/1 is a flexible framework, and apart from a handful of conventions, leaves a good deal of application architecture up to the individual developer. For example, when it comes to subsystems, the documentation gives a fairly wide-ranging definition:
Subsystems give you a way of modularizing your FW/1 application as it grows. They also provide a way to incorporate other FW/1 applications directly into an existing one. Subsystems can be used to create modules that have no dependencies on the parent application or you can use subsystems to group common functionality together.
I prefer to reserve my subsystems for navigable, application-specific code, while modules (third party projects that provide general functionality) are stored together in a separate directory. Why the distinction? Primarily organization - you might describe it as a separation of concerns. I treat modules and subsystems as fundamentally different types of code.
I use subsystems to organize sections of an application that can be accessed by users (admin panel, marketing reports, API, etc.); I edit them directly and keep the code in version control. Subsystems are for the users of the application.
Modules, on the other hand, are for me, the developer. They should never be web-accessible and I want them available within the primary application's bean factory.
Because I'm pulling in modules from a package repository (ForgeBox), I don't edit modules directly within my application.[3] If there is an issue, I go to the module's source project and open an issue or pull request. Finally, by keeping modules separate from subsystems, I can apply different .gitignore
rules to them more easily.[4]
As far as I can tell, the differences between the subsystem approach and the approach outlined below are minor and very much a matter of preference; they're just different ways of organizing an application.
The Modules Approach
In this approach, modules are stored in a /modules
directory. This is the default location used by CommandBox to install them, and a ColdBox convention. By default, FW/1 does not have a /modules
folder, but as we'll see, adding it isn't a big deal.
Enough with talking about all this hypothetically - let's take a look at the code!
Building the App
I'm going to walk through how you can set this up using CommandBox to automate the process. If that's not your speed, you can head over to this example GitHub repo to see what I'm talking about.
The first thing you'll need is a CommandBox module called FW/1 Commands, built by none other than Tony Junkes. Within the CommandBox shell, we'll install it by running:
install fw1-commands
With this installed, we can quickly scaffold a FW/1 app:
fw1 create app name="coldbox_module_test" skeleton="Skeleton" --installFW1
This command sets up a basic FW/1 application for us in the current directory. The next step is to add the modules.
Installing Hyper
To kick off the module-adding process, we'll install Hyper:
install hyper
This instructs CommandBox to download the Hyper module from ForgeBox and install it in the /modules
directory in our project. Because this is a FW/1 app, there is no /modules
directory, but that's not a problem; CommandBox creates it automatically.
Configuring Hyper
The first thing we'll need to do in order to configure Hyper is to add a mapping for it to our Application.cfc
this.mappings = {
"/hyper": expandPath("./modules/hyper")
};
This is done automatically when the module is installed in a ColdBox app, but it's not too much work to set up manually here.
Hyper is an HTTP request builder, so we'll need an example endpoint to request. For this walk-though, I'll use The Star Wars API, because it's used in the Hyper documentation.
We'll configure Hyper's defaults for our Star Wars API HTTP requests[5] using a DI/1 load listener. While load listener configuration can be done inline, we'll set up a CFC to encapsulate the configuration and avoid cluttering our Application.cfc
; we'll be using the same load listener to configure all modules that we add to the application.
First, we'll create the file:
touch model/LoadListener.cfc
Then we'll wire it into our framework configuration, in Application.cfc:
variables.framework = {diConfig: {loadListener: "LoadListener"}};
And finally here's what we'll put in the LoadListener.cfc
file:
component {
function onLoad(beanFactory) {
beanfactory
.declare("StarWarsClient")
.asValue(new hyper.models.HyperBuilder(baseUrl = "https://swapi.dev/api", timeout = 20))
.done();
// other modules will be configured here
beanFactory.load();
}
}
This tells DI/1 that we want a pre-configured instance of the Hyper request builder, called StarWarsClient
, ready to interact with the Star Wars API.
That's it; Hyper is ready to go. To use the module in our main controller, we can simply add StarWarsClient
as a property, and start making requests:
// controllers/main.cfc
component accessors="true" {
property StarWarsClient;
public void function default(rc) {
var luke = StarWarsClient.get("/people/1");
writeDump(var = "#luke.getData()#", abort = "true");
}
}
Accessing the main page of our application will now show us the result of the HTTP request made by Hyper.
Installing qb
Moving right along, let's add our next module:
install qb
And just like that, qb is installed in /modules
. It does feel a little like magic.
Configuring qb
Again, the first thing we'll need to do is add mappings in Application.cfc
; here's what they should look like now:
this.mappings = {
"/hyper" : expandPath("./modules/hyper"),
"/qb" : expandPath("./modules/qb"),
"/cbpaginator": expandPath("./modules/qb/modules/cbpaginator"),
};
Those of you who are still paying attention may be wondering what the cbpaginator
mapping is. That module, cbpaginator, is a dependency of qb, which is why we need to account for its mapping as well.[6]
The final configuration step is wiring up the module with our load listener. For qb, this is a bit more verbose than it was for Hyper. It will also look slightly different, depending on the database engine you're using. Here's a basic setup for PostgreSQL that we'll add right after the Hyper block in our load listener:
// model/LoadListener.cfc
beanfactory
.declare("BaseGrammar").instanceOf("qb.models.Grammars.BaseGrammar").done()
.declare("PostgresGrammar").instanceOf("qb.models.Grammars.PostgresGrammar").done()
.declare("QueryUtils").instanceOf("qb.models.Query.QueryUtils").done()
.declare("qb").instanceOf("qb.models.Query.QueryBuilder").withOverrides({
grammar: beanfactory.getBean("PostgresGrammar"),
utils : beanfactory.getBean("QueryUtils")
}).asTransient().done();
What we're doing here is creating all the components that qb expects to exist, and then defining a transient bean, named qb
, that we can use to build queries.
Now, I'm not going to set up a datasource for this demo, but we can still take a look at how you'd use qb in a FW/1 controller:
component accessors="true" {
property beanFactory;
public void function default(rc) {
var qb = variables.beanFactory.getBean("qb");
var example = qb
.table("test")
.select([ "post_id", "author_id", "title", "body", ])
.whereLike("author", "Ja%")
.orderBy("published_at");
// to run the query: example.get()
// just dump generated sql, with params
writeDump(var = "#example.toSQL(true)#", abort = "true");
}
}
Notice that we've added the beanFactory
as a property, because it's needed in order to retrieve qb. Once retrieved, you can use its fluent syntax to build a query (check out the docs for more details). In the above example, we're using .toSQL()
to dump the generated SQL, in order to show that it works. In a real application, you'd use .get()
to run the SELECT and retrieve the results.
Installing LogBox
The final module of the post! The syntax when we install LogBox will be slightly different; by default LogBox is installed into the application root, but we're going to put it in /modules
, along with the rest:
install ID=logbox directory=modules/
By providing the directory, CommandBox will install LogBox alongside the other modules we've added, and it doesn't impact of use of LogBox in the application.
Configuring LogBox
LogBox has a slightly more complicated configuration than Hyper and qb, but the first step is the same - adding an application mapping. We'll tack the mapping for LogBox onto the end of this.mappings
in Application.cfc
:
this.mappings = {
"/hyper" : expandPath("./modules/hyper"),
"/qb" : expandPath("./modules/qb"),
"/cbpaginator": expandPath("./modules/qb/modules/cbpaginator"),
"/logbox" : expandPath("./modules/logbox"),
};
Next, as you may have guessed, is the load listener. A complication here is that the configuration for LogBox is supplied via a CFC with a configure()
method. We'll need to create that file and store it somewhere. I followed Tony's lead on this one, creating a /conf
directory, where configuration related files could be stored for the application. Let's create the file:
touch conf/LogBoxConfig.cfc
There is a lot that you can do with this file, so I'll point you in the direction of the LogBox documentation for all the bells, whistles, knobs, and dials. LogBox has all manner of appenders that can log to email, files, sockets, database tables, via cflog, and more. In our app, we'll configure one simple log appender that writes to the console.[7] Here's how we can do that, in the file we just created:
// conf/LogBoxConfig.cfc
component {
void function configure() {
variables.logBox = {
appenders: {
"ExampleConsole": {
class : "logbox.system.logging.appenders.ConsoleAppender",
levelMax: "INFO",
levelMin: "FATAL",
},
},
root: {levelmax: "DEBUG", levelMin: "FATAL", appenders: "*"},
};
}
}
With that configuration in place, we can wire it up with the load listener. Here's what we need to add, beneath the Hyper config:
// model/LoadListener.cfc
beanfactory
.declare("LogBoxConfig")
.instanceOf("logbox.system.logging.config.LogBoxConfig")
.withOverrides({CFCConfigPath: "conf.LogBoxConfig"})
.done();
beanfactory
.declare("LogBoxBase")
.instanceOf("logbox.system.logging.LogBox")
.withOverrides({config: beanfactory.getBean("LogBoxConfig")})
.done();
beanfactory
.declare("LogBox")
.asValue(beanfactory.getBean("LogBoxBase"))
.done();
With that, we can get around to the fun part of actually incorporating logging into our application's CFCs. Again, we'll use our main controller to demonstrate how this would be done:
component accessors="true" {
function init(any LogBox) {
variables.LogBox = LogBox;
variables.logger = variables.LogBox.getLogger(this);
return this;
}
public void function default(rc) {
variables.logger.info("I am an interesting and helpful message");
variables.logger.error("Danger Will Robinson!");
writeOutput("If you're tailing the log, you should see an error and an info message");
abort;
}
}
Whew! That was a lot to get through, but you made it! Here's the repo with all the demo code if you want to take it for a spin yourself.
Finally, if you've got any questions, feel free to let me know in the comments. Cheers!
Footnotes
I'm not going to give you the pitch on why these modules are great, except to say that they're really powerful, fun to use, and save me tons of time. If you need more specifics, check out their GitHub repos, or feel free to ask me in the comments, on Twitter, or the CFML Slack channel. ↩︎
Just to be perfectly clear, not all ColdBox modules can be used with FW/1 - some are very specific to the Coldbox framework - but the modules discussed in this post support standalone usage. ↩︎
One reason for this is to ensure that I can update the modules when new versions are released and not worry about overwriting custom code modifications.. ↩︎
For example, to exclude
/modules
from version control if they're being installed and managed programmatically. like/node_modules
. ↩︎Hyper's request builder can be used without configuring defaults, but in my experience it's much easier if you do. ↩︎
The cbpaginator dependency was added in December 2019, as part of qb's 7.0.0 release, which is why it's not mentioned in Tony's guide to qb and FW/1. ↩︎
Just to avoid any confusion, it doesn't log to the browser console; the ConsoleAppender uses
System.out.println
to log, so you'll see the information if you're following the server's standard output. ↩︎