building atlassian plugins with groovy - atlassian summit 2010 - lightning talks
DESCRIPTION
Building Atlassian Plugins with Groovy Paul King, ASERTTRANSCRIPT
11
Groovy PluginsWhy you should be developingAtlassian plugins using Groovy
Dr Paul King, Director, ASERT
22
What is Groovy?
3
“Groovy is like a super version of Java. It can leverage Java's enterprise capabilities but also has cool productivity features like closures, DSL support, builders and dynamic typing.”
Groovy = Java – boiler plate code + optional dynamic typing + closures + domain specific languages + builders + meta-‐programming
3
What is Groovy?
4
Now free
4
What is Groovy?
5
What alternative JVM language are you using or intending to use
http://www.leonardoborges.com/writings
http://it-republik.de/jaxenter/quickvote/results/1/poll/44(translated using http://babelfish.yahoo.com)
Source: http://www.micropoll.com/akira/mpresult/501697-116746
http://www.java.net
http://www.jroller.com/scolebourne/entry/devoxx_2008_whiteboard_votes
Source: http://www.grailspodcast.com/
5
Reason: Language Features• Closures
• Runtime metaprogramming
• Compile-time metaprogramming
• Grape modules
• Builders
• DSL friendly
• Productivity
• Clarity
•Maintainability
•Quality
• Fun
66
Reason: Testing• Support for Testing DSLs and
BDD style tests
• Built-in assert, power asserts
• Built-in testing
• Built-in mocks
• Metaprogramming eases testing pain points
7
• Productivity
• Clarity
•Maintainability
•Quality
• Fun
• Shareability
7
Myth: Dynamic typing == No IDE support• Completion through inference
• Code analysis
• Seamless debugging
• Seamless refactoring
• DSL completion
88
Myth: Scripting == Non-professional• Analysis tools
• Coverage tools
• Testing support
99
Java
10
import java.util.List;import java.util.ArrayList;
class Erase { private List removeLongerThan(List strings, int length) { List result = new ArrayList(); for (int i = 0; i < strings.size(); i++) { String s = (String) strings.get(i); if (s.length() <= length) { result.add(s); } } return result; } public static void main(String[] args) { List names = new ArrayList(); names.add("Ted"); names.add("Fred"); names.add("Jed"); names.add("Ned"); System.out.println(names); Erase e = new Erase(); List shortNames = e.removeLongerThan(names, 3); System.out.println(shortNames.size()); for (int i = 0; i < shortNames.size(); i++) { String s = (String) shortNames.get(i); System.out.println(s); } }}
names = ["Ted", "Fred", "Jed", "Ned"]println namesshortNames = names.findAll{ it.size() <= 3 }println shortNames.size()shortNames.each{ println it }
Groovy
10
Java
11
Groovyimport org.w3c.dom.Document;import org.w3c.dom.NodeList;import org.w3c.dom.Node;import org.xml.sax.SAXException;
import javax.xml.parsers.DocumentBuilderFactory;import javax.xml.parsers.DocumentBuilder;import javax.xml.parsers.ParserConfigurationException;import java.io.File;import java.io.IOException;
public class FindYearsJava { public static void main(String[] args) { DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance(); try { DocumentBuilder builder = builderFactory.newDocumentBuilder(); Document document = builder.parse(new File("records.xml")); NodeList list = document.getElementsByTagName("car"); for (int i = 0; i < list.getLength(); i++) { Node n = list.item(i); Node year = n.getAttributes().getNamedItem("year"); System.out.println("year = " + year.getTextContent()); } } catch (ParserConfigurationException e) { e.printStackTrace(); } catch (SAXException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } }}
def p = new XmlParser()def records = p.parse("records.xml")records.car.each { println "year = ${it.@year}"}
11
Java
12
Groovy
@Immutable class Punter { String first, last}
public final class Punter { private final String first; private final String last;
public String getFirst() { return first; }
public String getLast() { return last; }
@Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((first == null) ? 0 : first.hashCode()); result = prime * result + ((last == null) ? 0 : last.hashCode()); return result; }
public Punter(String first, String last) { this.first = first; this.last = last; } // ...
// ... @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; Punter other = (Punter) obj; if (first == null) { if (other.first != null) return false; } else if (!first.equals(other.first)) return false; if (last == null) { if (other.last != null) return false; } else if (!last.equals(other.last)) return false; return true; }
@Override public String toString() { return "Punter(first:" + first + ", last:" + last + ")"; }
}
12
Java
13
Groovy
@InheritConstructorsclass CustomExceptionextends RuntimeException { }
public class CustomException extends RuntimeException { public CustomException() { super(); }
public CustomException(String message) { super(message); }
public CustomException(String message, Throwable cause) { super(message, cause); }
public CustomException(Throwable cause) { super(cause); }}
13
14
@Grab('org.gcontracts:gcontracts:1.0.2')import org.gcontracts.annotations.*
@Invariant({ first != null && last != null })class Person { String first, last
@Requires({ delimiter in ['.', ',', ' '] }) @Ensures({ result == first+delimiter+last }) String getName(String delimiter) { first + delimiter + last }}
new Person(first: 'John', last: 'Smith').getName('.')
Groovy
@Grab('org.codehaus.gpars:gpars:0.10')import groovyx.gpars.agent.Agent
withPool(5) { def nums = 1..100000 println nums.parallel. map{ it ** 2 }. filter{ it % 7 == it % 5 }. filter{ it % 3 == 0 }. reduce{ a, b -‐> a + b }}
@Grab('com.google.collections:google-‐collections:1.0')import com.google.common.collect.HashBiMap
HashBiMap fruit = [grape:'purple', lemon:'yellow', lime:'green']
assert fruit.lemon == 'yellow'assert fruit.inverse().yellow == 'lemon'
Groovy and Gpars both OSGi compliant
Groovy 1.8+
14
Plugin Tutorial: World of WarCraft...• http://confluence.atlassian.com/display/CONFDEV/
WoW+Macro+explanation
1515
• Normal instructions for gmaven:http://gmaven.codehaus.org/
16
...Plugin Tutorial: World of WarCraft...
... <plugin> <groupId>org.codehaus.gmaven</groupId> <artifactId>gmaven-‐plugin</artifactId> <version>1.2</version> <configuration>...</configuration> <executions>...</executions> <dependencies>...</dependencies> </plugin> ...
16
17
...Plugin Tutorial: World of WarCraft...package com.atlassian.confluence.plugins.wowplugin;
import java.io.Serializable;import java.util.Arrays;import java.util.List;
/*** Simple data holder for basic toon information*/public final class Toon implements Comparable, Serializable{ private static final String[] CLASSES = { "Warrior", "Paladin", "Hunter", "Rogue", "Priest", "Death Knight", "Shaman", "Mage", "Warlock", "Unknown", // There is no class with ID 10. Weird. "Druid" };
private final String name; private final String spec; private final int gearScore; private final List recommendedRaids; private final String className;
public Toon(String name, int classId, String spec, int gearScore, String... recommendedRaids) { this.className = toClassName(classId -‐ 1); this.name = name; this.spec = spec; this.gearScore = gearScore; this.recommendedRaids = Arrays.asList(recommendedRaids); }...
... public String getName() { return name; }
public String getSpec() { return spec; }
public int getGearScore() { return gearScore; }
public List getRecommendedRaids() { return recommendedRaids; }
public String getClassName() { return className; }
public int compareTo(Object o) { Toon otherToon = (Toon) o;
if (otherToon.gearScore -‐ gearScore != 0) return otherToon.gearScore -‐ gearScore;
return name.compareTo(otherToon.name); }
private String toClassName(int classIndex) { if (classIndex < 0 || classIndex >= CLASSES.length) return "Unknown: " + classIndex + 1; else return CLASSES[classIndex]; }}
17
18
...Plugin Tutorial: World of WarCraft...package com.atlassian.confluence.plugins.gwowplugin
class Toon implements Serializable { private static final String[] CLASSES = [ "Warrior", "Paladin", "Hunter", "Rogue", "Priest", "Death Knight", "Shaman", "Mage", "Warlock", "Unknown", "Druid"]
String name int classId String spec int gearScore def recommendedRaids
String getClassName() { classId in 0..<CLASSES.length ? CLASSES[classId -‐ 1] : "Unknown: " + classId }}
83 -> 17
18
19
...Plugin Tutorial: World of WarCraft...package com.atlassian.confluence.plugins.wowplugin;
import com.atlassian.cache.Cache;import com.atlassian.cache.CacheManager;import com.atlassian.confluence.util.http.HttpResponse;import com.atlassian.confluence.util.http.HttpRetrievalService;import com.atlassian.renderer.RenderContext;import com.atlassian.renderer.v2.RenderMode;import com.atlassian.renderer.v2.SubRenderer;import com.atlassian.renderer.v2.macro.BaseMacro;import com.atlassian.renderer.v2.macro.MacroException;import org.dom4j.Document;import org.dom4j.DocumentException;import org.dom4j.Element;import org.dom4j.io.SAXReader;
import java.io.IOException;import java.io.InputStream;import java.io.UnsupportedEncodingException;import java.net.URLEncoder;import java.util.*;
/** * Inserts a table of a guild's roster of 80s ranked by gear level, with recommended raid instances. The data for * the macro is grabbed from http://wow-heroes.com. Results are cached for $DEFAULT_CACHE_LIFETIME to reduce * load on the server. * <p/> * Usage: {guild-gear|realm=Nagrand|guild=A New Beginning|zone=us} * <p/> * Problems: * <p/> * * wow-heroes reports your main spec, but whatever gear you logged out in. So if you logged out in off-spec gear * your number will be wrong * * gear score != ability. l2play nub. */public class GuildGearMacro extends BaseMacro { private HttpRetrievalService httpRetrievalService; private SubRenderer subRenderer; private CacheManager cacheManager;
private static final String[] RAIDS = { "Heroics", "Naxxramas 10", // and OS10 "Naxxramas 25", // and OS25/EoE10 "Ulduar 10", // and EoE25 "Onyxia 10", "Ulduar 25", // and ToTCr10 "Onyxia 25", "Trial of the Crusader 25", "Icecrown Citadel 10" };
private static final String[] SHORT_RAIDS = { "H", "Naxx10/OS10", "Naxx25/OS25/EoE10", "Uld10/EoE25", "Ony10", "Uld25/TotCr10", "Ony25", "TotCr25", "IC" }; ...
... public boolean isInline() { return false; }
public boolean hasBody() { return false; }
public RenderMode getBodyRenderMode() { return RenderMode.NO_RENDER; }
public String execute(Map map, String s, RenderContext renderContext) throws MacroException { String guildName = (String) map.get("guild"); String realmName = (String) map.get("realm"); String zone = (String) map.get("zone"); if (zone == null) zone = "us";
StringBuilder out = new StringBuilder("||Name||Class||Gear Score"); for (int i = 0; i < SHORT_RAIDS.length; i++) { out.append("||").append(SHORT_RAIDS[i].replace('/', '\n')); } out.append("||\n");
List<Toon> toons = retrieveToons(guildName, realmName, zone);
for (Toon toon : toons) {
out.append("| "); try { String url = String.format("http://xml.wow-heroes.com/index.php?zone=%s&server=%s&name=%s", URLEncoder.encode(zone, "UTF-8"), URLEncoder.encode(realmName, "UTF-8"), URLEncoder.encode(toon.getName(), "UTF-8")); out.append("["); out.append(toon.getName()); out.append("|"); out.append(url); out.append("]"); } catch (UnsupportedEncodingException e) { out.append(toon.getName()); }
out.append(" | "); out.append(toon.getClassName()); out.append(" ("); out.append(toon.getSpec()); out.append(")"); out.append("|"); out.append(toon.getGearScore()); boolean found = false;
for (String raid : RAIDS) { if (toon.getRecommendedRaids().contains(raid)) { out.append("|(!)"); found = true; } else { out.append("|").append(found ? "(x)" : "(/)"); } } out.append("|\n"); }
return subRenderer.render(out.toString(), renderContext); }
private List<Toon> retrieveToons(String guildName, String realmName, String zone) throws MacroException { String url = null;...
... try { url = String.format("http://xml.wow-heroes.com/xml-guild.php?z=%s&r=%s&g=%s", URLEncoder.encode(zone, "UTF-8"), URLEncoder.encode(realmName, "UTF-8"), URLEncoder.encode(guildName, "UTF-8")); } catch (UnsupportedEncodingException e) { throw new MacroException(e.getMessage(), e); }
Cache cache = cacheManager.getCache(this.getClass().getName() + ".toons");
if (cache.get(url) != null) return (List<Toon>) cache.get(url);
try { List<Toon> toons = retrieveAndParseFromWowArmory(url); cache.put(url, toons); return toons; } catch (IOException e) { throw new MacroException("Unable to retrieve information for guild: " + guildName + ", " + e.toString()); } catch (DocumentException e) { throw new MacroException("Unable to parse information for guild: " + guildName + ", " + e.toString()); } }
private List<Toon> retrieveAndParseFromWowArmory(String url) throws IOException, DocumentException { List<Toon> toons = new ArrayList<Toon>(); HttpResponse response = httpRetrievalService.get(url);
InputStream responseStream = response.getResponse(); try { SAXReader reader = new SAXReader(); Document doc = reader.read(responseStream); List toonsXml = doc.selectNodes("//character"); for (Object o : toonsXml) { Element element = (Element) o; toons.add(new Toon(element.attributeValue("name"), Integer.parseInt(element.attributeValue("classId")), element.attributeValue("specName"), Integer.parseInt(element.attributeValue("score")), element.attributeValue("suggest").split(";"))); }
Collections.sort(toons); } finally { responseStream.close(); } return toons; }
public void setHttpRetrievalService(HttpRetrievalService httpRetrievalService) { this.httpRetrievalService = httpRetrievalService; }
public void setSubRenderer(SubRenderer subRenderer) { this.subRenderer = subRenderer; }
public void setCacheManager(CacheManager cacheManager) { this.cacheManager = cacheManager; }}
19
20
...Plugin Tutorial: World of WarCraft...package com.atlassian.confluence.plugins.gwowplugin
import com.atlassian.cache.CacheManagerimport com.atlassian.confluence.util.http.HttpRetrievalServiceimport com.atlassian.renderer.RenderContextimport com.atlassian.renderer.v2.RenderModeimport com.atlassian.renderer.v2.SubRendererimport com.atlassian.renderer.v2.macro.BaseMacroimport com.atlassian.renderer.v2.macro.MacroException
/** * Inserts a table of a guild's roster of 80s ranked by gear level, with recommended raid * instances. The data for the macro is grabbed from http://wow-‐heroes.com. Results are * cached for $DEFAULT_CACHE_LIFETIME to reduce load on the server. * <p/> * Usage: {guild-‐gear:realm=Nagrand|guild=A New Beginning|zone=us} */class GuildGearMacro extends BaseMacro { HttpRetrievalService httpRetrievalService SubRenderer subRenderer CacheManager cacheManager
private static final String[] RAIDS = [ "Heroics", "Naxxramas 10", "Naxxramas 25", "Ulduar 10", "Onyxia 10", "Ulduar 25", "Onyxia 25", "Trial of the Crusader 25", "Icecrown Citadel 10"] private static final String[] SHORT_RAIDS = [ "H", "Naxx10/OS10", "Naxx25/OS25/EoE10", "Uld10/EoE25", "Ony10", "Uld25/TotCr10", "Ony25", "TotCr25", "IC"]
boolean isInline() { false } boolean hasBody() { false } RenderMode getBodyRenderMode() { RenderMode.NO_RENDER }
String execute(Map map, String s, RenderContext renderContext) throws MacroException { def zone = map.zone ?: "us" def out = new StringBuilder("||Name||Class||Gear Score") SHORT_RAIDS.each { out.append("||").append(it.replace('/', '\n')) } out.append("||\n")
def toons = retrieveToons(map.guild, map.realm, zone)...
... toons.each { toon -‐> def url = "http://xml.wow-‐heroes.com/index.php?zone=${enc zone}&server=${enc map.realm}&name=${enc toon.name}" out.append("| [${toon.name}|${url}] | $toon.className ($toon.spec)| $toon.gearScore") boolean found = false RAIDS.each { raid -‐> if (raid in toon.recommendedRaids) { out.append("|(!)") found = true } else { out.append("|").append(found ? "(x)" : "(/)") } } out.append("|\n") } subRenderer.render(out.toString(), renderContext) }
private retrieveToons(String guildName, String realmName, String zone) throws MacroException { def url = "http://xml.wow-‐heroes.com/xml-‐guild.php?z=${enc zone}&r=${enc realmName}&g=${enc guildName}" def cache = cacheManager.getCache(this.class.name + ".toons") if (!cache.get(url)) cache.put(url, retrieveAndParseFromWowArmory(url)) return cache.get(url) }
private retrieveAndParseFromWowArmory(String url) { def toons httpRetrievalService.get(url).response.withReader { reader -‐> toons = new XmlSlurper().parse(reader).guild.character.collect { new Toon( name: it.@name, classId: [email protected](), spec: it.@specName, gearScore: [email protected](), recommendedRaids: [email protected]().split(";")) } } toons.sort{ a, b -‐> a.gearScore == b.gearScore ? a.name <=> b.name : a.gearScore <=> b.gearScore } }
def enc(s) { URLEncoder.encode(s, 'UTF-‐8') }}
200 -> 90
20
21
...Plugin Tutorial: World of WarCraft...{groovy-wow-item:1624} {groovy-guild-gear:realm=Kirin Tor|guild=Faceroll Syndicate|zone=us}
21
22
...Plugin Tutorial: World of WarCraft...> atlas-mvn clover2:setup test clover2:aggregate clover2:clover
22
•
• Testing with Spock • Or Cucumber, EasyB, JBehave,
23
...Plugin Tutorial: World of WarCraftpackage com.atlassian.confluence.plugins.gwowplugin
class ToonSpec extends spock.lang.Specification { def "successful name of Toon given classId"() {
given: def t = new Toon(classId: thisClassId)
expect: t.className == name
where: name | thisClassId "Hunter" | 3 "Rogue" | 4 "Priest" | 5
}}
narrative 'segment flown', { as_a 'frequent flyer' i_want 'to accrue rewards points for every segment I fly' so_that 'I can receive free flights for my dedication to the airline'}
scenario 'segment flown', { given 'a frequent flyer with a rewards balance of 1500 points' when 'that flyer completes a segment worth 500 points' then 'that flyer has a new rewards balance of 2000 points'}
scenario 'segment flown', { given 'a frequent flyer with a rewards balance of 1500 points', { flyer = new FrequentFlyer(1500) } when 'that flyer completes a segment worth 500 points', { flyer.fly(new Segment(500)) } then 'that flyer has a new rewards balance of 2000 points', { flyer.pointsBalance.shouldBe 2000 } }
23
24
Scripting on the fly...
24
25
...Scripting on the fly...
25
26
...Scripting on the fly
26
2727