TypeScriptHelper.java
/*
* The coLAB project
* Copyright (C) 2021-2023 AlbaSim, MEI, HEIG-VD, HES-SO
*
* Licensed under the MIT License
*/
package ch.colabproject.colab.generator.plugin;
import ch.colabproject.colab.generator.model.interfaces.WithJsonDiscriminator;
import ch.colabproject.colab.generator.model.tools.ClassDoc;
import ch.colabproject.colab.generator.plugin.rest.ErrorHandler;
import java.io.File;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.StringReader;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.time.temporal.Temporal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import javax.json.Json;
import javax.json.JsonObject;
import javax.json.bind.Jsonb;
import javax.json.bind.JsonbBuilder;
import javax.json.bind.JsonbConfig;
import javax.json.stream.JsonParser;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import javax.ws.rs.core.Response;
import org.apache.maven.plugin.MojoFailureException;
import org.reflections.Reflections;
/**
* Some methods to convert java things to typescript ones
*
* @author maxence
*/
public class TypeScriptHelper {
/**
* never-called private constructor
*/
private TypeScriptHelper() {
throw new UnsupportedOperationException(
"This is a utility class and cannot be instantiated");
}
private static String getTsTypeName(Class<?> javaClass) {
if (WithJsonDiscriminator.class.isAssignableFrom(javaClass)) {
return WithJsonDiscriminator.getJsonDiscriminator(javaClass);
} else {
if (javaClass.getPackageName().startsWith("ch.colabproject") && !javaClass.isEnum()) {
Logger.info("Consider to implement WithJsonDiscriminator: "
+ javaClass.getSimpleName());
}
return javaClass.getSimpleName();
}
}
private static Method getGetter(Class<?> javaClass, String fieldName) {
String methodName = fieldName.substring(0, 1).toUpperCase()
+ fieldName.substring(1);
try {
// default getter looks like getXxxx
return javaClass.getMethod("get" + methodName);
} catch (NoSuchMethodException ex2) {
try {
// boolean getter may looks liek isXxx
return javaClass.getMethod("is" + methodName);
} catch (NoSuchMethodException ex3) {
return null;
}
}
}
private static Field getField(Class<?> javaClass, String fieldName) {
Class<?> declaringClass = javaClass;
Method getter = getGetter(javaClass, fieldName);
if (getter != null) {
declaringClass = getter.getDeclaringClass();
}
try {
return declaringClass.getDeclaredField(fieldName);
} catch (NoSuchFieldException ex) {
return null;
}
}
/**
* Generate typescript interface.This method will populate types map with type required by the
* generated interface
*
* @param javaType the java type to generate bindings for
* @param types list of type this interface required to be generated too
* @param inheritance this method will populate this map will known implementation
* @param reflections reflection store to fetch abstract classes /interfaces directSubtypes
* @param javadoc javadoc as extracted by the JavaDocEtractor
*
* @return ts interface or type
*
* @throws org.apache.maven.plugin.MojoFailureException if generation fails
*/
public static String generateInterface(
Type javaType,
Map<String, Type> types,
Map<String, List<String>> inheritance,
Reflections reflections,
Map<String, ClassDoc> javadoc
) throws MojoFailureException {
if (javaType instanceof Class<?>) {
Class<?> javaClass = (Class<?>) javaType;
String name = getTsTypeName(javaClass);
if (javaClass.isArray()) {
// hack: remove []
name = name.replace("[]", "");
}
StringBuilder sb = new StringBuilder();
int modifiers = javaClass.getModifiers();
if (javaClass.isEnum()) {
String joinType = Arrays.stream(javaClass.getEnumConstants())
.map(item -> "'" + item.toString() + "'")
.collect(Collectors.joining(" | "));
sb.append("export type ").append(name).append(" = ")
.append(joinType).append(";\n");
} else if (Modifier.isAbstract(modifiers) || Modifier.isInterface(modifiers)) {
// abstract class
// Type X = directSubCLass | otherdirectsubclass
List<String> allConcreteSubtypes = reflections.getSubTypesOf(javaClass).stream()
.filter(subType -> !Modifier.isAbstract(subType.getModifiers())
&& !Modifier.isInterface(subType.getModifiers())
)
.map(subType -> getTsTypeName(subType))
.collect(Collectors.toList());
inheritance.put(name, allConcreteSubtypes);
String directSubtypes = reflections.getSubTypesOf(javaClass).stream()
// Only keep direct directSubtypes
.filter(subType -> {
return (Modifier.isAbstract(modifiers)
&& javaClass.equals(subType.getSuperclass()))
|| (Modifier.isInterface(modifiers)
&& Arrays.stream(subType.getInterfaces())
.anyMatch(iface -> iface.equals(javaClass)));
})
.map(subType -> {
String subTypeTsName = getTsTypeName(subType);
// make sure to generate interface
types.put(subTypeTsName, subType);
return subTypeTsName;
})
.collect(Collectors.joining(" | "));
sb.append("export type ").append(name).append(" = ")
.append(directSubtypes == null || directSubtypes.isBlank() ? " never"
: directSubtypes)
.append(";\n");
} else {
inheritance.put(name, new ArrayList<>());
inheritance.get(name).add(name);
// concrete class
///////////////////////////////
ClassDoc classDoc = javadoc.get(javaClass.getName());
Map<String, String> fields = null;
// javadoc
sb.append("/**\n");
if (classDoc != null) {
sb.append(classDoc.getDoc());
fields = classDoc.getFields();
} else {
Logger.warn("No javadoc for class " + name);
}
sb.append("*/\n");
sb.append("export interface ").append(name).append("{\n");
if (WithJsonDiscriminator.class.isAssignableFrom(javaClass)) {
sb.append(" '@class': '").append(name).append("';\n");
}
try {
Object newInstance = javaClass.getConstructor().newInstance();
JsonbConfig withNullValues = new JsonbConfig()
.withNullValues(Boolean.TRUE);
Jsonb jsonb = JsonbBuilder.create(withNullValues);
String json = jsonb.toJson(newInstance);
JsonParser parser = Json.createParser(new StringReader(json));
parser.next();
JsonObject object = parser.getObject();
Set<String> keySet = object.keySet();
for (String key : keySet) {
if (!"@class".equals(key)) {
Type propertyType = null;
boolean optional = true;
Field field = getField(javaClass, key);
if (field != null) {
optional = (field.getAnnotation(NotNull.class) == null)
&& (field.getAnnotation(NotEmpty.class) == null)
&& (field.getAnnotation(NotBlank.class) == null);
propertyType = field.getGenericType();
} else {
// unable to find a field, rely on method
// no way to detect whether the property is optional
Method getter = getGetter(javaClass, key);
if (getter != null) {
propertyType = getter.getGenericReturnType();
}
}
if (fields != null) {
String fieldDoc = fields.getOrDefault(key, "");
sb.append(" /**\n ").append(fieldDoc).append(" */\n");
}
sb.append(" '").append(key).append("'");
if (optional) {
sb.append("?");
}
sb.append(": ");
if (propertyType != null) {
String tsType = convertType(propertyType, types);
sb.append(tsType);
} else {
sb.append("unknown");
}
if (optional) {
sb.append(" | undefined | null");
}
sb.append(";\n");
}
}
} catch (RuntimeException
| NoSuchMethodException
| InstantiationException
| IllegalAccessException
| InvocationTargetException ex) {
throw new MojoFailureException("Something went wrong", ex);
}
sb.append("}\n");
}
return sb.toString();
} else {
return "";
}
}
private static boolean isArrayLike(Type javaType) {
if (javaType instanceof Class) {
return Collection.class.isAssignableFrom((Class<?>) javaType);
}
return false;
}
private static boolean isMap(Type javaType) {
if (javaType instanceof Class) {
return Map.class.isAssignableFrom((Class<?>) javaType);
}
return false;
}
/**
* Convert java type to typescript type. this method will populate the customTypes map with java
* type which requires a dedicates TS interface.
*
* @param javaType the java type to convert
* @param customTypes java type to generate TS interface for
*
* @return the typescript type name to use
*/
public static String convertType(Type javaType, Map<String, Type> customTypes) {
if (javaType == null) {
return "undefined";
} else if (javaType instanceof Class) {
Class<?> javaClass = (Class<?>) javaType;
if (Number.class.isAssignableFrom(javaClass)
|| byte.class.isAssignableFrom(javaClass)
|| short.class.isAssignableFrom(javaClass)
|| int.class.isAssignableFrom(javaClass)
|| long.class.isAssignableFrom(javaClass)
|| float.class.isAssignableFrom(javaClass)
|| double.class.isAssignableFrom(javaClass)
|| Temporal.class.isAssignableFrom(javaClass)) {
return "number";
} else if (String.class.isAssignableFrom(javaClass)) {
return "string";
} else if (boolean.class.isAssignableFrom(javaClass)
|| Boolean.class.isAssignableFrom(javaClass)) {
return "boolean";
} else if (void.class.isAssignableFrom(javaClass)) {
return "void";
} else if (InputStream.class.isAssignableFrom(javaClass)
|| OutputStream.class.isAssignableFrom(javaClass)
|| File.class.isAssignableFrom(javaClass)) {
return "File";
} else if (Response.class.isAssignableFrom(javaClass)) {
return "HttpResponse";
} else if (ErrorHandler.class.isAssignableFrom(javaClass)) {
return "ErrorHandler";
} else if (isArrayLike(javaClass)) {
return "unknown[]";
// } else if (javaClass.isEnum()) {
// return Arrays.stream(javaClass.getEnumConstants())
// .map(item -> "'" + item.toString() + "'")
// .collect(Collectors.joining(" | "));
} else {
String name = getTsTypeName(javaClass);
if (!customTypes.containsKey(name)) {
customTypes.put(name, javaType);
/* } else { // TODO check collision */
}
return name;
}
} else if (javaType instanceof ParameterizedType) {
ParameterizedType genericType = (ParameterizedType) javaType;
Type rawType = genericType.getRawType();
Type[] args = genericType.getActualTypeArguments();
if (isArrayLike(rawType)) {
return convertType(args[0], customTypes) + "[]";
} else if (isMap(rawType)) {
return "{"
+ "[key: " + convertType(args[0], customTypes) + "]: "
+ convertType(args[1], customTypes)
+ "}";
} else {
return "unknown";
}
} else {
return "unknown";
}
}
}