/* * #%L * FlatPack Jersey integration * %% * Copyright (C) 2012 Perka Inc. * %% * 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. * #L% */ package com.getperka.flatpack.jersey; import static com.getperka.flatpack.util.FlatPackCollections.listForAny; import static com.getperka.flatpack.util.FlatPackCollections.mapForLookup; import static com.getperka.flatpack.util.FlatPackCollections.setForIteration; import static com.getperka.flatpack.util.FlatPackCollections.setForLookup; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.net.URLDecoder; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.inject.Inject; import javax.ws.rs.HttpMethod; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.QueryParam; import javax.ws.rs.core.UriBuilder; import com.getperka.flatpack.FlatPack; import com.getperka.flatpack.FlatPackEntity; import com.getperka.flatpack.HasUuid; import com.getperka.flatpack.Packer; import com.getperka.flatpack.TraversalMode; import com.getperka.flatpack.TypeReference; import com.getperka.flatpack.Unpacker; import com.getperka.flatpack.Visitors; import com.getperka.flatpack.client.dto.ApiDescription; import com.getperka.flatpack.client.dto.EndpointDescription; import com.getperka.flatpack.client.dto.ParameterDescription; import com.getperka.flatpack.client.dto.TypeDescription; import com.getperka.flatpack.ext.Codex; import com.getperka.flatpack.ext.EntityDescription; import com.getperka.flatpack.ext.Property; import com.getperka.flatpack.ext.Type; import com.getperka.flatpack.ext.TypeContext; import com.getperka.flatpack.ext.VisitorContext; import com.getperka.flatpack.inject.HasInjector; import com.getperka.flatpack.jersey.FlatPackResponse.ExtraEntity; import com.getperka.flatpack.security.GroupPermissions; import com.getperka.flatpack.security.SecurityAction; import com.getperka.flatpack.security.SecurityGroup; import com.getperka.flatpack.security.SecurityGroups; import com.getperka.flatpack.util.AcyclicVisitor; import com.getperka.flatpack.util.FlatPackTypes; import com.google.gson.Gson; import com.google.gson.JsonElement; /** * Analyzes a FlatPack instance and a API methods to produce an {@link ApiDescription}. */ public class ApiDescriber { private static final Pattern linkPattern = Pattern.compile("[{]@link[\\s]+([^\\s}]+)([^}]*)?[}]"); private String apiName = "API"; private final Collection<Method> apiMethods; @Inject private TypeContext ctx; private final Map<Package, Map<String, String>> docStringsByPackage = mapForLookup(); private final Set<Class<? extends HasUuid>> described = setForLookup(); private Set<Class<? extends HasUuid>> entitiesToExtract = setForIteration(); private Set<String> limitGroupNames; @Inject private Packer packer; private final Map<Property, EntityDescription> propertiesToEntities = mapForLookup(); @Inject private SecurityGroups securityGroups; /** * Each entry contains the key and all of its known subtypes. */ private final Map<Class<? extends HasUuid>, Set<Class<? extends HasUuid>>> typeHierarchy = mapForLookup(); @Inject private Unpacker unpacker; @Inject private Visitors visitors; /** * Allows weak entity types to be included. */ private Set<EntityDescription> weakEntities = setForLookup(); /** * Entities that contain only these properties will be filtered. */ private Set<Property> weakProperties = setForIteration(); public ApiDescriber(FlatPack flatpack, Collection<Method> apiMethods) { this.apiMethods = apiMethods; ((HasInjector) flatpack).getInjector().injectMembers(this); } /** * Analyze the Methods provided to the constructor and produce an ApiDescription. */ public ApiDescription describe() throws IOException { // Compute type hierarchy so a reference to a base type will include its subtypes for (EntityDescription entity : ctx.getEntityDescriptions()) { // Accumulate subtypes as we ascend the type hiererchy List<Class<? extends HasUuid>> chain = listForAny(); while (entity != null) { Class<? extends HasUuid> clazz = entity.getEntityType(); chain.add(clazz); Set<Class<? extends HasUuid>> set = typeHierarchy.get(clazz); if (set == null) { set = setForIteration(); typeHierarchy.put(clazz, set); } // Add all accumulated subtypes set.addAll(chain); entity = entity.getSupertype(); } } ApiDescription description = new ApiDescription(); description.setApiName(apiName); List<EntityDescription> entities = new ArrayList<EntityDescription>(); description.setEntities(entities); // Extract API endpoints Set<EndpointDescription> endpoints = new LinkedHashSet<EndpointDescription>(); for (Method method : apiMethods) { EndpointDescription desc = describeEndpoint(method); if (desc != null) { endpoints.add(desc); } } description.setEndpoints(new ArrayList<EndpointDescription>(endpoints)); // Extract all entities Set<Class<?>> seen = setForLookup(); do { Set<Class<? extends HasUuid>> toProcess = entitiesToExtract; entitiesToExtract = setForIteration(); for (Class<? extends HasUuid> clazz : toProcess) { if (seen.add(clazz)) { entities.add(describeEntity(clazz)); } } } while (!entitiesToExtract.isEmpty()); if (limitGroupNames != null) { /* * Filter the description, but first we need to make a mutable copy of the internal * datastructures. */ JsonElement elt = packer.pack(FlatPackEntity.entity(description)); description = unpacker.<ApiDescription> unpack(ApiDescription.class, elt, null).getValue(); visitors.visit(new AcyclicVisitor() { @Override public <T> void endVisitValue(T value, Codex<T> codex, VisitorContext<T> ctx) { boolean keep; if (value instanceof Property) { Property p = (Property) value; keep = shouldKeep(p.getGroupPermissions()); /* * If the implied property should be kept, also keep this one. This allows collection * properties in parent types to be generated if only the child's parent-referencing * property is visible. */ if (!keep && p.getImpliedProperty() != null) { keep = shouldKeep(p.getImpliedProperty().getGroupPermissions()); } } else if (value instanceof EntityDescription) { EntityDescription e = (EntityDescription) value; if (e.getProperties().isEmpty()) { keep = false; } else if (!weakEntities.contains(e) && weakProperties.containsAll(e.getProperties())) { keep = false; } else { keep = true; } } else { keep = true; } if (keep) { return; } if (ctx.canRemove()) { ctx.remove(); } else if (ctx.canReplace()) { ctx.replace(null); } else { throw new UnsupportedOperationException("Could not filter"); } } private boolean shouldKeep(GroupPermissions permissions) { if (permissions == null) { return true; } for (Map.Entry<SecurityGroup, Set<SecurityAction>> entry : permissions.getOperations() .entrySet()) { // If no actions are granted, ignore the group if (entry.getValue().isEmpty()) { continue; } SecurityGroup group = entry.getKey(); if (securityGroups.getGroupAll().equals(group) || securityGroups.getGroupReflexive().equals(group) || limitGroupNames.contains(group.getName())) { return true; } } return false; } }, description); } return description; } /** * Filter entities that contain only properties defined by the given class. */ public ApiDescriber ignoreEmptySubtypes(Class<? extends HasUuid> toIgnore) { EntityDescription describe = ctx.describe(toIgnore); weakEntities.add(describe); weakProperties.addAll(describe.getProperties()); return this; } /** * Only extract items that may be accessed by {@link SecurityGroup} with the given names. */ public ApiDescriber limitGroupNames(Collection<String> limitRoles) { this.limitGroupNames = setForIteration(); this.limitGroupNames.addAll(limitRoles); return this; } public ApiDescriber withApiName(String apiName) { this.apiName = apiName; return this; } protected String keyForType(java.lang.reflect.Type t) { if (t instanceof Class) { return ((Class<?>) t).getName(); } if (t instanceof ParameterizedType) { ParameterizedType pt = (ParameterizedType) t; StringBuilder sb = new StringBuilder(); sb.append(keyForType(pt.getRawType())).append("<"); boolean needsComma = false; for (java.lang.reflect.Type param : pt.getActualTypeArguments()) { if (needsComma) { sb.append(","); } else { needsComma = true; } sb.append(keyForType(param)); } sb.append(">"); return sb.toString(); } throw new UnsupportedOperationException(t.getClass().getName()); } protected String methodKey(Class<?> declaringClass, Method method) { String methodKey; { StringBuilder methodKeyBuilder = new StringBuilder(declaringClass.getName()) .append(":").append(method.getName()).append("("); boolean needsComma = false; for (java.lang.reflect.Type paramType : method.getGenericParameterTypes()) { if (needsComma) { methodKeyBuilder.append(", "); } else { needsComma = true; } methodKeyBuilder.append(keyForType(paramType)); } methodKeyBuilder.append(")"); methodKey = methodKeyBuilder.toString(); } return methodKey; } private EndpointDescription describeEndpoint(Method method) throws IOException { Class<?> declaringClass = method.getDeclaringClass(); // Determine the HTTP access method String methodName = null; for (Annotation annotation : method.getAnnotations()) { // The HTTP method is declared as a meta-annotation on the @GET, @PUT, etc. annotation HttpMethod methodAnnotation = annotation.annotationType().getAnnotation(HttpMethod.class); if (methodAnnotation != null) { methodName = methodAnnotation.value(); } } if (methodName == null) { return null; } // Create a key for looking up the method's doc strings String methodKey = methodKey(declaringClass, method); // Determine the endpoint path UriBuilder builder = UriBuilder.fromPath(""); if (declaringClass.isAnnotationPresent(Path.class)) { builder.path(declaringClass); } if (method.isAnnotationPresent(Path.class)) { builder.path(method); } // This path has special characters URL-escaped, so we'll undo the escaping String path = builder.build().toString(); path = URLDecoder.decode(path, "UTF8"); // Build the EndpointDescription EndpointDescription desc = new EndpointDescription(methodName, path); List<ParameterDescription> pathParams = new ArrayList<ParameterDescription>(); List<ParameterDescription> queryParams = new ArrayList<ParameterDescription>(); Annotation[][] annotations = method.getParameterAnnotations(); java.lang.reflect.Type[] parameters = method.getGenericParameterTypes(); for (int i = 0, j = parameters.length; i < j; i++) { Type paramType = reference(parameters[i]); if (annotations[i].length == 0) { // Assume that an un-annotated parameter is the main entity type desc.setEntity(paramType); } else { for (Annotation annotation : annotations[i]) { if (PathParam.class.equals(annotation.annotationType())) { PathParam pathParam = (PathParam) annotation; ParameterDescription param = new ParameterDescription(desc, pathParam.value(), paramType); String docString = getDocStrings(declaringClass).get(methodKey + "[" + i + "]"); param.setDocString(replaceLinks(docString)); pathParams.add(param); } else if (QueryParam.class.equals(annotation.annotationType())) { QueryParam queryParam = (QueryParam) annotation; ParameterDescription param = new ParameterDescription(desc, queryParam.value(), paramType); String docString = getDocStrings(declaringClass).get(methodKey + "[" + i + "]"); param.setDocString(replaceLinks(docString)); queryParams.add(param); } } } } // If the returned entity type is described, extract the information FlatPackResponse responseAnnotation = method.getAnnotation(FlatPackResponse.class); if (responseAnnotation != null) { java.lang.reflect.Type reflectType = FlatPackTypes.createType(responseAnnotation.value()); Type returnType = reference(reflectType); desc.setReturnDocString( responseAnnotation.description().isEmpty() ? null : responseAnnotation.description()); desc.setReturnType(returnType); desc.setTraversalMode(responseAnnotation.traversalMode()); List<TypeDescription> extraTypeDescriptions = new ArrayList<TypeDescription>(); for (ExtraEntity extra : responseAnnotation.extra()) { TypeDescription typeDescription = new TypeDescription(); typeDescription.setDocString(extra.description()); typeDescription.setType(reference(FlatPackTypes.createType(extra.type()))); extraTypeDescriptions.add(typeDescription); } desc.setExtraReturnData(extraTypeDescriptions.isEmpty() ? null : extraTypeDescriptions); } else if (HasUuid.class.isAssignableFrom(method.getReturnType())) { Type returnType = reference(method.getReturnType()); desc.setReturnType(returnType); desc.setTraversalMode(TraversalMode.SIMPLE); } String docString = getDocStrings(declaringClass).get(methodKey); desc.setDocString(replaceLinks(docString)); desc.setPathParameters(pathParams.isEmpty() ? null : pathParams); desc.setQueryParameters(queryParams.isEmpty() ? null : queryParams); return desc; } private EntityDescription describeEntity(Class<? extends HasUuid> clazz) throws IOException { EntityDescription toReturn = ctx.describe(clazz); if (!described.add(clazz)) { return toReturn; } // Attach interesting annotations toReturn.setDocAnnotations(extractInterestingAnnotations(clazz)); // Attach the docstring Map<String, String> strings = getDocStrings(clazz); String docString = strings.get(clazz.getName()); if (docString != null) { toReturn.setDocString(replaceLinks(docString)); } // Iterate over the properties for (Iterator<Property> it = toReturn.getProperties().iterator(); it.hasNext();) { Property prop = it.next(); propertiesToEntities.put(prop, toReturn); // Record a reference to (possibly) an entity type reference(prop.getType()); Method accessor = prop.getGetter(); if (accessor == null) { accessor = prop.getSetter(); } // Send down interesting annotations prop.setDocAnnotations(extractInterestingAnnotations(accessor)); // The property set include all properties defined in supertypes Class<?> declaringClass = accessor.getDeclaringClass(); strings = getDocStrings(declaringClass); if (strings != null) { String memberName = declaringClass.getName() + ":" + accessor.getName() + "()"; prop.setDocString(replaceLinks(strings.get(memberName))); } } return toReturn; } private List<Annotation> extractInterestingAnnotations(AnnotatedElement elt) { List<Annotation> toReturn = listForAny(); for (Annotation a : elt.getAnnotations()) { if (a.annotationType().equals(Deprecated.class)) { toReturn.add(a); continue; } if (a.annotationType().getName().equals("javax.validation.Valid")) { toReturn.add(a); } // Look for JSR-303 constraints for (Annotation meta : a.annotationType().getAnnotations()) { if (meta.annotationType().getName().equals("javax.validation.Constraint")) { toReturn.add(a); } } } return toReturn.isEmpty() ? Collections.<Annotation> emptyList() : toReturn; } /** * Load the {@code package.json} file from the class's package. */ private Map<String, String> getDocStrings(Class<?> clazz) throws IOException { Map<String, String> toReturn = docStringsByPackage.get(clazz.getPackage()); if (toReturn != null) { return toReturn; } InputStream stream = clazz.getResourceAsStream("package.json"); if (stream == null) { toReturn = Collections.emptyMap(); } else { Reader reader = new InputStreamReader(stream, FlatPackTypes.UTF8); toReturn = new Gson().fromJson(reader, new TypeReference<Map<String, String>>() {}.getType()); reader.close(); } docStringsByPackage.put(clazz.getPackage(), toReturn); return toReturn; } private void reference(Class<? extends HasUuid> clazz) { if (clazz != null) { entitiesToExtract.addAll(typeHierarchy.get(clazz)); } } /** * Convert a reflection Type into FlatPack's typesystem. This method will also record any * referenced entities. */ private Type reference(java.lang.reflect.Type t) { // If t is a FlatPackEntity<Foo>, return a description of Foo java.lang.reflect.Type referencedEntityType = FlatPackTypes.getSingleParameterization(t, FlatPackEntity.class); if (referencedEntityType != null) { t = referencedEntityType; } // Ensure that the TypeContext has processed the type if (t instanceof Class<?> && HasUuid.class.isAssignableFrom((Class<?>) t)) { ctx.describe(((Class<?>) t).asSubclass(HasUuid.class)); } Type type = ctx.getCodex(t).describe(); reference(type); return type; } /** * Traverse a type, looking for references to entities. This should be a visitor. */ private void reference(Type type) { if (type.getName() != null && type.getEnumValues() == null) { EntityDescription description = ctx.getEntityDescription(type.getName()); Class<? extends HasUuid> clazz = description.getEntityType(); reference(clazz); } if (type.getListElement() != null) { reference(type.getListElement()); } if (type.getMapKey() != null) { reference(type.getMapKey()); } if (type.getMapValue() != null) { reference(type.getMapValue()); } } /** * Replace any {@literal {@link} tags in a docString with something easier for the viewer app to * deal with. */ private String replaceLinks(String docString) { if (docString == null) { return null; } // Matcher uses StringBuffer and not StringBuilder StringBuffer sb = new StringBuffer(); Matcher m = linkPattern.matcher(docString); while (m.find()) { String name = m.group(1); // TODO: Support field references, API method references EntityDescription referenced = ctx.getEntityDescription(name); if (referenced == null) { // Just append the original text if (m.group(2) != null) { m.appendReplacement(sb, m.group(2)); } else { m.appendReplacement(sb, m.group(1)); } } else { /* * This is colluding with the viewer app, but it's much simpler than re-implementing another * {@link} replacement in the viewer. */ String payloadName = referenced.getTypeName(); String displayString = m.group(2) == null ? payloadName : m.group(2); m.appendReplacement(sb, "<entityReference payloadName='" + payloadName + "'>" + displayString + "</entityReference>"); } } m.appendTail(sb); return sb.toString(); } }