import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Charsets;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.io.Files;
import com.google.common.io.Resources;
import com.google.common.reflect.ClassPath;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.Charset;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
*
Utility class to generate code from templates using configuration specified in scaffolding.json
* Use this to rapidly create all the supporting code specific to your project for a particular purpose.
* Examples of use are:
*
* - Entities and their supporting structures (DAOs, repositories, services, resources, unit tests, models and so on
* - Rapid generation of common design patterns
*
* There is no mandated scaffold template - you can use anything you like. It just has to have placeholders defined using
* the Handlebars notation with no spaces (e.g. {{package}}
) listed below:
*
* package
: Base package, e.g. org.example
* entity-class
: Entity class, e.g. AdminUser
* entity-variable
: Entity variable, e.g. adminUser
* entity-snake
: Entity variable as snake case, e.g. admin_user
* entity-snake-upper
: Entity variable as uppercase snake case, e.g. ADMIN_USER
* entity-comment
: Entity variable as a comment, e.g. admin user
* entity-hyphen
: Entity variable in hyphenated form, e.g. admin-user
*
* As of version 1.6.0+ you can add your own token map containing key-value pairs. This allows external applications to configure
* Scaffolding to perform token replacement for almost complete customisation of the output. These will be overwritten by the standard
* placeholders above (case-sensitive) so the recommendation is to use all capitals and underscores.
* How to install
* Just copy this source code into your project under src/test/java
. You might want to copy in scaffolding.json
* as well.
* Quickly generate templates from existing code
* Scaffolding
is specific to your application so you can read existing examples and turn them into
* templates. After a bit of editing they will be suitable for use in your application (and others based on it). To get Scaffolding
* to read your existing code you need to provide a scaffolding.js
configuration like this:
*
* {
* "profile": "example-microservice",
* "input_directory": "source/example-project",
* "output_directory": "target/generated-service",
* "template_location": "src/test/resources/scaffolding",
* "base_package": "org.example.service",
* "read": true,
* "only_with_entity_directives": false,
* "entities": ["MyEntity"],
* "user_token_map": {"PORT": "1000"}
* }
*
*
* All code from input_directory
base_package
and below will be recursively examined and templates built. These will be
* stored in a directory structure under src/test/resources/scaffolding
(from template_location
).
* You can then delete any that are not useful and edit those that remain to meet your requirements. Usually there will
* be little to no editing required.
* Generate code from templates
* Once you have your templates in place, you can use them to generate new code. This is again driven by the scaffolding.json
* files. You switch away from read
and provide a list of new entities that you would like created:
*
* {
* "profile": "example-microservice",
* "input_directory": "source/example-project",
* "output_directory": "target/generated-service",
* "template_location": "classpath:/scaffolding",
* "base_package": "org.example.service",
* "read": false,
* "only_with_entity_directives": false,
* "entities": ["Role", "DataSource"],
* "user_token_map": {"PORT": "1000"}
* }
*
* Using the above, the generic templates built from the MyEntity
will be used to produce the
* equivalent forRole
and DataSource
. If you have been careful with what is included in
* User
then the produced code will act as good launch point for the new entities.
*
* Note that template_location
has been adjusted to show support for reading templates off a classpath
* location. Also the PORT
entry replaces the first 4 digits in the actual port which allows a group of ports
* to be specified such as HTTP on 10000 and Admin HTTP on 10001 and so on.
*
* @author Gary Rowe (http://gary-rowe.com)
* @since 1.9.0
*/
public class Scaffolding {
// File filters
private static final String[] IGNORE_FILE_REGEXES = new String[]{
"scaffolding.json$",
".*\\.classpath$",
".*\\.project$",
".*\\.wtpmodules$",
".*\\.iml$",
".*\\.iws$",
".*nb.*\\.xml$",
".*\\.DS_Store$",
".*\\.xdoclet$"
};
private static final Pattern[] ignoreFilePatterns = new Pattern[IGNORE_FILE_REGEXES.length];
// Directives
private static final String BASE_PACKAGE_DIRECTIVE = "{{package}}";
private static final String BASE_PACKAGE_PATH_DIRECTIVE = "{{package-path}}";
private static final String ENTITY_CLASS_DIRECTIVE = "{{entity-class}}";
private static final String ENTITY_TITLE_DIRECTIVE = "{{entity-title}}";
private static final String ENTITY_VARIABLE_DIRECTIVE = "{{entity-variable}}";
private static final String ENTITY_HYPHEN_DIRECTIVE = "{{entity-hyphen}}";
private static final String ENTITY_COMMENT_DIRECTIVE = "{{entity-comment}}";
private static final String ENTITY_SNAKE_DIRECTIVE = "{{entity-snake}}";
private static final String ENTITY_SNAKE_UPPER_DIRECTIVE = "{{entity-snake-upper}}";
private static final String DIRECTIVE_REGEX = "\\{\\{entity.\\S+\\}\\}";
private static final Pattern ENTITY_DIRECTIVE_PATTERN = Pattern.compile(DIRECTIVE_REGEX);
static {
// Pre-compile the "ignore file" patterns
for (int i = 0; i < IGNORE_FILE_REGEXES.length; i++) {
ignoreFilePatterns[i] = Pattern.compile(IGNORE_FILE_REGEXES[i]);
}
}
private final ScaffoldingConfiguration sc;
/**
* @param sc The scaffolding configuration
*/
public Scaffolding(ScaffoldingConfiguration sc) {
this.sc = sc;
}
/**
* Main entry point to the scaffolding operations if running from an IDE
*
* @param args [0]: path to scaffolding.json
*
* @throws java.io.IOException If something goes wrong
*/
public static void main(String[] args) throws IOException {
if (args == null || args.length != 1) {
args = new String[]{"scaffolding.json"};
}
InputStream is = new FileInputStream(args[0]);
ObjectMapper mapper = new ObjectMapper();
ScaffoldingConfiguration sc = mapper.readValue(is, ScaffoldingConfiguration.class);
new Scaffolding(sc).run();
}
/**
* Executes the process
*
* @throws java.io.IOException If something goes wrong
*/
public void run() throws IOException {
Preconditions.checkNotNull(sc);
if (sc.isRead()) {
handleRead();
} else {
handleWrite();
}
}
/**
* Converts camel case to snake case as follows:
*
* This
: this
* ThisIs
: this_is
* thisIsATest
: this_is_a_test
* this1Is12A123Test1234
: this_1_is_12_a_123_test_1234
*
* Adapted from Stack Overflow answer
*
* @param camelCase The camel case (with arbitrary initial capitalisation)
*
* @return A snake case version in lowercase
*/
public static String toSnakeCase(String camelCase) {
return camelCase.replaceAll(
String.format("%s|%s|%s",
"(?<=[A-Z])(?=[A-Z][a-z])",
"(?<=[^A-Z])(?=[A-Z])",
"(?<=[A-Za-z])(?=[^A-Za-z])"
),
"_"
).toLowerCase();
}
/**
* Converts camel case to document lowercase as follows:
*
* This
: this
* ThisIs
: this is
* thisIsATest
: this is a test
* this1Is12A123Test1234
: this 1 is 12 a 123 test 1234
*
* Adapted from Stack Overflow answer
*
* @param camelCase The camel case (with arbitrary initial capitalisation)
*
* @return A document version in lowercase (useful for describing entities in Javadocs)
*/
public static String toComment(String camelCase) {
return camelCase.replaceAll(
String.format("%s|%s|%s",
"(?<=[A-Z])(?=[A-Z][a-z])",
"(?<=[^A-Z])(?=[A-Z])",
"(?<=[A-Za-z])(?=[^A-Za-z])"
),
" "
).toLowerCase();
}
/**
* Converts camel case to hyphenated lowercase as follows:
*
* This
: this
* ThisIs
: this-is
* thisIsATest
: this-is-a-test
* this1Is12A123Test1234
: this-1-is-12-a-123-test-1234
*
* Adapted from Stack Overflow answer
*
* @param camelCase The camel case (with arbitrary initial capitalisation)
*
* @return A hyphen version in lowercase (useful for describing entities in RESTful endpoints)
*/
public static String toHyphen(String camelCase) {
return camelCase.replaceAll(
String.format("%s|%s|%s",
"(?<=[A-Z])(?=[A-Z][a-z])",
"(?<=[^A-Z])(?=[A-Z])",
"(?<=[A-Za-z])(?=[^A-Za-z])"
),
"-"
).toLowerCase();
}
/**
* Converts camel case to title case with spaces as follows:
*
* This
: This
* ThisIs
: This is
* thisIsATest
: This Is A Test
* this1Is12A123Test1234
: This 1 Is 12 A 123 Test 1234
*
* Adapted from Stack Overflow answer
*
* @param camelCase The camel case (with arbitrary initial capitalisation)
*
* @return A document version in title case (useful for describing entities in titles)
*/
public static String toTitle(String camelCase) {
String spaced = camelCase.replaceAll(
String.format("%s|%s|%s",
"(?<=[A-Z])(?=[A-Z][a-z])",
"(?<=[^A-Z])(?=[A-Z])",
"(?<=[A-Za-z])(?=[^A-Za-z])"
),
" "
);
return spaced.substring(0, 1).toUpperCase() + spaced.substring(1);
}
/**
* Execute in write mode
*
* @throws IOException If something goes wrong
*/
private void handleWrite() throws IOException {
System.out.println("Writing new code from templates");
// Build URIs for all templates within the project
Set projectUris = Sets.newHashSet();
if (sc.getTemplateLocation().startsWith("classpath:")) {
// Use classpath filtering
filterClasspath(projectUris);
} else {
recurseFiles(sc.getTemplateLocation() + "/"+ sc.getProfilePath(), projectUris);
}
sc.setProjectUris(projectUris);
System.out.println("Extracted templates: " + projectUris.size());
writeTemplates(sc);
}
/**
* Execute in read mode
*
* @throws IOException If something goes wrong
*/
private void handleRead() throws IOException {
System.out.println("Reading existing project and extracting templates");
// Build URIs for all files within the project
Set projectUris = Sets.newHashSet();
recurseFiles(sc.getInputDirectory(), projectUris);
sc.setProjectUris(projectUris);
// Add root level files
File[] files = new File(sc.getInputDirectory()).listFiles();
if (files != null) {
for (File file : files) {
if (file.isDirectory()) {
continue;
}
String fileName = file.getName();
// Handle common exclusions
boolean ignore = false;
for (Pattern pattern : ignoreFilePatterns) {
if (pattern.matcher(fileName).matches()) {
// It's on the ignore list
ignore = true;
}
}
if (!ignore) {
projectUris.add(file.toURI());
} else {
System.err.println("Ignoring '" + fileName + "'");
}
}
}
readTemplates(sc);
}
/**
* Scans the existing project and builds templates from the code
*
* @param sc The configuration
*
* @throws java.io.IOException If something goes wrong
*/
private void readTemplates(ScaffoldingConfiguration sc) throws IOException {
Set entities = sc.getEntities();
String basePackage = sc.getBasePackage();
// Form a set of all classes in the classpath starting at the base package
String workDir = (new File(sc.getInputDirectory())).toURI().toString();
for (URI uri : sc.getProjectUris()) {
// Avoid URL encoding when writing templates
String projectPath = URLDecoder.decode(uri.toString(), "UTF-8").replace(workDir, "");
// Read the source file
String sourceCode = Resources.toString(uri.toURL(), Charset.defaultCharset());
// Multiple entities may lead to overlapping templates
// but some project structures can cater for this
for (String entity : entities) {
// Work out the template target
String templateTarget = sc.getTemplateLocation() + "/" + sc.getProfilePath() + projectPath + ".hbs";
// Introduce the base package directive
String content = sourceCode.replace(basePackage, BASE_PACKAGE_DIRECTIVE);
// Build the patterns to recognise the entities
String entityVariable = entity.substring(0, 1).toLowerCase() + entity.substring(1); // Java case
String entityTitle = toTitle(entity);
String entitySnake = toSnakeCase(entity);
String entityComment = toComment(entity);
String entityHyphen = toHyphen(entity);
// Check for entity content
content = content
.replace(entity, ENTITY_CLASS_DIRECTIVE)
// Detect title case (e.g. in README)
.replace(entityTitle, ENTITY_TITLE_DIRECTIVE)
// Detect variable case (e.g. in methods)
.replace(entityVariable, ENTITY_VARIABLE_DIRECTIVE)
// Detect comment (e.g. in Javadocs)
.replace(entityComment, ENTITY_COMMENT_DIRECTIVE)
// Detect ADMIN_USER
.replace(entitySnake.toUpperCase(), ENTITY_SNAKE_UPPER_DIRECTIVE)
// Detect admin_user and "admin_user"
.replace(entitySnake, ENTITY_SNAKE_DIRECTIVE)
// Detect admin-user
.replace(entityHyphen, ENTITY_HYPHEN_DIRECTIVE)
;
// Check for user template entries
for (Map.Entry entry : sc.getUserTokenMap().entrySet()) {
// Find the value and replace with the key as a directive (e.g. "{{PORT}}")
content = content.replace(entry.getValue(), "{{"+entry.getKey()+"}}");
}
templateTarget = templateTarget
.replace(entity, ENTITY_CLASS_DIRECTIVE)
.replace(entityTitle, ENTITY_TITLE_DIRECTIVE)
.replace(entityVariable, ENTITY_VARIABLE_DIRECTIVE)
.replace(entitySnake.toUpperCase(), ENTITY_SNAKE_UPPER_DIRECTIVE)
.replace(entitySnake, ENTITY_SNAKE_DIRECTIVE)
.replace(entityHyphen, ENTITY_HYPHEN_DIRECTIVE)
;
// Check if path or content must contain directives
if (sc.isOnlyWithEntityDirectives() && !containsEntityDirectives(projectPath, content)) {
// Ignore
System.err.println("Ignoring '" + projectPath + "' due to directive restriction.");
continue;
}
// Write the content
writeResult(content, templateTarget);
}
}
}
/**
* @param path The path
* @param content The content
*
* @return True if either the path or content contain entity directives
*/
private boolean containsEntityDirectives(String path, String content) {
return ENTITY_DIRECTIVE_PATTERN.matcher(path).find() || ENTITY_DIRECTIVE_PATTERN.matcher(content).find();
}
/**
* Recursive method
*
* @param directory The starting directory
* @param projectUris The set of URIs for all the templates
*
* @throws java.io.IOException If something goes wrong
*/
private void recurseFiles(String directory, Set projectUris) throws IOException {
File[] files = new File(directory).listFiles();
if (files != null) {
for (File file : files) {
if (file.isDirectory()) {
recurseFiles(file.getAbsolutePath(), projectUris);
continue;
}
if (file.getName().matches("^(.*?)")) {
projectUris.add(file.toURI());
}
}
}
}
/**
* @param projectUris The set of URIs for all the templates
*
* @throws java.io.IOException If something goes wrong
*/
private void filterClasspath(Set projectUris) throws IOException {
ClassPath classPath = ClassPath.from(Scaffolding.class.getClassLoader());
ImmutableSet resources = classPath.getResources();
String profilePath = sc.getProfilePath();
for (ClassPath.ResourceInfo resourceInfo : resources) {
// Fully qualified resource name
String resourceName = resourceInfo.getResourceName();
if (resourceName.endsWith(".hbs") && resourceName.contains(profilePath)) {
projectUris.add(URI.create(resourceInfo.url().toString()));
}
}
}
/**
* @return A Map keyed on the target name (relative to working directory) and containing the template with directives
*
* @throws java.io.IOException If something goes wrong
*/
private Map buildTemplateMap(ScaffoldingConfiguration sc) throws IOException {
// Provide a map for target and template
Map templateMap = Maps.newHashMap();
// Work out the URI path prefix which can be stripped to
// make the project path relative
String pathPrefix;
if (sc.getTemplateLocation().startsWith("classpath:")) {
// Templates are from classpath
String rawUri = sc.getProjectUris().iterator().next().toString();
pathPrefix = rawUri.substring(0, rawUri.indexOf(".jar!")+5);
} else {
// Templates are from file system
// Current working directory
String workDir = (new File("")).toURI().getPath();
pathPrefix = workDir + sc.getTemplateLocation() + "/" + sc.getProfilePath();
}
// Work through all project URIs
for (URI uri : sc.getProjectUris()) {
// Determine the project path
String rawUri = uri.toString();
// Filter out any non-scaffolding resources (the project URI gathering process should have done this)
if (rawUri.endsWith(".hbs")) {
// Target is the relative path to the current working directory
String target = URLDecoder.decode(rawUri, Charsets.UTF_8.name()).replace(pathPrefix, "");
if (target.startsWith("file:")) {
target=target.substring(5);
}
String template = Resources.toString(uri.toURL(), Charsets.UTF_8);
templateMap.put(target, template);
}
}
return templateMap;
}
/**
* Handles the process of writing out the templates
*
* @param sc The configuration
*
* @throws java.io.IOException If something goes wrong
*/
private void writeTemplates(ScaffoldingConfiguration sc) throws IOException {
Set entities = sc.getEntities();
String basePackage = sc.getBasePackage();
String outputDirectory = sc.getOutputDirectory();
// Read all the templates
Map templateMap = buildTemplateMap(sc);
// Work through the entities applying the templates
for (String entity : entities) {
String entityVariable = entity.substring(0, 1).toLowerCase() + entity.substring(1);
String entityTitle = toTitle(entity);
String entitySnake = toSnakeCase(entity);
String entityHyphen = toHyphen(entity);
String entityComment = toComment(entity);
// Create directive map for replacements
Map directiveMap = Maps.newHashMap();
// Add user tokens as directives (will be overwritten by standard ones)
for (Map.Entry entry : sc.getUserTokenMap().entrySet()) {
directiveMap.put("{{"+entry.getKey()+"}}", entry.getValue());
}
// Add standard directives
directiveMap.put(BASE_PACKAGE_DIRECTIVE, basePackage);
directiveMap.put(BASE_PACKAGE_PATH_DIRECTIVE, sc.getBasePath());
directiveMap.put(ENTITY_CLASS_DIRECTIVE, entity);
directiveMap.put(ENTITY_TITLE_DIRECTIVE, entityTitle);
directiveMap.put(ENTITY_VARIABLE_DIRECTIVE, entityVariable);
directiveMap.put(ENTITY_HYPHEN_DIRECTIVE, entityHyphen);
directiveMap.put(ENTITY_COMMENT_DIRECTIVE, entityComment);
directiveMap.put(ENTITY_SNAKE_UPPER_DIRECTIVE, entitySnake.toUpperCase());
directiveMap.put(ENTITY_SNAKE_DIRECTIVE, entitySnake);
for (Map.Entry templateEntry : templateMap.entrySet()) {
String content = templateEntry.getValue();
// Transform the target
String target = templateEntry.getKey();
// Strip off the .hbs
target = target.substring(0, target.length() - 4);
// Check if path or content must contain directives
if (sc.isOnlyWithEntityDirectives() && !containsEntityDirectives(target, content)) {
// Ignore
System.err.println("Ignoring '" + target + "' due to directive restriction.");
continue;
}
// Cache the profile path
String profilePath = sc.getProfilePath();
System.out.println("profilePath:" + profilePath);
// Transform target and content using directives
for (Map.Entry directiveEntry : directiveMap.entrySet()) {
target = target.replace(directiveEntry.getKey(), directiveEntry.getValue());
if (target.contains(profilePath)) {
// Strip off unwanted paths (possibly running in an IDE)
target = target.substring(target.indexOf(profilePath) + profilePath.length());
}
content = content.replace(directiveEntry.getKey(), directiveEntry.getValue());
}
// Write out the result
writeResult(content, outputDirectory + "/" + target);
}
}
}
/**
* @param content The content
* @param fileName The file name
*
* @throws java.io.IOException If something goes wrong
*/
private void writeResult(String content, String fileName) throws IOException {
File file = new File(fileName);
if (file.exists()) {
System.err.println("Skipping '" + fileName + "' to prevent an overwrite.");
return;
}
System.out.println("Writing: '" + fileName + "'");
Files.createParentDirs(file);
// Have a good target
OutputStreamWriter writer = new OutputStreamWriter(
new FileOutputStream(file),
Charsets.UTF_8
);
writer.write(content);
writer.close();
}
/**
* Configuration to use when reading or writing the templates.
*/
public static class ScaffoldingConfiguration {
@JsonProperty("profile")
private String profile = "";
@JsonProperty("input_directory")
private String inputDirectory = ".";
@JsonProperty("output_directory")
private String outputDirectory = ".";
@JsonProperty("template_location")
private String templateLocation = "src/test/resources";
@JsonProperty("base_package")
private String basePackage = "org.example";
@JsonProperty
private boolean read = false;
@JsonProperty("only_with_entity_directives")
private boolean onlyWithEntityDirectives = false;
@JsonProperty
private Set entities = Sets.newHashSet();
@JsonIgnore
private Set projectUris = Sets.newHashSet();
@JsonProperty("user_token_map")
private Map userTokenMap = Maps.newHashMap();
public ScaffoldingConfiguration() {
}
/**
* A profile name is appended as "src/test/resources/scaffolding"+profile to manage
* collections of scaffolding templates targeting different variations
*
* For example, a profile of "dw-0.6.1-mongodb" could be used to indicate that these
* template would create a Dropwizard v0.6.1 microservice containing suitable data access code
* for MongoDB
*
* @return The name of the profile (e.g. "dw-0.6.1-mongodb")
*/
public String getProfile() {
return profile;
}
public void setProfile(String profile) {
this.profile = profile;
}
/**
* @return The name of the profile with path separator appended (if not empty)
*/
public String getProfilePath() {
if (profile == null || profile.length() == 0) {
return "";
}
if (!profile.endsWith("/")) {
return profile + "/";
}
return profile;
}
/**
* @return The input directory when reading (e.g. ".", "/temp/source/example-project")
*/
public String getInputDirectory() {
return inputDirectory;
}
public void setInputDirectory(String inputDirectory) {
this.inputDirectory = inputDirectory;
}
/**
* @return The output directory when writing (e.g. ".", "target/generated-sources")
*/
public String getOutputDirectory() {
return outputDirectory;
}
public void setOutputDirectory(String outputDirectory) {
this.outputDirectory = outputDirectory;
}
/**
* The input path allows a variety of operating modes. The default is to read a standard Maven test resources
* directory structure recursively. However, prefixing "classpath:" will cause Scaffolding to read from its
* classpath instead.
*
* Any selected profile will be appended to this path
*
* @return The template location when reading (e.g. "src/test/resources/scaffolding", "classpath:/templates/scaffolding")
*/
public String getTemplateLocation() {
return templateLocation;
}
public void setTemplateLocation(String templateLocation) {
this.templateLocation = templateLocation;
}
/**
* @return The base package to read from/write to
*/
public String getBasePackage() {
return basePackage;
}
public void setBasePackage(String basePackage) {
this.basePackage = basePackage;
}
/**
* @return True if Scaffolding should scan the project looking for templates
*/
public boolean isRead() {
return read;
}
public void setRead(boolean read) {
this.read = read;
}
/**
* @return True if Scaffolding should only store/use templates that contain entity directives in path or content
*/
public boolean isOnlyWithEntityDirectives() {
return onlyWithEntityDirectives;
}
public void setOnlyWithEntityDirectives(boolean onlyWithEntityDirectives) {
this.onlyWithEntityDirectives = onlyWithEntityDirectives;
}
public Set getEntities() {
return entities;
}
public void setEntities(Set entities) {
this.entities = entities;
}
/**
* @return The base package in path format
*/
@JsonIgnore
public String getBasePath() {
return basePackage.replace(".", "/");
}
public void setProjectUris(Set projectUris) {
this.projectUris = projectUris;
}
/**
* @return The URIs for all discovered project content
*/
public Set getProjectUris() {
return projectUris;
}
public void setUserTokenMap(Map userTokenMap) {
this.userTokenMap = userTokenMap;
}
/**
* @return The user's token map with key-value pairs for additional replacement
*/
public Map getUserTokenMap() {
return userTokenMap;
}
}
}