Java tutorial
/* Copyright 2012 Fabrizio Cannizzo * * This file is part of RestFixture. * * RestFixture (http://code.google.com/p/rest-fixture/) is free software: * you can redistribute it and/or modify it under the terms of the * GNU Lesser General Public License as published by the Free Software Foundation, * either version 3 of the License, or (at your option) any later version. * * RestFixture is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with RestFixture. If not, see <http://www.gnu.org/licenses/>. * * If you want to contact the author please leave a comment here * http://smartrics.blogspot.com/2008/08/get-fitnesse-with-some-rest.html */ package smartrics.rest.fitnesse.fixture.ext; import com.patternity.graphic.behavioral.Agent; import com.patternity.graphic.behavioral.Message; import com.patternity.graphic.behavioral.Note; import com.patternity.graphic.dag.Node; import com.patternity.graphic.layout.sequence.SequenceLayout; import com.patternity.util.TemplatedWriter; import fitnesse.util.Base64; import org.apache.batik.transcoder.TranscoderException; import org.apache.batik.transcoder.TranscoderInput; import org.apache.batik.transcoder.TranscoderOutput; import org.apache.batik.transcoder.image.ImageTranscoder; import org.apache.batik.transcoder.image.JPEGTranscoder; import org.apache.batik.transcoder.image.PNGTranscoder; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import smartrics.rest.client.RestData.Header; import smartrics.rest.fitnesse.fixture.PartsFactory; import smartrics.rest.fitnesse.fixture.RestFixture; import smartrics.rest.fitnesse.fixture.ext.SlimRestFixtureWithSeq.Model; import smartrics.rest.fitnesse.fixture.support.CellWrapper; import smartrics.rest.fitnesse.fixture.support.Tools; import java.awt.*; import java.io.*; import java.util.EventListener; import java.util.HashMap; import java.util.List; import java.util.Map; /** * An extension of RestFixture that generates a sequence diagrams for a table * fixture. Sequence diagrams are generated as SVG files using <a * href="http://cyrille.martraire.com/2008/12/new-java-api-uml-diagrams/" * >Patternity Graphic</a>. <br/> * Each picture can then be transcoded into either PNG or JPG format (via <a * href="http://xmlgraphics.apache.org/batik/using/transcoder.html">Batik * transcoder API</a>). The format is inferred by the file extension. <br/> * The fixture supports a configuration property. * <table border="1"> * <tr> * <td>restfixture.graphs.dir</td> * <td>destination directory where the images with sequence diagrams will be * created. The directory will be created if not existent; the fixture will fail * if the directory can't be created</td> * </tr> * </table> * <br/> * If the directory specified by restfixture.graphs.dir is created under * <code><i>FitNesseRoot</i>/files</code> the generated images can be embedded * in the FitNesse pages. <br/> * Including images can be achieved via <code>!img</code> (for PNG or JPG) or * via a specific FitNesse symbol, <code>!svg</code>, for native svg files * {@see smartrics.rest.fitnesse.fixture.SvgImage} <br/> * <b>NOTE</b>: This class only works with Fit runner (not Slim) <br/> * Using the fixture is straightforward. Like the RestFixture, the hostname * needs to be specified. Additionally a new cell needs to be supplied with some * data pertaining the creation of the image file. <br/> * <table border="1"> * <tr> * <td>RestFixtureWithSeq</td> * <td>hostname</td> * <td>image data</td> * </tr> * </table> * <br/> * Image data is a string containing path to the image file, relative to the * value of the <code>restfixture.graphs.dir</code> directory. <br/> * The string is followed by a list of attributes passed to the SVG generator * for inclusion in the SVG file. for example * * <table border="1"> * <tr> * <td>RestFixtureWithSeq</td> * <td>hostname</td> * <td>post_images/a_post_image.svg viewBox="0 0 200 200" width="100" * height="150"</td> * </tr> * </table> * <br/> * If the file path contains spaces, it must be included in double quotes. Each * attribute value must be included in double quotes. * * @author smartrics * */ public class SlimRestFixtureWithSeq extends RestFixture { public static final String DEFAULT_GRAPH_DIR_PROPERTY_NAME = "restfixture.graphs.dir"; /** * Model interface for storing http events. * * @author smartrics * */ public interface Model { void delete(String res, String args, String ret); void comment(String body); void get(String res, String args, String ret); void post(String res, String args, String result); void put(String res, String args, String ret); } static final Log LOG = LogFactory.getLog(SlimRestFixtureWithSeq.class); /** * Default directory where the diagrams are generated. * * The value is <code>new File("restfixture")</code>, a directory relative * to the default fitnesse root directory. */ private File graphFileDir; /** * This fixture instance picture name. */ private String pictureName; /** * This fixture instance picture data. * * Picture data is a composite string containig the path to the image file * and a sequence of attributes in the form of name=value. */ private String pictureData; private Model model; private boolean initialised; /** * Listens to events raised by the fixture and captures them in the model. */ private MyFixtureListener myFixtureListener; /** * Svg attributes. */ private Map<String, String> attributes; /** * File format. */ private String format; @SuppressWarnings("rawtypes") private CellWrapper cell; public SlimRestFixtureWithSeq() { super(); this.initialised = false; LOG.info("Default ctor"); } public SlimRestFixtureWithSeq(String hostName, String pictureData) { super(hostName); this.pictureData = pictureData; this.initialised = false; } public SlimRestFixtureWithSeq(String hostName, String configName, String pictureData) { super(hostName, configName); this.pictureData = pictureData; this.initialised = false; } public SlimRestFixtureWithSeq(PartsFactory partsFactory, String hostName, String configName, String pictureData) { super(partsFactory, hostName, configName); this.pictureData = pictureData; this.initialised = false; } /** * embeds as a <img> with content encoded the model caprured so far. */ public void embed() { cell = row.getCell(1); byte[] content = PictureGenerator.generate(model.toString(), parseAttributes(cell.body()), "template.svg", format); cell.body(getFormatter().gray( "<img src=\"data:image/" + format + ";base64," + new String(Base64.encode(content)) + "\" />")); } public void setModel(Model model) { this.model = model; } @Override protected void initialize(Runner runner) { super.initialize(runner); initializeFields(); createSequenceModel(); initialised = true; String defaultPicsDir = System.getProperty(DEFAULT_GRAPH_DIR_PROPERTY_NAME, "FitNesseRoot/files/restfixture"); String picsDir = getConfig().get(DEFAULT_GRAPH_DIR_PROPERTY_NAME, defaultPicsDir); graphFileDir = new File(picsDir); if (!graphFileDir.exists()) { if (!graphFileDir.mkdirs()) { throw new RuntimeException( "Unable to create the diagrams destination dir '" + graphFileDir.getAbsolutePath() + "'"); } else { LOG.info("Created diagrams destination directory '" + graphFileDir.getAbsolutePath() + "'"); } } LOG.info("Generated diagrams directory: '" + graphFileDir.getAbsolutePath() + "'"); myFixtureListener = new MyFixtureListener(new File(graphFileDir, this.getPictureName()).getAbsolutePath(), model, attributes); setFixtureListener(myFixtureListener); } /** * State of the RestFixtureWithSeq is valid (or true) if both the baseUrl * and picture name are not null. These parameters are the first and second * in input to the fixture. * * @return true if valid. */ @Override protected boolean validateState() { return getBaseUrl() != null && pictureData != null; } @Override protected void notifyInvalidState(boolean state) { if (!state) { throw new RuntimeException( "Both baseUrl and picture data (containing the picture name) need to be passed to the fixture"); } } protected void createSequenceModel() { if (!initialised) { LOG.info("Initialising sequence model"); this.model = new SequenceModel(); } } /** * Overridden as SLIM can't find it. * * Note: for SLIM to find this method it has to be defined in the java file * after the override of the ActionFixture method */ @Override public List<List<String>> doTable(List<List<String>> rows) { List<List<String>> result = super.doTable(rows); myFixtureListener.tableFinished(); return result; } @Override public void setBody() { super.setBody(); } /** * a DELETE generates a message and a return arrows. */ @Override public void DELETE() { super.DELETE(); String res = getLastRequest().getResource(); String args = getLastRequest().getQuery(); String ret = "status=" + getLastResponse().getStatusCode().toString(); model.delete(res, args, ret); } @Override public void comment() { super.comment(); @SuppressWarnings("rawtypes") CellWrapper messageCell = row.getCell(1); String body = messageCell.body(); String plainBody = Tools.fromHtml(body).trim(); model.comment(plainBody); } /** * a GET generates a message and a return arrows. */ @Override public void GET() { super.GET(); String res = getResource(); String args = getLastRequest().getQuery(); String ret = "status=" + getLastResponse().getStatusCode().toString(); model.get(res, args, ret); } /** * a POST generates a message to the resource type, which in turn generates * a create to the resource just created. The resource uri must be defined * in the <code>Location</code> header in the POST response. A return arrow * is then generated. */ @Override public void POST() { super.POST(); String res = getResource(); String id = getIdFromLocationHeader(); // could ever be that the POST to /abc returns a location of /qwe/1 ?? String result = String.format("id=%s, status=%s", id, getLastResponse().getStatusCode().toString()); String args = getLastRequest().getQuery(); model.post(res, args, result); } /** * a PUT generates a message and a return arrows. */ @Override public void PUT() { super.PUT(); String res = getResource(); String args = getLastRequest().getQuery(); String ret = "status=" + getLastResponse().getStatusCode().toString(); model.put(res, args, ret); } /** * the picture name is the second parameter of the fixture. * * @return the picture name */ String getPictureName() { return pictureName; } void setFixtureListener(MyFixtureListener l) { this.myFixtureListener = l; } private static String getPictureFormat(String pictureName) { int pos = pictureName.indexOf("."); if (pos >= 0) { return pictureName.substring(pos + 1).toLowerCase(); } throw new IllegalArgumentException("The picture name must terminate with an extension of .svg, .png, .jpg"); } private void initializeFields() { if (!initialised) { String data = pictureData; LOG.info("Picture data = " + pictureData); int[] pos = getPositionOfNextOfTokenOptionallyInDoubleQuotes(data); this.pictureName = data.substring(pos[0], pos[1]); LOG.info("Found picture name: " + pictureName); if (pos[1] < data.length()) { data = data.substring(pos[1] + 1); this.attributes = parseAttributes(data); } this.format = getPictureFormat(pictureName); } } private static Map<String, String> parseAttributes(String data) { Map<String, String> foundAttributes = new HashMap<String, String>(); while (true) { int eqPos = data.indexOf("="); if (eqPos < 0) { break; } String aName = data.substring(0, eqPos); LOG.info("Found attribute name: " + aName); data = data.substring(eqPos + 1); int[] pos = getPositionOfNextOfTokenOptionallyInDoubleQuotes(data); String aVal = data.substring(pos[0], pos[1]); LOG.info("Found attribute val: " + aVal + ", pos[" + pos[0] + ", " + pos[1] + "]"); foundAttributes.put(aName, aVal); if (data.length() - aVal.length() == 0) { break; } data = data.substring(pos[1] + 1); } return foundAttributes; } private static int[] getPositionOfNextOfTokenOptionallyInDoubleQuotes(String data) { String del = " "; int start = 0; if (data.trim().startsWith("\"")) { del = "\""; start = 1; } int end = data.indexOf(del, start + 1); if (end == -1) { end = data.length(); } return new int[] { start, end }; } private String[] guessParts(String res) { String[] empty = new String[] { "?", "" }; if (res == null) { return empty; } String myRes = res.trim(); if (myRes.isEmpty()) { return empty; } int pos = myRes.lastIndexOf("/"); if (pos == myRes.length() - 1) { pos = -1; myRes = myRes.substring(0, myRes.length() - 1); } String[] parts = new String[2]; if (pos >= 0) { parts[0] = myRes.substring(0, pos); parts[1] = myRes.substring(pos + 1); } else { parts[0] = myRes; parts[1] = ""; } return parts; } private String getIdFromLocationHeader() { List<Header> list = getLastResponse().getHeader("Location"); String location = ""; if (list != null && !list.isEmpty()) { location = list.get(0).getValue(); } String[] parts = guessParts(location); return parts[1]; } private String getResource() { String res = getLastRequest().getResource(); if (res.endsWith("/")) { res = res.substring(0, res.length() - 1); } return res; } } /** * Holds the sequence diagram model, specifically abstratcs out the underlying * library constructing the SVG picture. * * @author smartrics * */ class SequenceModel implements Model { private Map<String, Resource> resourceToAgentMap; private Node root; private SequenceLayout layout; /** * Hints to the SVG files generator. */ private static final int DEFAULT_FONT_SIZE = 16; private static final int DEFAULT_AGENT_STEP = 150; private static final int DEFAULT_TIME_STEP = 25; SequenceModel() { Message message = new Message(null, null); Node root = new Node(message); SequenceLayout layout = new SequenceLayout(DEFAULT_FONT_SIZE); layout.setAgentStep(DEFAULT_AGENT_STEP); layout.setTimeStep(DEFAULT_TIME_STEP); this.root = root; this.layout = layout; this.resourceToAgentMap = new HashMap<String, Resource>(); } public void comment(String text) { root.add(new Node(new Note(Tools.fromHtml(text)))); } public void get(String resource, String query, String result) { message(Message.SYNC, resource, "GET", query, result); } public void post(String resource, String query, String result) { message(Message.SYNC, resource, "POST", query, result); } public void put(String resource, String query, String result) { message(Message.SYNC, resource, "PUT", query, result); } public void delete(String resource, String query, String result) { message(Message.DESTROY, resource, "DELETE", query, result); } public String toString() { return layout.layout(root); } private void message(int type, String resourceTo, String method, String args, String result) { Agent agentTo = agentFor(resourceTo); String methodSignature = method; if (args != null) { methodSignature = method + "(" + args + ")"; } String resultString = ""; if (result != null) { resultString = result; } Message message = new Message(type, agentTo, methodSignature, resultString); root.add(new Node(message)); } private Resource agentFor(String resource) { Resource a = resourceToAgentMap.get(resource); if (a == null) { final boolean isActivable = true; a = new Resource(resource, isActivable); resourceToAgentMap.put(resource, a); } return a; } } /** * A representation of a resource for the purposes of generating the sequence * diagram. * * @author smartrics * */ class Resource extends Agent { public Resource(String type, boolean isActivable) { super(type, "", isActivable); } public String toString() { return isEllipsis() ? "..." : (new StringBuilder()).append(getType()).toString(); } } /** * A <code>fit.FixtureListener</code> that listens for a table being completed. * the action performed on table completion is the actual graph generation. * * @author smartrics */ class MyFixtureListener implements EventListener { private final Model model; private final String picFileName; private final Map<String, String> attributes; public MyFixtureListener(String outFileName, Model m, Map<String, String> attr) { model = m; attributes = attr != null ? attr : new HashMap<String, String>(); picFileName = outFileName; } /** * generates the sequence diagram with the events collected in the model. */ public void tableFinished() { int pos = picFileName.lastIndexOf("."); String format = "svg"; if (pos > 0) { format = picFileName.substring(pos + 1).toLowerCase(); } byte[] content = PictureGenerator.generate(model.toString(), attributes, "template.svg", format); File f = new File(picFileName); try { f.createNewFile(); } catch (IOException e1) { throw new IllegalArgumentException("Unable to create output picture file: " + f.getAbsolutePath(), e1); } FileOutputStream fos = null; try { fos = new FileOutputStream(f); } catch (FileNotFoundException e1) { throw new IllegalArgumentException("Unable to find output picture file: " + f.getAbsolutePath(), e1); } try { fos.write(content); } catch (IOException e1) { throw new IllegalArgumentException("Unable to write output picture file: " + f.getAbsolutePath(), e1); } try { fos.flush(); } catch (IOException e1) { throw new IllegalArgumentException("Unable to flush output picture file: " + f.getAbsolutePath()); } try { if (fos != null) { fos.close(); } } catch (IOException e) { } } } /** * Utility class to generate picture wrapping the Patternity Graphic svg * utility. * * @author smartrics * */ class PictureGenerator { private PictureGenerator() { } /** * generates a byte array with the content of the picture from an svg * template. * * @param content * the picture content. * @param attributes * the diagram attributes. * @param svgTemplate * the svg template to use. * @param format * the format of the output rasterised image. * @return a byte array of the picture in the given format. */ public static byte[] generate(String content, Map<String, String> attributes, String svgTemplate, String format) { ByteArrayOutputStream baos = new ByteArrayOutputStream(); final TemplatedWriter writer = new TemplatedWriter(baos, svgTemplate); StringBuffer sb = new StringBuffer(); for (Map.Entry<String, String> e : attributes.entrySet()) { sb.append(e.getKey()).append("=\"").append(e.getValue()).append("\" "); } writer.write(content, sb.toString()); byte[] ret = baos.toByteArray(); try { baos.close(); } catch (IOException e) { SlimRestFixtureWithSeq.LOG.debug("Exception closing byte array output stream"); } if (format.equals("svg")) { return ret; } else { Integer w = null; String widthString = attributes.get("width"); if (widthString != null) { try { w = Integer.parseInt(widthString); } catch (NumberFormatException e) { SlimRestFixtureWithSeq.LOG.debug("Unable to parse width as integer: " + widthString); } } Integer h = null; String heightString = attributes.get("height"); if (heightString != null) { try { h = Integer.parseInt(heightString); } catch (NumberFormatException e) { SlimRestFixtureWithSeq.LOG.debug("Unable to parse height as integer: " + heightString); } } return transcode(ret, format, w, h); } } public static byte[] transcode(byte[] svg, String format, Integer w, Integer h) { ImageTranscoder trans = null; if (format.equals("jpg")) { trans = new JPEGTranscoder(); } else if (format.equals("png")) { trans = new PNGTranscoder(); } else { throw new IllegalArgumentException("Unsupported raster format. Only jpg and png: " + format); } TranscoderInput input = new TranscoderInput(new ByteArrayInputStream(svg)); ByteArrayOutputStream ostream = new ByteArrayOutputStream(); TranscoderOutput output = new TranscoderOutput(ostream); if (w != null && h != null) { Rectangle aoi = new Rectangle(w, h); trans.addTranscodingHint(JPEGTranscoder.KEY_WIDTH, new Float(aoi.width)); trans.addTranscodingHint(JPEGTranscoder.KEY_HEIGHT, new Float(aoi.height)); trans.addTranscodingHint(ImageTranscoder.KEY_FORCE_TRANSPARENT_WHITE, Boolean.FALSE); trans.addTranscodingHint(JPEGTranscoder.KEY_AOI, aoi); } try { trans.transcode(input, output); } catch (TranscoderException e) { throw new IllegalStateException("Unable to transcode to format: " + format, e); } try { ostream.flush(); } catch (IOException e) { SlimRestFixtureWithSeq.LOG.debug("Unable to flush output stream"); // should be safe to ignore } try { ostream.close(); } catch (IOException e) { SlimRestFixtureWithSeq.LOG.debug("Unable to close output stream"); // should be safe to ignore } return ostream.toByteArray(); } }