Monday, April 2, 2007

Serving a Downloadable File with ColdFusion in Model Glue

I thought maybe it's time for another "real" blog post. I learned a little something today (with some help from Adam) so now I'll share. A hyperlink to download a PDF file - seems simple and straightforward enough. But I've been building this site using the Model Glue framework (and learning MG in the process -- so far so good!), and I wanted to serve the file with <cfcontent>, not just a plain hyperlink, so it wasn't as straightforward as just <a href="myfile.pdf">Download It</a>...

Here's my solution, I'd be interested to hear how others have done this. It seemed like a little bit overkill as I was writing it, but I also realized that I would never need to write this snippet of code again, so that kind of makes up for the long-windedness of it.

The solution is composed of all the standard files and snippets in a Model Glue application.
  • the "model" CFC - in this case, a simple object
  • a .cfm view file where the "cfcontent" resides
  • a function in the Controller.cfc to create the download object and add it to the event
  • a message broadcast, listener, and event handler in the ModelGlue.xml file
  • all the views, which compose my page and contain the links to download the file

First, let's look at the "Download" Bean CFC: Download.cfc represents my "file" object that stores my file information. It basically just stores values for the file name, file path, and mime type of the file I'm serving for download. It lives in my "model" folder.


<!--- Download.cfc --->
<cfcomponent displayname="Download" hint="retrieves a file for download to be served with cfcontent">

<!--- init() --->

<cffunction name="init" access="public" returntype="any">

<cfargument name="fileName" type="string" required="yes" hint="name of file once downloaded">

<cfargument name="filePath" type="string" required="yes" hint="full path of file to be served">

<cfargument name="fileType" type="string" required="yes" hint="mime type of file">

<cfset filename =" ARGUMENTS.fileName">
<cfset
filepath =" ExpandPath(ARGUMENTS.filePath)">
<cfset
filetype =" ARGUMENTS.fileType">

<cfreturn this>

</cffunction>

<!--- getters --->

<cffunction name="getFilename" returntype="string" access="public" output="false">
<cfreturn filename>
</cffunction>

<cffunction name="getFilePath" returntype="string" access="public" output="false">
<cfreturn filepath>
</cffunction>

<cffunction name="getFileType" returntype="string" access="public" output="false">
<cfreturn fileType>
</cffunction>

</cfcomponent>

Notice that the CFC uses ExpandPath() so that a relative url can be passed in as the location of the file, which is much easier when it comes time to build your link, and then the app is not server-dependent. Yes, I could write setters and use those setters to construct the CFC, but I'm never going to build these objects to say, insert into a database, so I didn't bother.

Then I have the actual "download" include. It's called _download.cfm because the underscore preceding the filename is a common convention for files that should not be called directly, but need to be included in another file, it also differentiates it clearly from your "template" view files.

This file gets dynamically included on the page when the "download" viewState value is present. It also only serves the file if it's not a simple value (to ensure the values we need are there). It lives in the "views" folder. Notice there are separate values for the file path and the file name. The file name is what the user sees when they download the file, the file path tells CF where to locate the file. Being able to set the file name in the header is nice because you can use a short, sweet and simple file name for the end user, but your own convoluted, datestamped , purpose-coded file naming convention for the actual file (ok maybe that's just me).

Also we use the http header so that the user will be prompted with the browser "download" dialog, rather than leaving it to the browser to try and open the file directly. It prevents us from having to list some weird instructions like "right-click and choose Save Target As" for the less-savvy users (arent' they all).



<cfset download =" viewState.getValue('download')">

<cfif NOT isSimpleValue(download)>
<cfheader name="Content-Disposition" value="attachment; filename=#download.getFileName()#">
<cfcontent type="#download.getfiletype()#" file="#download.getfilePath()#" deletefile="no">
</cfif>


My Model Glue Controller.cfc contains this function to build the Download object. It just calls the Download.cfc, creates the "download" object, and then adds the instance of said object to the event so it can be found when the page is rendered.

<!--- Controller.cfc --->

<cffunction name="getMyDoc" access="public" returntype="void" output="false">
<cfargument name="event" type="any" required="yes">
<cfset fileDownload = createObject("component","model.Download").init('MyDoc.pdf','assets/docs/MyDoc_123.pdf','application/pdf')>
<cfset arguments.event.setValue("download", fileDownload)>
</cffunction>


In My ModelGlue.xml file, I hook my application into the above function with this XML snippet.

<message-listener message="getMyDoc" function="getMyDoc" />

The event handler for that message is...

<event-handler name="download.mydoc">
<broadcasts>
<message name="getMyDoc" />
</broadcasts>
<results>
<result do="page.mydoc" />
</results>
</event-handler>

The broadcast, obviously, requests the download object via the Controller.cfc. The result - "do" is the same page that my link is on - so the user does not leave that page when they click the "download" link, they are just served the file. Lastly, on the page where I want the link that is clicked to download this file, this is the <a> tag:

<a href="#viewState.getValue('myself')#download.mydoc">Download My Doc</a>

Nice. That's all there is to it.

Of course this sample doesn't show any security or anything, this would only be appropriate for a public download. Sorry if the spacing and indentation is ugly, Blogger's code formatting is a pain! Hope you find it helpful!

2 comments:

Anonymous said...

Ok how can we use this and make the file name to download dynamic. I have a cfgrid that has file names in it, so how can I get the name from a column in the cfgrid and use it for the file name and then get the expand path etc.


Thanks

Jim

Rae said...

I’m no CFGRID expert, and I’m not familiar with the exact format of your grid, so I’m going to refer you to the docs for figuring out how to get the data you want out of the CFGRID – there are lots of options for columns, rows, cells, etc.

CF 7 LiveDocs - CFGRID

I would probably not use the built-in CFGRIDUPDATE, as I’m not sure how this would work within the Model Glue framework – CFGRIDUPDATE pretty much breaks every MVC rule out there! So that means that you’re going to want to treat the grid data like any other form data, and pass the file for the download with the FORM scope.

Here’s how you process a form within Model Glue:

Within the <views> block of your MG xml, in the include that is the main body of your page (where your form is), add a value for an “exit event”, where the value of your exit event is the event handler that will process your form:

<include name="body" template="myView.cfm">

<value name="xe.submitGrid" value="submitGridAction" />

</include>

Within the body of that include, place your CFFORM with your CFGRID, and for the “submit” action, use something like:

<cfset submit = viewstate.getValue("myself") & viewstate.getValue("xe.submitGrid") />

This submit variable now holds the passed exit event value from the event object, and tells the form which event handler to submit to. Then in the action attribute of your CFFORM, insert the #submit# variable.

When the form is submitted your event handler broadcasts a message, which calls a function on your controller, and the controller is where you dynamically get the name of the file (from the FORM scope which, in Model Glue, is in the Event object) for your download. (I glossed over this assuming some knowledge of MG…)

To get the filename passed in the form from the Event object:

<cfset myFile = arguments.event.getValue("filename")>

(Where the filename parameter is the form field that contains the file name.)

Then you can create a new Download object (Download.cfc) from that file name, pass in the path and file type, and proceed with the rest of the tutorial as usual.

In other words, all we are doing is sending the file name dynamically from the view using the FORM scope, rather than hard coding the file name into the view.
HTH!