Mapping an AngularJS client into a Force.com server

Client-side MVC frameworks such as AngularJS move most of an application into the browser. The server’s job is largely just to deliver static files – HTML, CSS, JavaScript, images – normally located in a tree of directories. And to provide a JSON-based RESTful API using HTTP GET/PUT operations to read and write model objects.

Force.com’s @RestResource Apex classes are an effective way of implementing a JSON-based RESTful API. But Force.com is by design not a general purpose web server, so mapping a typical tree of client files into the page and static resource mechanisms that are available is a bit awkward. Below is one approach to that problem.

One of the pleasures of developing this sort of client-side logic is that (at least on a Mac) all it takes to get started is to put a few files in a directory and then run “python -m SimpleHTTPServer 8000” in that directory. As you edit and add, you see the results immediately: no 10 second delays while files are copied up to a remote server. You pretty naturally end up with a directory tree. So I make this layout of the files essentially the “master”. (A downside of working this way is that browsers like Chrome block requests to servers – such as the remote RESTful API one – other than the server that the pages came from. But handily Chrome has a --disable-web-security command-line option to turn this checking off while you are developing.) This layout can also be zipped and used pretty much directly in tools like Adobe PhoneGap Build so your HTML 5 application can be delivered as an iOS or Android etc app.

But how to transform this master layout into Force.com pages and static resources so the entire app can be served from Force.com? The good news is that thanks to URLFOR, a static resource can be a zip file and a page can reference files and directories within that zip file. So CSS, JavaScript and images can all go into a single static resource. However, the HTML that includes references to the other objects has to go into pages as that is the context that URLFOR works in. The raw HTML also has to be wrapped in Visualforce:

<apex:page showHeader="false" sidebar="false" standardStylesheets="false" applyHtmlTag="false">
    ....
</apex:page>

and the naming flattened as directories are not supported. Visualforce will also report any “well formed XML” violations.

This re-organization of course means that references between the files also have to be adjusted. For a limited size project, the changes can be worked out by hand and treated as a set of search/replace strings (the filter elements below).

Doing all this transformation work by hand is obviously tedious and error-prone. So below is the source code of Ant task that can be configured like this:

<webtosf fromdir="." todir="../EepServer/src">
	<fileset dir="css"/>
	<fileset dir="data"/>
	<fileset dir="js"/>
	<filter token="lib/bootstrap/bootstrap.min.css" value="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.min.css"/>
	<filter token="lib/angular/angular.min.js" value="//ajax.googleapis.com/ajax/libs/angularjs/1.2.0-rc.3/angular.min.js"/>
	<filter token="js/app.js" value="{!URLFor($Resource.appzip, 'js/app.js')}"/>
	<filter token="data/Acme-420x114.png" value="{!URLFor($Resource.appzip, 'data/Acme-420x114.png')}"/>
	<filter token="partials/login.html" value="partialslogin"/>
</webtosf>

to do all the work repeatedly and reliably.

Feel free to use this code and change it as you like:

package com.claimvantage.ant;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.DirectoryScanner;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.Task;
import org.apache.tools.ant.types.FileSet;

/**
 * Transforms a fairly free-form JavaScript web app layout into Force.com resources.
 * A set of text replacements are applied to any .css, .js or .html files
 * to e.g. fix resource references and necessary name changes.
 * Any .html files are converted into individual Force.com pages.
 * Other files are all added to a single zip file static resource called appzip.
 */
public class WebToSf extends Task {

    private static final String LF = System.getProperty("line.separator");

    public static class Filter {

        private String token;
        private String value;

        public void setToken(String token) {
            this.token = token;
        }

        public void setValue(String value) {
            this.value = value;
        }
    }

    // Root to read from and write to
    private File fromDir;
    private File toDir;

    // Files that go into the named zip
    private List<FileSet> zipContents = new ArrayList<FileSet>();

    // Replacements
    private List<Filter> filters = new ArrayList<Filter>();

    public void setFromDir(File fromDir) {
        this.fromDir = fromDir;
    }

    public void setToDir(File toDir) {
        this.toDir = toDir;
    }

    public void addFileset(FileSet zipContent) {
        zipContents.add(zipContent);
    }

    public Filter createFilter() {
        Filter filter = new Filter();
        filters.add(filter);
        return filter;
    }

    public void execute() {

        if (fromDir == null) {
            throw new BuildException("fromdir must be set");
        }
        if (!fromDir.exists()) {
            throw new BuildException("fromdir " + fromDir.getAbsolutePath() + " does not exist");
        }
        if (toDir == null) {
            throw new BuildException("todir must be set");
        }
        for (int i = 0; i < filters.size(); i++) {
            if (filters.get(i).token == null) {
                throw new BuildException("token missing from filter index " + i);
            }
        }
        for (int i = 0; i < filters.size(); i++) {
            if (filters.get(i).value == null) {
                throw new BuildException("value missing from filter index " + i);
            }
        }

        try {
            zip();
            pages(fromDir);
        } catch (Exception e) {
            throw new BuildException(e);
        }
    }

    private void zip() throws Exception {
        
        File staticresources = new File(toDir, "staticresources");
        if (!staticresources.exists()) {
            staticresources.mkdirs();
        }

        // Data
        ZipOutputStream zos = new ZipOutputStream(new BufferedOutputStream(
                new FileOutputStream(new File(staticresources, "appzip" + ".resource"))));
        try {
            for (FileSet fs : zipContents) {
                
                if (!fs.isFilesystemOnly()) {
                    throw new BuildException("only filesystem flesets supported");
                }
                
                DirectoryScanner ds = fs.getDirectoryScanner(getProject());
                File baseDir = getProject().getBaseDir();
                File fsDir = fs.getDir(getProject());

                // Keep path as folders
                String path = "";
                for (String part : pathDifference(baseDir, fsDir)) {
                    path += part;
                    path += "/";
                }
                
                for (String fsName : ds.getIncludedFiles()) {
                    
                    String newName = path + fsName;
                    
                    log("zipping dir=" + fsDir + " file=" + fsName + " to=" + newName,
                            Project.MSG_INFO);

                    zos.putNextEntry(new ZipEntry(newName));
                    if (isText(fsName)) {
                        // Replace
                        BufferedReader r = new BufferedReader(new InputStreamReader(
                                new FileInputStream(new File(fsDir, fsName))));
                        try {
                            String line;
                            while ((line = r.readLine()) != null) {
                                zos.write(replace(line).getBytes());
                                zos.write(LF.getBytes());
                            }
                            zos.closeEntry();
                        } finally {
                            r.close();
                        }
                    } else {
                        // Just byte for byte copy
                        BufferedInputStream is = new BufferedInputStream(
                                new FileInputStream(new File(fsDir, fsName)));
                        try {
                            byte[] buf = new byte[4092];
                            int len;
                            while ((len = is.read(buf)) != -1) {
                                zos.write(buf, 0, len);
                            }
                        } finally {
                            is.close();
                        }
                    }
                }
            }
        } finally {
            zos.close();
        }
        
        // Meta
        BufferedWriter ww = new BufferedWriter(new FileWriter(
                new File(staticresources, "appzip" + ".resource-meta.xml")));
        try {
            ww.write(""
                    + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + LF
                    + "<StaticResource xmlns=\"http://soap.sforce.com/2006/04/metadata\">" + LF
                    + "    <cacheControl>Public</cacheControl>" + LF
                    + "    <contentType>application/zip</contentType>" + LF
                    + "</StaticResource>"
                    );
        } finally {
            ww.close();
        }
    }

    private boolean isText(String name) {
        
        String lc = name.toLowerCase();
        return lc.endsWith(".css") || lc.endsWith(".js");
    }

    private void pages(File dir) throws Exception {

        File pages = new File(toDir, "pages");
        if (!pages.exists()) {
            pages.mkdirs();
        }

        for (File f : dir.listFiles()) {
            if (f.isDirectory()) {
                pages(f);
            } else {
                if (isHtml(f.getName())) {
                    page(pages, f);
                }
            }
        }
    }

    private boolean isHtml(String name) {
        
        String lc = name.toLowerCase();
        return lc.endsWith(".html");
    }

    private void page(File pages, File f) throws Exception {
        
        // Prepend path
        String name = "";
        for (String path : pathDifference(getProject().getBaseDir(), f.getParentFile())) {
            name += cleanName(path);
        }
        name += cleanName(removeSuffix(f.getName()));
        
        File to = new File(pages, name + ".page");
        File toMeta = new File(pages, name + ".page-meta.xml");
        log("transforming page file=" + f + " to=" + to, Project.MSG_INFO);

        BufferedReader r = new BufferedReader(new FileReader(f));
        try {

            // Data
            BufferedWriter w = new BufferedWriter(new FileWriter(to));
            try {
                w.write("<apex:page showHeader=\"false\" sidebar=\"false\""
                        + " standardStylesheets=\"false\""
                        + " applyHtmlTag=\"false\">" + LF + LF);
                String line;
                while ((line = r.readLine()) != null) {
                    w.write(replace(line));
                    w.write(LF);
                }
                w.write(LF + "</apex:page>");
            } finally {
                w.close();
            }

            // Meta
            BufferedWriter ww = new BufferedWriter(new FileWriter(toMeta));
            try {
                ww.write("" + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + LF
                        + "<StaticResource xmlns=\"http://soap.sforce.com/2006/04/metadata\">" + LF
                        + "    <apiVersion>29.0</apiVersion>" + LF
                        + "    <label>" + name + "</label>" + LF
                        + "</StaticResource>"
                        );
            } finally {
                ww.close();
            }
        } finally {
            r.close();
        }
    }

    private String replace(String line) {
        
        for (Filter f : filters) {
            if (line.contains(f.token)) {
                log("... replacing " + f.token + " in line " + line, Project.MSG_INFO);
                line = line.replace(f.token, f.value);
            }
        }
        return line;
    }

    private String cleanName(String name) {

        StringBuffer sb = new StringBuffer();
        for (int i = 0; i < name.length(); i++) {
            char c = name.charAt(i);
            if (Character.isLetter(c) || Character.isDigit(c) || c == '_') {
                sb.append(c);
            }
        }
        return sb.toString();
    }
    
    private List<String> pathDifference(File baseDir, File subDir) {
        
        List<String> parts = new ArrayList<String>();
        for (File f = subDir; !baseDir.equals(f); f = f.getParentFile()) {
            parts.add(f.getName());
        }
        Collections.reverse(parts);
        return parts;
    }
    
    private String removeSuffix(String name) {
        
        int index = name.lastIndexOf('.');
        return index != -1 ? name.substring(0, index) : name;
    }
}
Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s