diff --git a/gradle/jsonModelsGeneration.gradle b/gradle/jsonModelsGeneration.gradle new file mode 100644 index 0000000..20fb624 --- /dev/null +++ b/gradle/jsonModelsGeneration.gradle @@ -0,0 +1,932 @@ +/* + * Copyright (c) 2017 Touch Instinct + * + * This file is part of RoboSwag library. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +apply plugin: 'com.android.application' + +buildscript { + repositories { + jcenter() + } + dependencies { + classpath 'org.yaml:snakeyaml:1.8' + classpath 'com.squareup:javapoet:1.8.0' + } +} + +import com.squareup.javapoet.* +import javafx.util.Pair +import org.yaml.snakeyaml.Yaml + +import javax.lang.model.element.Modifier + + +//TODO: missable in future +//TODO: NUMBER/BOOLEAN enums in future +//TODO: maybe save md5-hashes to check if files/scheme changed + +class Types { + + static final TypeName LOGAN_SQUARE_ENUM = ClassName.bestGuess("ru.touchin.templates.logansquare.LoganSquareEnum") + static final TypeName LOGAN_SQUARE_ENUM_CONVERTER = ClassName.bestGuess("ru.touchin.templates.logansquare.LoganSquareEnumConverter") + static final TypeName NULLABLE = ClassName.bestGuess("android.support.annotation.Nullable") + static final TypeName NON_NULL = ClassName.bestGuess("android.support.annotation.NonNull") + static final TypeName JSON_OBJECT = ClassName.bestGuess("com.bluelinelabs.logansquare.annotation.JsonObject") + static final TypeName JSON_FIELD = ClassName.bestGuess("com.bluelinelabs.logansquare.annotation.JsonField") + static final TypeName COLLECTIONS = ClassName.get(Collections.class) + static final TypeName API_MODEL = ClassName.bestGuess("ru.touchin.templates.ApiModel") + static final TypeName LOGAN_SQUARE_JSON_MODEL = ClassName.bestGuess("ru.touchin.templates.logansquare.LoganSquareJsonModel") + static final TypeName OBJECT_UTILS = ClassName.bestGuess("ru.touchin.roboswag.core.utils.ObjectUtils") + static final TypeName OBJECT_OUTPUT_STREAM = ClassName.bestGuess("java.io.ObjectOutputStream") + static final TypeName OBJECT_INPUT_STREAM = ClassName.bestGuess("java.io.ObjectInputStream") + +} + +/** + * Abstract object of scheme for generation. Includes: + * - models of objects (classes); + * - enums; + * - imports to resolve external types usages. + */ +abstract class SchemeObject { + + /** + * Override to write scheme object code into *.java file. + * @param directory Directory to store *.java file; + * @param objects Other objects to resolve types etc.; + * @param packageName Package name of models/enums classes. + */ + abstract void writeToFile(File directory, Map objects, String packageName) + +} + +/** + * Object that's describing import of external type which is not generating by generator. + * + * Registerer it in scheme file like: + * + * imports: + * - android.support.v4.util.Pair + * - java.util.Date + */ +class ImportObject extends SchemeObject { + + static final String GROUP_NAME = "imports" + + final String name + final String fullName + + ImportObject(String value) { + fullName = value.trim() + name = fullName.substring(fullName.lastIndexOf('.') + 1) + } + + @Override + void writeToFile(final File directory, final Map objects, final String packageName) { + //do nothing - imports are only to resolve external types in other models + } + +} + +/** + * Object that's describing enum model. Where enum values are associating with values storing in JSON. + * Associating JSON values could be of types: string, boolean and int/long (numbers). + * + * Registerer it in scheme file like: + * + * enum MyEnum: + * VALUE_ONE: value_one + * VALUE_TWO: value_two + */ +class EnumObject extends SchemeObject { + + enum Type { + STRING("\$S"), + NUMBER("\$LL"), + BOOLEAN("\$L") + + final String format + + Type(final String format) { + this.format = format + } + } + + static final String PREFIX = "enum " + + static Type typeOf(final String jsonValue) { + if (jsonValue.equals("true") || jsonValue.equals("false")) { + //TODO: BOOLEAN in future + return Type.STRING + } + try { + Integer.parseInt(jsonValue) + //TODO: NUMBER in future + return Type.STRING + } catch (final NumberFormatException ignored) { + return Type.STRING + } + } + + final String name + Type type + + final Map values = new HashMap<>() + + EnumObject(final String enumName, final Map jsonValues) { + this.name = enumName.trim() + if (this.name.isEmpty()) { + throw new Exception("Name of enum is empty") + } + + for (final Map.Entry entry : jsonValues) { + final enumValue = entry.key.trim() + final jsonValue = entry.value.trim() + if (jsonValue.isEmpty() || enumValue.isEmpty()) { + throw new Exception("Value of enum is empty") + } + if (this.values.containsKey(enumValue)) { + throw new Exception("Value '" + enumValue + "' already registered into enum") + } + final Type type = typeOf(jsonValue) + if (this.type == null) { + this.type = type + } else if (this.type != type) { + throw new Exception("Type of value '" + jsonValues + "' conflicts with previous value type: " + this.type) + } + this.values.put(enumValue, jsonValue) + } + } + + @Override + void writeToFile(final File directory, final Map objects, final String packageName) { + final TypeSpec.Builder enumBuilder = TypeSpec.enumBuilder(name).addModifiers(Modifier.PUBLIC) + .addSuperinterface(Types.LOGAN_SQUARE_ENUM) + + .addField(FieldSpec.builder(ClassName.get(String.class), "valueName", Modifier.PRIVATE, Modifier.FINAL) + .addAnnotation(Types.NON_NULL) + .build()) + + .addMethod(MethodSpec.constructorBuilder() + .addParameter(ClassName.get(String.class), "valueName", Modifier.FINAL) + .addStatement("this.valueName = valueName") + .build()) + + .addMethod(MethodSpec.methodBuilder("getValueName") + .returns(ClassName.get(String.class)) + .addModifiers(Modifier.PUBLIC) + .addAnnotation(ClassName.get(Override.class)) + .addAnnotation(Types.NON_NULL) + .addStatement("return valueName") + .build()) + + .addType(TypeSpec.classBuilder("LoganSquareConverter").addModifiers(Modifier.PUBLIC, Modifier.STATIC) + .superclass(ParameterizedTypeName.get(Types.LOGAN_SQUARE_ENUM_CONVERTER, ClassName.bestGuess(name))) + .addMethod(MethodSpec.constructorBuilder().addModifiers(Modifier.PUBLIC) + .addStatement("super(values())") + .build()) + .build()) + + for (final Map.Entry enumValue : values) { + enumBuilder.addEnumConstant(enumValue.key, TypeSpec.anonymousClassBuilder(type.format, enumValue.value).build()) + } + + JavaFile.builder(packageName, enumBuilder.build()) + .indent(" ") + .build() + .writeTo(directory) + } + +} + +/** + * Type of field of class. + */ +enum FieldType { + + BOOLEAN(TypeName.BOOLEAN, ClassName.get(Boolean.class), false, "writeBoolean", "readBoolean"), + INT(TypeName.INT, ClassName.get(Integer.class), false, "writeInt", "readInt"), + LONG(TypeName.LONG, ClassName.get(Long.class), false, "writeLong", "readLong"), + FLOAT(TypeName.FLOAT, ClassName.get(Float.class), false, "writeFloat", "readFloat"), + DOUBLE(TypeName.DOUBLE, ClassName.get(Double.class), false, "writeDouble", "readDouble"), + STRING(ClassName.get(String.class), false, "writeUTF", "readUTF"), + LIST(ClassName.get(List.class), true), + MAP(ClassName.get(Map.class), true), + DATE_TIME(ClassName.bestGuess("org.joda.time.DateTime"), false), + ENUM(false), + MODEL(true), + IMPORTED_CLASS(true), + TYPE_ARGUMENT(true) + + // primitive type for using if use in Non-Null case + final TypeName primitiveTypeName + final TypeName nonPrimitiveTypeName + // flag to check if such type could be validate via ApiModel class methods in some way + final boolean ableToInnerValidate + final String serializationMethodName + final String deserializationMethodName + + FieldType(final boolean ableToInnerValidate) { + this(null, null, ableToInnerValidate, null, null) + } + + FieldType(final TypeName typeName, final boolean ableToInnerValidate) { + this(typeName, typeName, ableToInnerValidate, null, null) + } + + FieldType(final TypeName typeName, final boolean ableToInnerValidate, + final String serializationMethodName, final String deserializationMethodName) { + this(typeName, typeName, ableToInnerValidate, serializationMethodName, deserializationMethodName) + } + + FieldType(final TypeName primitiveTypeName, final TypeName nonPrimitiveTypeName, final boolean ableToInnerValidate, + final String serializationMethodName, final String deserializationMethodName) { + this.primitiveTypeName = primitiveTypeName + this.nonPrimitiveTypeName = nonPrimitiveTypeName + this.ableToInnerValidate = ableToInnerValidate + this.serializationMethodName = serializationMethodName + this.deserializationMethodName = deserializationMethodName + } + + /** + * Returning type of field by string of type. + * @param typeString String to get type from; + * @param objects Map of registered objects to check if such type is object from that map; + * @return Typeof field. + */ + static FieldType get(final String typeString, final Map objects) { + switch (typeString) { + case "string": case "String": return STRING + case "List": case "Collection": case "LinkedList": case "ArrayList": return LIST + case "Map": case "HashMap": case "TreeMap": case "LinkedHashMap": return MAP + case "int": case "Integer": return INT + case "boolean": case "Boolean": return BOOLEAN + case "long": case "Long": return LONG + case "float": case "Float": return FLOAT + case "double": case "Double": return DOUBLE + case "date": case "datetime": case "DateTime": return DATE_TIME + default: + final SchemeObject object = objects.get(typeString) + if (object instanceof ImportObject) { + return IMPORTED_CLASS + } + if (object instanceof EnumObject) { + return ENUM + } + if (object instanceof ClassObject) { + return MODEL + } + return TYPE_ARGUMENT + } + } + +} + +class TypeNameUtils { + + /** + * Returns type arguments part from full string that represents type. + * Sample: 'List' -> '' or 'Map' -> ''. + * @param fullTypeString Full string represents type. E.g. 'Map'; + * @return Type arguments part of string. Like '' + */ + private static String extractTypeArgumentsString(final String fullTypeString) { + final int startOfTypeArguments = fullTypeString.indexOf("<"); + return startOfTypeArguments > 0 ? fullTypeString.substring(startOfTypeArguments).replace(" ", "") : null + } + + /** + * Returns base type name without package and type arguments. + * Sample: 'java.lang.List' -> 'List' + * @param fullTypeString Full type name string; + * @return Base type name. + */ + static String extractBaseTypeString(final String fullTypeString) { + String result = fullTypeString.replace(" ", "") + if (result.indexOf('<') > 0) { + result = result.substring(0, result.indexOf('<')) + } + if (result.indexOf('.') > 0) { + result = result.substring(result.lastIndexOf('.') + 1) + } + return result + } + + /** + * Resolving type name of base type from string. + * Sample: 'List' -> 'java.util.List' typeName. + * @param typeString String represents type; + * @param objects Objects to resolve some external imported classes; + * @return Type name of base type. + */ + private static TypeName resolveBaseTypeName(final String typeString, final Map objects) { + final String baseTypeString = extractBaseTypeString(typeString) + final SchemeObject associatedObject = objects.get(baseTypeString) + if (associatedObject instanceof ImportObject) { + return ClassName.bestGuess(associatedObject.fullName) + } + return ClassName.bestGuess(baseTypeString) + } + + private static Pair getTypeNameWithArguments(final TypeName parentTypeName, String typeArgumentsString, + final Map objects) { + final List typeArguments = new ArrayList<>() + while (!typeArgumentsString.isEmpty()) { + final int nextComma = typeArgumentsString.indexOf(',') + final int nextLeft = typeArgumentsString.indexOf('<') + final int nextRight = typeArgumentsString.indexOf('>') + if (nextLeft == 0) { + throw new Exception("Unexpected symbol '<'") + } + if (nextRight == -1) { + throw new Exception("Missed symbol '>'") + } + if (nextRight == 0) { + throw new Exception("Argument missed") + } + + if (nextComma > 0 && nextComma < nextRight && (nextLeft == -1 || nextComma < nextLeft)) { + // when there are several types divided by comma and current have no arguments + // Samples: String, Integer>; String, List> + typeArguments.add(resolveBaseTypeName(typeArgumentsString.substring(0, nextComma), objects)) + typeArgumentsString = typeArgumentsString.substring(nextComma + 1) + continue + } + if (nextLeft == -1 || nextRight < nextLeft) { + // when there it is last argument in scope + // Samples: String>; String>, Integer>; String>> + typeArguments.add(resolveBaseTypeName(typeArgumentsString.substring(0, nextRight), objects)) + // stop parsing if there is last argument + typeArgumentsString = nextRight < typeArgumentsString.length() - 1 ? typeArgumentsString.substring(nextRight + 1) : "" + break + } + // when it is element with type arguments + // Sample: List> + final TypeName baseTypeName = resolveBaseTypeName(typeArgumentsString.substring(0, nextLeft), objects) + if (typeArgumentsString.length() < nextLeft + 2) { + throw new Exception("No data after '<'") + } + typeArgumentsString = typeArgumentsString.substring(nextLeft + 1) + final Pair innerArgs = getTypeNameWithArguments(baseTypeName, typeArgumentsString, objects) + typeArgumentsString = innerArgs.key.substring(1) + typeArguments.add(innerArgs.value) + } + return new Pair(typeArgumentsString, ParameterizedTypeName.get(parentTypeName, (TypeName[]) typeArguments.toArray())) + } + + /** + * Resolves TypeName from raw type string. + * @param typeString String describes type. E.g. 'java.lang.List' + * @param objects Map of registered objects. + * @return Resolved TypeName. + */ + static TypeName resolveTypeName(final String typeString, final Map objects) { + final TypeName baseTypeName = resolveBaseTypeName(typeString, objects) + final String typeArgumentsString = extractTypeArgumentsString(typeString) + if (typeArgumentsString == null) { + return baseTypeName + } + + try { + final Pair result = getTypeNameWithArguments(baseTypeName, typeArgumentsString.substring(1), objects) + if (!result.key.isEmpty()) { + throw new Exception("Useless symbols '" + result.key + "'") + } + return result.getValue() + } catch (final Exception exception) { + throw new Exception("Error resolving type of '" + typeString + "' : " + exception.getMessage()) + } + } +} + +/** + * Represents info about field of JSON model. + * - name - is field actual name; + * - jsonName - field name association with JSON parameter name. By default equals 'name' property; + * - type - type of field; + * - nullable - 'nullable' flag, true if field could contains null and associated JSON value could be null; + * - missable - 'missable' flag, true if JSON parameter associated with field could be missed in JSON object; + * - nonEmptyCollection - 'non-empty' flag, true if JSON parameter could contains collection and that collection souldn't be empty; + * - solidCollection - 'solid' flag, true if JSON parameter could contains collection and that collection can't contains any invalid element. + */ +class FieldInfo { + + private static final String DEFAULT_GETTER_PREFIX = "get" + + private static upperStartName(final String name) { + if (name.isEmpty()) { + throw new Exception("Empty name of field") + } + if (name.length() == 1) { + return name.charAt(0).toUpperCase() + } + return name.charAt(0).toUpperCase().toString() + name.substring(1) + } + + private static boolean checkNameStartsWithPrefix(final String name, final String prefix) { + return name.length() > prefix.length() && name.startsWith(prefix) && name.charAt(prefix.length()).isUpperCase() + } + + final String name + final String jsonName + boolean nullable + boolean missable + boolean nonEmptyCollection + boolean solidCollection + final FieldType type + final TypeName typeName + + FieldInfo(final String fieldName, final Map parameters, final Map objects) { + this.name = fieldName.trim() + if (name.isEmpty()) { + throw new Exception("name is empty") + } + jsonName = parameters.containsKey("jsonName") ? parameters.get("jsonName") : fieldName + if (jsonName.isEmpty()) { + throw new Exception("jsonName is empty") + } + + final String flagsString = parameters.get("flags") + if (flagsString != null) { + final List flags = Arrays.asList(flagsString.replace(" ", "").split(",")) + for (final String flag : flags) { + switch (flag) { + case "nullable": + nullable = true + break + case "missable": + missable = true + break + case "non-empty": + nonEmptyCollection = true + break + case "solid": + solidCollection = true + break + default: throw new Exception("Unexpected flag: " + flag) + } + } + } + + if (parameters.get("type") == null) { + throw new Exception("Missed type") + } + final String typeString = parameters.get("type").trim() + if (typeString.isEmpty()) { + throw new Exception("Empty type") + } + type = FieldType.get(TypeNameUtils.extractBaseTypeString(typeString), objects) + if (type.nonPrimitiveTypeName != null) { + if (type == FieldType.LIST) { + typeName = TypeNameUtils.resolveTypeName(typeString, objects) + if (!typeName.toString().startsWith("java.util.List")) { + throw new Exception("Unsupported list type '" + typeName.toString() + "' of field: " + fieldName + ". Supports only List<*>") + } + } else if (type == FieldType.MAP) { + typeName = TypeNameUtils.resolveTypeName(typeString, objects) + if (!typeName.toString().startsWith("java.util.Map") + } + } else { + typeName = nullable ? type.nonPrimitiveTypeName : type.primitiveTypeName + } + } else if (type != FieldType.TYPE_ARGUMENT) { + typeName = TypeNameUtils.resolveTypeName(typeString, objects) + } else { + // generic + typeName = ClassName.bestGuess(typeString) + } + } + + FieldSpec generateFieldCode() { + return FieldSpec.builder(typeName, name, Modifier.PRIVATE) + .addAnnotation(AnnotationSpec.builder(Types.JSON_FIELD) + .addMember("name", "\$S", jsonName) + .build()) + .build() + } + + private String getGetterPrefix() { + if (type != FieldType.BOOLEAN) { + return DEFAULT_GETTER_PREFIX + } + if (checkNameStartsWithPrefix(name, "is")) { + return "is" + } else if (checkNameStartsWithPrefix(name, "has")) { + return "has" + } else if (checkNameStartsWithPrefix(name, "have")) { + return "have" + } + return DEFAULT_GETTER_PREFIX + } + + MethodSpec generateGetterCode() { + final String getterPrefix = getGetterPrefix() + final MethodSpec.Builder builder = MethodSpec.methodBuilder(getterPrefix.equals(DEFAULT_GETTER_PREFIX) + ? getterPrefix + upperStartName(name) + : getterPrefix + upperStartName(name.substring(getterPrefix.length()))) + .addModifiers(Modifier.PUBLIC) + .returns(typeName) + + if (!typeName.isPrimitive()) { + builder.addAnnotation(AnnotationSpec.builder(nullable ? Types.NULLABLE : Types.NON_NULL).build()) + } + + if (type == FieldType.MAP) { + builder.addStatement("return \$T.unmodifiableMap(\$L)", Types.COLLECTIONS, name) + } else if (type == FieldType.LIST) { + builder.addStatement("return \$T.unmodifiableList(\$L)", Types.COLLECTIONS, name) + } else { + builder.addStatement("return \$L", name) + } + + return builder.build() + } + + MethodSpec generateSetterCode() { + final ParameterSpec.Builder parameterBuilder = ParameterSpec.builder(typeName, name, Modifier.FINAL) + if (!typeName.isPrimitive()) { + parameterBuilder.addAnnotation(AnnotationSpec.builder(nullable ? Types.NULLABLE : Types.NON_NULL).build()) + } + + final MethodSpec.Builder builder = MethodSpec.methodBuilder("set" + upperStartName(name)) + .addModifiers(Modifier.PUBLIC) + .addParameter(parameterBuilder.build()) + + if (type == FieldType.MAP) { + builder.addStatement("this.\$L = \$T.unmodifiableMap(\$L)", name, ClassName.get(Collections.class), name) + } else if (type == FieldType.LIST) { + builder.addStatement("this.\$L = \$T.unmodifiableList(\$L)", name, ClassName.get(Collections.class), name) + } else { + builder.addStatement("this.\$L = \$L", name, name) + } + + return builder.build() + } + + void generateValidationCode(MethodSpec.Builder validateMethod) { + if (!nullable) { + validateMethod.addStatement("validateNotNull(\$L)", name) + } + if (!type.ableToInnerValidate) { + return + } + if (type == FieldType.TYPE_ARGUMENT || type == FieldType.IMPORTED_CLASS) { + validateMethod + .beginControlFlow("if (\$L instanceof \$T)", name, Types.API_MODEL) + .addStatement("((\$T) \$L).validate()", Types.API_MODEL, name) + .endControlFlow() + return + } + if (nullable) { + validateMethod.beginControlFlow("if (\$L != null)", name) + } + if (type == FieldType.LIST) { + if (nonEmptyCollection) { + validateMethod.addStatement("validateCollectionNotEmpty(\$L)", name) + } + if (solidCollection) { + validateMethod.addStatement("validateCollection(\$L, CollectionValidationRule.EXCEPTION_IF_ANY_INVALID)", name) + } else if (nonEmptyCollection) { + validateMethod.addStatement("validateCollection(\$L, CollectionValidationRule.EXCEPTION_IF_ALL_INVALID)", name) + } else { + validateMethod.addStatement("validateCollection(\$L, CollectionValidationRule.REMOVE_INVALID_ITEMS)", name) + } + } else if (type == FieldType.MAP) { + if (nonEmptyCollection) { + validateMethod.addStatement("validateCollectionNotEmpty(\$L.values())", name) + } + validateMethod.addStatement("validateCollection(\$L.values(), CollectionValidationRule.EXCEPTION_IF_ANY_INVALID)", name) + } else if (type == FieldType.MODEL) { + validateMethod.addStatement("\$L.validate()", name) + } else { + throw new Exception("Unexpected able to validate field type '" + type + "' of field " + name) + } + if (nullable) { + validateMethod.endControlFlow() + } + } + +} + +/** + * Object that's describing class model. + * Contains name and info about each field. Field parameters are describing in FieldInfo class. + * Samples: + * + * class MySimpleClass: + * id: int + * name: string + * + * class MyNonSimpleClass: + * typeArguments: TItem, TResponse + * extends: BaseResponse + * id: + * jsonName: _id + * type: int + * name: + * type: string + * flags: nullable + * items: + * type: List + * flags: non-empty, solid + */ +class ClassObject extends SchemeObject { + + static final String PREFIX = "class " + + final String name + final Map fieldsInfo + final List fields = new ArrayList<>() + final List typeVariables = new ArrayList<>() + TypeName superclass + + ClassObject(final String name, final Map fieldsInfo) { + this.name = name.trim() + this.fieldsInfo = fieldsInfo + } + + void resolveFieldsInfo(final Map objects) { + final Set fieldNames = new HashSet<>() + for (final Map.Entry entry : fieldsInfo.entrySet()) { + if (fieldNames.contains(entry.key)) { + throw new Exception("Duplicate field name: " + name) + } + + if (entry.key.equals("typeVariables")) { + for (String typeVariable : entry.value.replace(" ", "").split(",")) { + typeVariables.add(typeVariable) + } + continue + } + + if (entry.key.equals("extends")) { + superclass = TypeNameUtils.resolveTypeName(entry.value, objects) + continue + } + + fieldNames.add(entry.key) + + try { + if (entry.value instanceof Map) { + fields.add(new FieldInfo(entry.key, (Map) entry.value, objects)) + } else { + final Map parameters = new HashMap<>() + parameters.put("type", entry.value) + fields.add(new FieldInfo(entry.key, parameters, objects)) + } + } catch (final Exception exception) { + throw new Exception("Error on parsing field '" + entry.key + "' : " + exception.getMessage()) + } + } + } + + @Override + void writeToFile(final File directory, final Map objects, final String packageName) { + final TypeSpec.Builder classBuilder = TypeSpec.classBuilder(name).addModifiers(Modifier.PUBLIC) + .addAnnotation(AnnotationSpec.builder(Types.JSON_OBJECT).addMember("serializeNullObjects", "true").build()) + .superclass(superclass != null ? superclass : Types.LOGAN_SQUARE_JSON_MODEL) + + // adds type variables + for (String typeVariable : typeVariables) { + classBuilder.addTypeVariable(TypeVariableName.get(typeVariable)) + } + + // adds default constructor + classBuilder.addMethod(MethodSpec.constructorBuilder().addModifiers(Modifier.PUBLIC).addStatement("super()").build()) + + // creates full constructor only if it is extends from LoganSquareJsonModel, + // else we can't create constructor as parent constructor could also have parameters + final MethodSpec.Builder fullConstructorBuilder = superclass == null ? + MethodSpec.constructorBuilder().addModifiers(Modifier.PUBLIC).addStatement("super()") + : null + + // creates validate() method + final MethodSpec.Builder validateMethod = MethodSpec.methodBuilder("validate").addModifiers(Modifier.PUBLIC) + .addAnnotation(Override.class) + .addException(ClassName.bestGuess("ValidationException")) + .addStatement("super.validate()") + + // creates writeObject() method + final MethodSpec.Builder serializeMethod = MethodSpec.methodBuilder("writeObject").addModifiers(Modifier.PRIVATE) + .addException(ClassName.get(IOException.class)) + .addParameter(ParameterSpec.builder(Types.OBJECT_OUTPUT_STREAM, "outputStream", Modifier.FINAL) + .addAnnotation(Types.NON_NULL) + .build()) + + // creates readObject() method + final MethodSpec.Builder deserializeMethod = MethodSpec.methodBuilder("readObject").addModifiers(Modifier.PRIVATE) + .addAnnotation(AnnotationSpec.builder(SuppressWarnings.class).addMember("value", "\$S", "unchecked").build()) + .addException(ClassName.get(IOException.class)) + .addException(ClassName.get(ClassNotFoundException.class)) + .addParameter(ParameterSpec.builder(Types.OBJECT_INPUT_STREAM, "inputStream", Modifier.FINAL) + .addAnnotation(Types.NON_NULL) + .build()) + + boolean first = true + final CodeBlock.Builder equalsStatement = CodeBlock.builder() + final CodeBlock.Builder hashCodeStatement = CodeBlock.builder() + + for (final FieldInfo field : fields) { + classBuilder.addField(field.generateFieldCode()) + classBuilder.addMethod(field.generateGetterCode()) + classBuilder.addMethod(field.generateSetterCode()) + field.generateValidationCode(validateMethod) + final String serializeMethodName = (!field.nullable && field.type.serializationMethodName != null + ? field.type.serializationMethodName : "writeObject"); + final String deserializeMethodName = (!field.nullable && field.type.deserializationMethodName != null + ? field.type.deserializationMethodName : "readObject"); + serializeMethod.addStatement("outputStream.\$L(\$L)", serializeMethodName, field.name) + if (deserializeMethodName.equals("readObject")) { + deserializeMethod.addStatement("\$L = (\$T) inputStream.\$L()", field.name, field.typeName, deserializeMethodName) + } else { + deserializeMethod.addStatement("\$L = inputStream.\$L()", field.name, deserializeMethodName) + } + + if (fullConstructorBuilder != null) { + fullConstructorBuilder.addParameter(ParameterSpec.builder(field.typeName, field.name, Modifier.FINAL) + .addAnnotation(field.nullable ? Types.NULLABLE : Types.NON_NULL) + .build()) + if (field.type == FieldType.LIST) { + fullConstructorBuilder.addStatement("this.\$L = \$T.unmodifiableList(\$L)", field.name, Types.COLLECTIONS, field.name) + } else if (field.type == FieldType.MAP) { + fullConstructorBuilder.addStatement("this.\$L = \$T.unmodifiableMap(\$L)", field.name, Types.COLLECTIONS, field.name) + } else { + fullConstructorBuilder.addStatement("this.\$L = \$L", field.name, field.name) + } + } + + if (first) { + if (superclass == null) { + hashCodeStatement.add("return \$T.hashCode(", Types.OBJECT_UTILS) + equalsStatement.add("return ", Types.OBJECT_UTILS) + } else { + hashCodeStatement.add("return \$T.hashCode(super.hashCode(), ", Types.OBJECT_UTILS) + equalsStatement.add("return super.equals(that) && ", Types.OBJECT_UTILS, field.name, field.name) + } + } else { + hashCodeStatement.add(", ") + equalsStatement.add("\n\t\t&& ") + } + + if (field.type == FieldType.MAP) { + equalsStatement.add("\$T.isMapsEquals(\$L, that.\$L)", Types.OBJECT_UTILS, field.name, field.name) + } else if (field.type == FieldType.LIST) { + equalsStatement.add("\$T.isCollectionsEquals(\$L, that.\$L)", Types.OBJECT_UTILS, field.name, field.name) + } else { + equalsStatement.add("\$T.equals(\$L, that.\$L)", Types.OBJECT_UTILS, field.name, field.name) + } + hashCodeStatement.add("\$L", field.name) + first = false + } + equalsStatement.add(";\n") + hashCodeStatement.add(");\n") + + // creates validate() method + classBuilder.addMethod(validateMethod.build()) + // adds equals() method + classBuilder.addMethod(MethodSpec.methodBuilder("equals").addModifiers(Modifier.PUBLIC) + .addAnnotation(Override.class) + .returns(TypeName.BOOLEAN) + .addParameter(ParameterSpec.builder(ClassName.get(Object.class), "object", Modifier.FINAL).addAnnotation(Types.NULLABLE).build()) + .beginControlFlow("if (this == object)").addStatement("return true").endControlFlow() + .beginControlFlow("if (object == null || getClass() != object.getClass())").addStatement("return false").endControlFlow() + .addStatement("final \$T that = (\$T) object", ClassName.bestGuess(name), ClassName.bestGuess(name)) + .addCode(equalsStatement.build()).build()) + // adds hashCode() method + classBuilder.addMethod(MethodSpec.methodBuilder("hashCode").addModifiers(Modifier.PUBLIC) + .addAnnotation(Override.class) + .returns(TypeName.INT) + .addCode(hashCodeStatement.build()).build()) + // adds writeObject() method + classBuilder.addMethod(serializeMethod.build()) + // adds readObject() method + classBuilder.addMethod(deserializeMethod.build()) + + if (fullConstructorBuilder != null) { + classBuilder.addMethod(fullConstructorBuilder.build()) + } + JavaFile.builder(packageName, classBuilder.build()) + .indent(" ") + .build() + .writeTo(directory) + } + +} + +class FileUtils { + + static void purgeDirectory(final File directory) { + for (File file : directory.listFiles()) { + if (file.isDirectory()) { + purgeDirectory(file) + } + file.delete() + } + } + + static void generateJsonModelsCode(final File generatedModelsDirectory, final String schemeFilePath, + final String modelsPackage, final String projectDir) { + if (schemeFilePath == null) { + return + } + + File schemeFile = new File(schemeFilePath) + if (!schemeFile.exists()) { + schemeFile = new File(projectDir, schemeFilePath) + } + if (!schemeFile.exists()) { + throw new Exception("JSON models scheme file not found at '" + schemeFilePath) + } + + final Yaml yaml = new Yaml() + final Map schemeObjects = new HashMap<>() + schemeObjects.put("Map", new ImportObject("java.util.Map")) + schemeObjects.put("List", new ImportObject("java.util.List")) + schemeObjects.put("DateTime", new ImportObject("org.joda.time.DateTime")) + + for (final Object data : yaml.loadAll(new FileReader(schemeFile))) { + if (!(data instanceof Map)) { + throw new Exception("Yaml file '" + schemeFile + "' is invalid") + } + + for (final Map.Entry entry : data.entrySet()) { + if (entry.key.equals(ImportObject.GROUP_NAME)) { + for (String importString : (Iterable) entry.value) { + final ImportObject importObject = new ImportObject(importString) + schemeObjects.put(importObject.name, importObject) + } + } else if (entry.key.startsWith(EnumObject.PREFIX)) { + final EnumObject enumObject = new EnumObject(entry.key.substring(EnumObject.PREFIX.length()), entry.value) + schemeObjects.put(enumObject.name, enumObject) + } else if (entry.key.startsWith(ClassObject.PREFIX)) { + final ClassObject classObject = new ClassObject(entry.key.substring(ClassObject.PREFIX.length()), entry.value) + schemeObjects.put(classObject.name, classObject) + } else { + throw new Exception("Unexpected scheme object: " + entry.key) + } + } + } + + for (final SchemeObject schemeObject : schemeObjects.values()) { + if (schemeObject instanceof ClassObject) { + try { + schemeObject.resolveFieldsInfo(schemeObjects) + } catch (final Exception exception) { + throw new Exception("Error on parsing class '" + schemeObject.name + "' : " + exception.getMessage()) + } + } + try { + schemeObject.writeToFile(generatedModelsDirectory, schemeObjects, modelsPackage) + } catch (final Exception exception) { + throw new Exception("Error on generating code for '" + schemeObject.name + "' : " + exception.getMessage()) + } + } + } + +} + +android.applicationVariants.all { + variant -> + final File generatedModelsDirectory = new File("${project.buildDir}/generated/source/jsonModels/${variant.dirName}") + /** + * Generating Java classes describing JSON models from specific YAML scheme. + */ + def generateJsonModelsTask = tasks.create("generateJsonModels${variant.name}") << { + final List jsonModelsMapping = android.extensions.findByName("jsonModelsMapping") + + FileUtils.purgeDirectory(generatedModelsDirectory) + for (final String jsonMapping : jsonModelsMapping) { + final int indexOfDivider = jsonMapping.indexOf('->') + if (indexOfDivider == -1) { + FileUtils.generateJsonModelsCode(generatedModelsDirectory, + jsonMapping.trim(), + android.defaultConfig.applicationId + '.logic.model', + "${project.projectDir}") + } else { + FileUtils.generateJsonModelsCode(generatedModelsDirectory, + jsonMapping.substring(0, indexOfDivider).trim(), + jsonMapping.substring(indexOfDivider + 2).trim(), + "${project.projectDir}") + } + } + } + + generateJsonModelsTask.description = 'Generates Java classes for JSON models' + variant.registerJavaGeneratingTask generateJsonModelsTask, generatedModelsDirectory +} \ No newline at end of file