JavaScript Mapping Library
Last week I shared a look at how to integrate the Adobe Photoshop API with ColdFusion, and that got me itching to see how difficult it would be to do the same with our Acrobat Services. While ColdFusion has native PDF features built-in, I think there are aspects of the platform that may be of use to CF developers.
Let’s start by briefly describing what Acrobat Services are. At a high level, they’re all about document management via APIs. Broadly the services are categorized like so:
Note that all of these services are available now for free with 500 document transactions per month. SDKs are available for Java, .NET, Node, and Python. There’s also a powerful REST API I’ll be making use of in my demo. In the past, I’ve recommended the excellent Java SDK wrapper built by Tony Junkes. It handled some conflicts between our Java SDK and ColdFusion. Now however I’d recommend just hitting up the REST API.
Put simply, Document Generation let’s you create a template in Microsoft Word. You then send that template to our API along with your data, and you get a custom PDF (or Word doc) out of it. Here’s an incredibly simple example. Imagine this Word doc:
See the various code-like-looking things in there? Each of these will be parsed by the API when you send your data. You can do simple variable replacements, conditional logic, and even looping. In the example above it’s just a list, but you can create dynamic tables. Dynamic images are supported as well.
We’ve got an online playground where you can test it out, and even a Word Add-In to make it easier for non-developers to test.
Given the Word template above, imagine this data:
{"name":"Raymond", "state":"Louisiana","skills": [ { "name": "cats" }, { "name": "star wars" }]}
You get this output (and I’m using PDF Embed here as an example):
As I said, this is rather simple and you can absolutely build more complex templates, but it gives you an idea.
In order for us to use this in ColdFusion and make use of the REST API, all of the services follow the same basic pattern:
Document Generation is a bit different in that it recently added additional support for SharePoint and S3. Soon all the services will support this. For now, though I’m going to use a local file for my testing.
For this demo, I’m going to automate the process of creating offer letters for prospective job candidates. The data will be stored in a simple MySQL table. Here’s my Word template:
I’ve got tokens for first and last names, salary, and a conditional piece of logic based on where they live. Also, note the use of $ formatNumber. The template language used in Document Generation is JSONata and while not everything is supported, you can use many of the formatting functions.
$ formatNumber
Now let’s consider the code. As a reminder, my CFML is quite rusty, so probably don’t consider this ‘best practice’ ColdFusion.
First, I grab the data:
<cfquery name="prospectives" datasource="demo">select id, firstName, lastName, position, salary, state from prospectives</cfquery>
The rest of the code is in a CFSCRIPT block I won’t type in here. Let’s create a variable pointing to the Word doc:
docpath = expandPath('./offer.docx');
Next I’ll make an instance of my CFC:
asService = new acrobatservices(clientId=application.CLIENT_ID, clientSecret=application.CLIENT_SECRET);
Note I’m passing in my OAuth credentials here.
For Acrobat Services, the files you upload are stored for 24 hours. These are secured and even we don’t have access to them. You can delete them as well if you want. For our case though we can use this to upload the Word document and use it for our data.
asset = asService.createAsset(docpath);writeoutput('<p>Uploaded asset id is #asset#</p>');
Next, I loop over my query:
for(person in prospectives) {// stuff here...}
Inside the loop, I first do a bit of data manipulation. The case of my JSON data has to match the case in the Word document. So I handle that here:
/*Case matters for Document Generation, so let's 'reshape' person*/personOb = { "firstName": person.firstName, "lastName": person.lastName, "position": person.position, "salary": person.salary, "state": person.state}
I then create the job:
pollLocation = asService.createDocGenJob(asset, personOb);writeoutput('<p>Location to poll is #pollLocation#</p>');
And then poll in a delayed loop. Note the utter lack of error handling here. I’m a 10X developer.
done = false;while(!done) { job = asService.getJob(pollLocation); writedump(var=job, label="Latest job status"); if(job.status == 'in progress') { sleep(2 * 1000); } else done = true;}
When done, I then save the PDF:
// assume goodpdfpath = expandPath('./result#person.id#.pdf');asService.downloadAsset(job.asset, pdfpath);
Notice that I’m using the primary key from the database to create unique filenames. I could also email this to the prospective as well.
Here’s an example result:
Cool, now let’s look at the CFC. The beginning is very similar to the Photoshop one I shared last week:
component accessors="true" { property name="clientId" type="string"; property name="clientSecret" type="string"; variables.REST_API = "https://pdf-services.adobe.io/"; function init(clientId, clientSecret) { variables.clientId = arguments.clientId; variables.clientSecret = arguments.clientSecret; return this; } public function getAccessToken() { if(structKeyExists(variables, 'accessToken')) return variables.accessToken; var imsUrl = 'https://ims-na1.adobelogin.com/ims/token/v2?client_id=#variables.clientId#&client_secret=#variables.clientSecret#&grant_type=client_credentials&scope=openid,AdobeID,read_organizations'; var result = ''; cfhttp(url=imsUrl, method='post', result='result') { cfhttpparam(type='body', value=''); }; result = deserializeJSON(result.fileContent); variables.accessToken = result.access_token; return variables.accessToken; }
As mentioned above, the process of uploading an asset is two steps. Create the asset record, which will give you an ID and URL, and then upload it. We can make that easier, right? So here’s one method for it:
/*I wrap the logic of creating and uploading an asset path*/public function createAsset(path) { var result = ''; var token = getAccessToken(); var mimeType = fileGetMimeType(arguments.path); var body = { "mediaType": mimeType }; body = serializeJSON(body); cfhttp(url=REST_API & '/assets', method='post', result='result') { cfhttpparam(type='header', name='Authorization', value='Bearer #token#'); cfhttpparam(type='header', name='x-api-key', value=variables.clientId); cfhttpparam(type='header', name='Content-Type', value='application/json'); cfhttpparam(type='body', value=body); } var assetInfo = deserializeJSON(result.fileContent); cfhttp(url=assetInfo.uploadUri, method='put', result='result') { cfhttpparam(type='body', value=fileReadBinary(arguments.path)); cfhttpparam(type='header', name='Content-Type', value=mimeType); } if(result.responseheader.status_code == 200) return assetInfo.assetID; else throw('Unknown error');}
Creating the Document Generation job is just a matter of passing in the data and crafting the API response. My method supports a fragments argument I didn’t go into, but you can consider it like a ‘snippets’ list of token shortcuts for more advanced usage.
fragments
public function createDocGenJob(assetID, data, fragments={}, outputformat="pdf") { var token = getAccessToken(); var result = ''; var body = { "assetID":arguments.assetID, "outputFormat":arguments.outputformat, "jsonDataForMerge":arguments.data, "fragments":arguments.fragments }; cfhttp(url=REST_API & '/operation/documentgeneration', method='post', result='result') { cfhttpparam(type='header', name='Authorization', value='Bearer #token#'); cfhttpparam(type='header', name='x-api-key', value=variables.clientId); cfhttpparam(type='header', name='Content-Type', value='application/json'); cfhttpparam(type='body', value=serializeJSON(body)); }; if(result.responseheader.status_code == 201) return result.responseheader.location; else throw('Unknown error');}
Checking the job is the same code as before, but note the ‘shape’ of the job result isn’t the same.
public function getJob(jobUrl) { var token = getAccessToken(); var result = ''; cfhttp(url=jobUrl, method='get', result='result') { cfhttpparam(type='header', name='Authorization', value='Bearer #token#'); cfhttpparam(type='header', name='x-api-key', value=variables.clientId); }; result = deserializeJSON(result.fileContent); return result;}
And then finally, the download method:
public function downloadAsset(assetOb, path) { var result = ""; var dir = getDirectoryFromPath(arguments.path); var filename = getFileFromPath(arguments.path); cfhttp(method="get", url=arguments.assetOb.downloadUri, getasbinary=true, result="result", path=dir, file=filename);}
And that’s it! If you’ve got any questions about this, reach out, and here’s the complete CFC you can copy and paste.
component accessors="true" { property name="clientId" type="string"; property name="clientSecret" type="string"; variables.REST_API = "https://pdf-services.adobe.io/"; function init(clientId, clientSecret) { variables.clientId = arguments.clientId; variables.clientSecret = arguments.clientSecret; return this; } public function getAccessToken() { if(structKeyExists(variables, 'accessToken')) return variables.accessToken; var imsUrl = 'https://ims-na1.adobelogin.com/ims/token/v2?client_id=#variables.clientId#&client_secret=#variables.clientSecret#&grant_type=client_credentials&scope=openid,AdobeID,read_organizations'; var result = ''; cfhttp(url=imsUrl, method='post', result='result') { cfhttpparam(type='body', value=''); }; result = deserializeJSON(result.fileContent); variables.accessToken = result.access_token; return variables.accessToken; } /* I wrap the logic of creating and uploading an asset path */ public function createAsset(path) { var result = ''; var token = getAccessToken(); var mimeType = fileGetMimeType(arguments.path); var body = { "mediaType": mimeType }; body = serializeJSON(body); cfhttp(url=REST_API & '/assets', method='post', result='result') { cfhttpparam(type='header', name='Authorization', value='Bearer #token#'); cfhttpparam(type='header', name='x-api-key', value=variables.clientId); cfhttpparam(type='header', name='Content-Type', value='application/json'); cfhttpparam(type='body', value=body); } var assetInfo = deserializeJSON(result.fileContent); cfhttp(url=assetInfo.uploadUri, method='put', result='result') { cfhttpparam(type='body', value=fileReadBinary(arguments.path)); cfhttpparam(type='header', name='Content-Type', value=mimeType); } if(result.responseheader.status_code == 200) return assetInfo.assetID; else throw('Unknown error'); } public function downloadAsset(assetOb, path) { var result = ""; var dir = getDirectoryFromPath(arguments.path); var filename = getFileFromPath(arguments.path); cfhttp(method="get", url=arguments.assetOb.downloadUri, getasbinary=true, result="result", path=dir, file=filename); } public function createDocGenJob(assetID, data, fragments={}, outputformat="pdf") { var token = getAccessToken(); var result = ''; var body = { "assetID":arguments.assetID, "outputFormat":arguments.outputformat, "jsonDataForMerge":arguments.data, "fragments":arguments.fragments }; cfhttp(url=REST_API & '/operation/documentgeneration', method='post', result='result') { cfhttpparam(type='header', name='Authorization', value='Bearer #token#'); cfhttpparam(type='header', name='x-api-key', value=variables.clientId); cfhttpparam(type='header', name='Content-Type', value='application/json'); cfhttpparam(type='body', value=serializeJSON(body)); }; if(result.responseheader.status_code == 201) return result.responseheader.location; else throw('Unknown error'); } public function getJob(jobUrl) { var token = getAccessToken(); var result = ''; cfhttp(url=jobUrl, method='get', result='result') { cfhttpparam(type='header', name='Authorization', value='Bearer #token#'); cfhttpparam(type='header', name='x-api-key', value=variables.clientId); }; result = deserializeJSON(result.fileContent); return result; } }
Raymond Camden
You must be logged in to post a comment.
This site uses Akismet to reduce spam. Learn how your comment data is processed.