All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.github.javafaker.service.FakeValuesService Maven / Gradle / Ivy

package com.github.javafaker.service;

import com.github.javafaker.Address;
import com.github.javafaker.Faker;
import com.github.javafaker.Name;
import com.mifmif.common.regex.Generex;
import org.apache.commons.lang3.ClassUtils;
import org.yaml.snakeyaml.Yaml;

import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.*;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class FakeValuesService {
    private static final Pattern EXPRESSION_PATTERN = Pattern.compile("#\\{([a-z0-9A-Z_.]+)\\s?(?:'([^']+)')?(?:,'([^']+)')*\\}");

    private final Logger log = Logger.getLogger("faker");
    
    private final List> fakeValuesMaps;
    
    private final RandomService randomService;

    /**
     * 

* Resolves YAML file using the most specific path first based on language and country code. * 'en_US' would resolve in the following order: *

    *
  1. /en-US.yml
  2. *
  3. /en.yml
  4. *
* The search is case-insensitive, so the following will all resolve correctly. Also, either a hyphen or * an underscore can be used when constructing a {@link Locale} instance. This is legacy behavior and not * condoned, but it will work. *
    *
  • EN_US
  • *
  • En-Us
  • *
  • eN_uS
  • *
*

* @param locale * @param randomService */ @SuppressWarnings({"unchecked", "rawtypes"}) public FakeValuesService(Locale locale, RandomService randomService) { if (locale == null) { throw new IllegalArgumentException("locale is required"); } this.randomService = randomService; locale = normalizeLocale(locale); final List locales = localeChain(locale); final List> all = new ArrayList(locales.size()); for (final Locale l : locales) { final StringBuilder filename = new StringBuilder(language(l)); if (!"".equals(l.getCountry())) { filename.append("-").append(l.getCountry()); } final InputStream stream = findStream(filename.toString()); if (stream != null) { all.add(fakerFromStream(stream, filename.toString())); } } if (all.size() == 1 && !locale.equals(Locale.ENGLISH)) { // if we have only successfully loaded ENGLISH and the requested locale // wasn't english that means we were unable to load the requested locale // in that case we vomit. // If someone requests FRANCE ("fr","FR") and we can't load fr_FR but we // load "fr", then that's ok. we picked up a variant. only if we ONLY pick up // the default do we throw that exception. throw new LocaleDoesNotExistException(locale.toString() + " does not exist"); } this.fakeValuesMaps = Collections.unmodifiableList(all); } /** * If you new up a locale with "he", it gets converted to "iw" which is old. * This addresses that unfortunate condition. */ private String language(Locale l) { if (l.getLanguage().equals("iw")) { return "he"; } return l.getLanguage(); } /** * @return the embedded faker: clause from the loaded Yml by the localeName, so .yml > en-us: > faker: */ protected Map fakerFromStream(InputStream stream, String localeName) { final Map valuesMap = new Yaml().loadAs(stream, Map.class); final Map localeBased = (Map) valuesMap.get(localeName); return (Map) localeBased.get("faker"); } /** * Convert the specified locale into a chain of locales used for message resolution. For example: * * {@link Locale#FRANCE} (fr_FR) -> [ fr_FR, fr, en ] * * @return a list of {@link Locale} instances */ protected List localeChain(Locale from) { if (Locale.ENGLISH.equals(from)) { return Collections.singletonList(Locale.ENGLISH); } final Locale normalized = normalizeLocale(from); final List chain = new ArrayList(3); chain.add(normalized); if (!"".equals(normalized.getCountry()) && !Locale.ENGLISH.getLanguage().equals(normalized.getLanguage())) { chain.add(new Locale(normalized.getLanguage())); } chain.add(Locale.ENGLISH); // default return chain; } /** * @return a proper {@link Locale} instance with language and country code set regardless of how * it was instantiated. new Locale("pt-br") will be normalized to a locale constructed * with new Locale("pt","BR"). */ private Locale normalizeLocale(Locale locale) { final String[] parts = locale.toString().split("[-\\_]"); if (parts.length == 1) { return new Locale(parts[0]); } else { return new Locale(parts[0],parts[1]); } } private InputStream findStream(String filename) { String filenameWithExtension = "/" + filename + ".yml"; InputStream streamOnClass = getClass().getResourceAsStream(filenameWithExtension); if (streamOnClass != null) { return streamOnClass; } return getClass().getClassLoader().getResourceAsStream(filenameWithExtension); } /** * Fetch a random value from an array item specified by the key * * @param key * @return */ public Object fetch(String key) { List valuesArray = (List) fetchObject(key); return valuesArray == null ? null : valuesArray.get(randomService.nextInt(valuesArray.size())); } /** * Same as {@link #fetch(String)} except this casts the result into a String. * * @param key * @return */ public String fetchString(String key) { return (String) fetch(key); } /** * Safely fetches a key. *

* If the value is null, it will return an empty string. *

* If it is a list, it will assume it is a list of strings and select a random value from it. *

* If the retrieved value is an slash encoded regular expression such as {@code /[a-b]/} then * the regex will be converted to a regexify expression and returned (ex. {@code #regexify '[a-b]'}) *

* Otherwise it will just return the value as a string. * * @param key the key to fetch from the YML structure. * @param defaultIfNull the value to return if the fetched value is null * @return see above */ @SuppressWarnings("unchecked") public String safeFetch(String key, String defaultIfNull) { Object o = fetchObject(key); if (o == null) return defaultIfNull; if (o instanceof List) { List values = (List) o; if (values.size() == 0) { return defaultIfNull; } return values.get(randomService.nextInt(values.size())); } else if (isSlashDelimitedRegex(o.toString())) { return String.format("#{regexify '%s'}", trimRegexSlashes(o.toString())); } else { return (String) o; } } /** * Return the object selected by the key from yaml file. * * @param key key contains path to an object. Path segment is separated by * dot. E.g. name.first_name * @return */ @SuppressWarnings("unchecked") public Object fetchObject(String key) { String[] path = key.split("\\."); Object result = null; for (Map fakeValuesMap : fakeValuesMaps) { Object currentValue = fakeValuesMap; for (int p = 0; currentValue != null && p < path.length; p++) { currentValue = ((Map) currentValue).get(path[p]); } result = currentValue; if (result != null) { break; } } return result; } /** * Returns a string with the '#' characters in the parameter replaced with random digits between 0-9 inclusive. *

* For example, the string "ABC##EFG" could be replaced with a string like "ABC99EFG". * * @param numberString * @return */ public String numerify(String numberString) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < numberString.length(); i++) { if (numberString.charAt(i) == '#') { sb.append(randomService.nextInt(10)); } else { sb.append(numberString.charAt(i)); } } return sb.toString(); } /** * Applies both a {@link #numerify(String)} and a {@link #letterify(String)} * over the incoming string. * * @param string * @return */ public String bothify(String string) { return letterify(numerify(string)); } /** * Applies both a {@link #numerify(String)} and a {@link #letterify(String,boolean)} * over the incoming string. * * @param string * @param isUpper * @return */ public String bothify(String string, boolean isUpper) { return letterify(numerify(string), isUpper); } /** * Generates a String that matches the given regular expression. */ public String regexify(String regex) { Generex generex = new Generex(regex); generex.setSeed(randomService.nextLong()); return generex.random(); } /** * Returns a string with the '?' characters in the parameter replaced with random alphabetic * characters. *

* For example, the string "12??34" could be replaced with a string like "12AB34". * * @param letterString * @return */ public String letterify(String letterString) { return this.letterify(letterString, false); } /** * Returns a string with the '?' characters in the parameter replaced with random alphabetic * characters. *

* For example, the string "12??34" could be replaced with a string like "12AB34". * * @param letterString * @param isUpper specifies whether or not letters should be upper case * @return */ public String letterify(String letterString, boolean isUpper) { return letterHelper((isUpper) ? 65 : 97, letterString); // from ascii table } private String letterHelper(int baseChar, String letterString) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < letterString.length(); i++) { if (letterString.charAt(i) == '?') { sb.append((char) (baseChar + randomService.nextInt(26))); // a-z } else { sb.append(letterString.charAt(i)); } } return sb.toString(); } /** * Resolves a key to a method on an object. * * #{hello} with result in a method call to current.hello(); * * #{Person.hello_someone} will result in a method call to person.helloSomeone(); * */ public String resolve(String key, Object current, Faker root) { final String expression = safeFetch(key, null); if (expression == null) { throw new RuntimeException(key + " resulted in null expression"); } return resolveExpression(expression, current, root); } /** * resolves an expression using the current faker. * @param expression * @param faker * @return */ public String expression(String expression, Faker faker) { return resolveExpression(expression, null, faker); } /** *

processes a expression in the style #{X.y} using the current objects as the 'current' location * within the yml file (or the {@link Faker} object hierarchy as it were). *

*

* #{Address.streetName} would get resolved to {@link Faker#address()}'s {@link Address#streetName()} *

*

* #{address.street} would get resolved to the YAML > locale: faker: address: street: *

*

* Combinations are supported as well: "#{x} #{y}" *

*

* Recursive templates are supported. if "#{x}" resolves to "#{Address.streetName}" then "#{x}" resolves to * {@link Faker#address()}'s {@link Address#streetName()}. *

*/ protected String resolveExpression(String expression, Object current, Faker root) { final Matcher matcher = EXPRESSION_PATTERN.matcher(expression); String result = expression; while (matcher.find()) { final String escapedDirective = matcher.group(0); final String directive = matcher.group(1); List args = new ArrayList(); for (int i=2;i < matcher.groupCount()+1 && matcher.group(i) != null;i++) { args.add(matcher.group(i)); } // resolve the expression and reprocess it to handle recursive templates String resolved = resolveExpression(directive, args, current, root); if (resolved == null) { throw new RuntimeException("Unable to resolve " + escapedDirective + " directive."); } resolved = resolveExpression(resolved, current, root); result = result.replace(escapedDirective, resolved); } return result; } /** *

Search Order

*
    *
  • Search for methods on the current object
  • *
  • local keys in Yaml File
  • *
  • Search for methods on faker child objects
  • *
  • Search for keys in yaml file by transforming object reference to yaml reference
  • *
* @return null if unable to resolve */ private String resolveExpression(String directive, List args, Object current, Faker root) { // name.name (resolve locally) // Name.first_name (resolve to faker.name().firstName()) final String simpleDirective = (isDotDirective(directive) || current == null) ? directive : classNameToYamlName(current) + "." + directive; String resolved = null; // resolve method references on CURRENT object like #{number_between '1','10'} on Number or // #{ssn_valid} on IdNumber if (!isDotDirective(directive)) { resolved = resolveFromMethodOn(current, directive, args); } // simple fetch of a value from the yaml file. the directive may have been mutated // such that if the current yml object is car: and directive is #{wheel} then // car.wheel will be looked up in the YAML file. if (resolved == null) { resolved = safeFetch(simpleDirective, null); } // resolve method references on faker object like #{regexify '[a-z]'} if (resolved == null && !isDotDirective(directive)) { resolved = resolveFromMethodOn(root, directive, args); } // Resolve Faker Object method references like #{ClassName.method_name} if (resolved == null && isDotDirective(directive)) { resolved = resolveFakerObjectAndMethod(root, directive, args); } // last ditch effort. Due to Ruby's dynamic nature, something like 'Address.street_title' will resolve // because 'street_title' is a dynamic method on the Address object. We can't do this in Java so we go // thru the normal resolution above, but if we will can't resolve it, we once again do a 'safeFetch' as we // did first but FIRST we change the Object reference Class.method_name with a yml style internal refernce -> // class.method_name (lowercase) if (resolved == null && isDotDirective(directive)) { resolved = safeFetch(javaNameToYamlName(simpleDirective), null); } return resolved; } /** * @param expression input expression * @return true if s is non null and is a slash delimited regex (ex. {@code /[ab]/}) */ private boolean isSlashDelimitedRegex(String expression) { return expression != null && expression.startsWith("/") && expression.endsWith("/"); } /** * Given a {@code slashDelimitedRegex} such as {@code /[ab]/}, removes the slashes and returns only {@code [ab]} * @param slashDelimitedRegex a non null slash delimited regex (ex. {@code /[ab]/}) * @return the regex without the slashes (ex. {@code [ab]}) */ private String trimRegexSlashes(String slashDelimitedRegex) { return slashDelimitedRegex.substring(1, slashDelimitedRegex.length() - 1); } private boolean isDotDirective(String directive) { return directive.contains("."); } /** * @return a yaml style name from the classname of the supplied object (PhoneNumber => phone_number) */ private String classNameToYamlName(Object current) { return javaNameToYamlName(current.getClass().getSimpleName()); } /** * @return a yaml style name like 'phone_number' from a java style name like 'PhoneNumber' */ private String javaNameToYamlName(String expression) { return expression.replaceAll("([A-Z])", "_$1") .substring(1) .toLowerCase(); } /** * Given a directive like 'firstName', attempts to resolve it to a method. For example if obj is an instance of * {@link Name} then this method would return {@link Name#firstName()}. Returns null if the directive is nested * (i.e. has a '.') or the method doesn't exist on the obj object. */ private String resolveFromMethodOn(Object obj, String directive, List args) { if (obj == null) { return null; } try { final MethodAndCoercedArgs accessor = accessor(obj, directive, args); return (accessor == null) ? null : string(accessor.invoke(obj)); } catch (Exception e) { log.log(Level.FINE, "Can't call " + directive + " on " + obj, e); return null; } } /** * Accepts a {@link Faker} instance and a name.firstName style 'key' which is resolved to the return value of: * {@link Faker#name()}'s {@link Name#firstName()} method. * @throws RuntimeException if there's a problem invoking the method or it doesn't exist. */ private String resolveFakerObjectAndMethod(Faker faker, String key, List args) { final String[] classAndMethod = key.split("\\.", 2); try { String fakerMethodName = classAndMethod[0].replaceAll("_", ""); MethodAndCoercedArgs fakerAccessor = accessor(faker, fakerMethodName, Collections.emptyList()); if (fakerAccessor == null) { log.fine("Can't find top level faker object named " + fakerMethodName + "."); return null; } Object objectWithMethodToInvoke = fakerAccessor.invoke(faker); String nestedMethodName = classAndMethod[1].replaceAll("_", ""); final MethodAndCoercedArgs accessor = accessor(objectWithMethodToInvoke, classAndMethod[1].replaceAll("_", ""), args); if (accessor == null) { throw new Exception("Can't find method on " + objectWithMethodToInvoke.getClass().getSimpleName() + " called " + nestedMethodName + "."); } return string(accessor.invoke(objectWithMethodToInvoke)); } catch (Exception e) { log.fine(e.getMessage()); return null; } } /** * Find an accessor by name ignoring case. */ private MethodAndCoercedArgs accessor(Object onObject, String name, List args) { log.log(Level.FINE, "Find accessor named " + name + " on " + onObject.getClass().getSimpleName() + " with args " + args); for (Method m : onObject.getClass().getMethods()) { if (m.getName().equalsIgnoreCase(name) && m.getParameterTypes().length == args.size()) { final List coercedArguments = coerceArguments(m, args); if (coercedArguments != null) { return new MethodAndCoercedArgs(m, coercedArguments); } } } if (name.contains("_")) { return accessor(onObject, name.replaceAll("_", ""), args); } return null; } /** * Coerce arguments in args into the appropriate types (if possible) for the parameter arguments * to accessor. * @return array of coerced values if successful, null otherwise * @throws Exception if unable to coerce */ private List coerceArguments(Method accessor, List args) { final List coerced = new ArrayList(); for (int i = 0; i < accessor.getParameterTypes().length; i++) { Class toType = ClassUtils.primitiveToWrapper(accessor.getParameterTypes()[i]); try { final Constructor ctor = toType.getConstructor(String.class); final Object coercedArgument = ctor.newInstance(args.get(i)); coerced.add(coercedArgument); } catch (Exception e) { log.fine("Unable to coerce " + args.get(i) + " to " + toType.getSimpleName() + " via " + toType.getSimpleName() + "(String) constructor."); return null; } } return coerced; } private String string(Object obj) { return (obj == null) ? null : obj.toString(); } /** * simple wrapper class around an accessor and a list of coerced arguments. * this is useful as we get to find the method and coerce the arguments in one * shot, returning both when successful. This saves us from doing it more than once (coercing args). */ private class MethodAndCoercedArgs { private final Method method; private final List coerced; private MethodAndCoercedArgs(Method m, List coerced) { this.method = requireNonNull(m, "method cannot be null"); this.coerced = requireNonNull(coerced, "coerced arguments cannot be null"); } private Object invoke(Object on) throws InvocationTargetException, IllegalAccessException { return method.invoke(on, coerced.toArray()); } /** * source level precludes me from using Objects.requireNonNull */ private T requireNonNull(T instance, String messageIfNull) { if (instance == null) { throw new NullPointerException(messageIfNull); } return instance; } } }