/**
* This file is part of Graylog.
*
* Graylog is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Graylog is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Graylog. If not, see <http://www.gnu.org/licenses/>.
*/
package org.graylog2.shared.rest.documentation.generator;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.module.jsonSchema.JsonSchema;
import com.fasterxml.jackson.module.jsonSchema.factories.SchemaFactoryWrapper;
import com.google.common.collect.ComparisonChain;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.primitives.Primitives;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
import org.graylog2.shared.ServerVersion;
import org.reflections.Reflections;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
import javax.ws.rs.DELETE;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.FormParam;
import javax.ws.rs.GET;
import javax.ws.rs.HEAD;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.OPTIONS;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static com.google.common.base.Strings.isNullOrEmpty;
import static com.google.common.base.Strings.nullToEmpty;
/**
* This is generating API information in <a href="http://swagger.io/">Swagger</a> format.
*
* We decided to write this ourselves and not to use the Swagger JAX-RS/Jersey integration
* because it was not compatible to Jersey2 at that point and just way too complicated
* and too big for what we want to do with it.
*/
public class Generator {
private static final Logger LOG = LoggerFactory.getLogger(Generator.class);
public static final String EMULATED_SWAGGER_VERSION = "1.2";
private static Map<String, Object> overviewResult = Maps.newHashMap();
private static Reflections reflections;
private final Map<Class<?>, String> pluginMapping;
private final String pluginPathPrefix;
private final ObjectMapper mapper;
public Generator(Set<String> packageNames, Map<Class<?>, String> pluginMapping, String pluginPathPrefix, ObjectMapper mapper) {
this.pluginMapping = pluginMapping;
this.pluginPathPrefix = pluginPathPrefix;
this.mapper = mapper;
if (reflections == null) {
reflections = new Reflections(packageNames.toArray(),
pluginMapping.keySet().stream().map(Class::getClassLoader).collect(Collectors.toSet()));
}
}
public Generator(String packageName, ObjectMapper mapper) {
this(ImmutableSet.of(packageName), ImmutableMap.of(), "", mapper);
}
private String prefixedPath(Class<?> resourceClass, @Nullable String resourceAnnotationPath) {
final String resourcePath = nullToEmpty(resourceAnnotationPath);
final StringBuilder prefixedPath = new StringBuilder();
if (pluginMapping.containsKey(resourceClass)) {
prefixedPath.append(pluginPathPrefix);
prefixedPath.append("/");
prefixedPath.append(pluginMapping.get(resourceClass));
}
if (!resourcePath.startsWith("/")) {
prefixedPath.append("/");
}
return prefixedPath.append(resourcePath).toString();
}
public synchronized Map<String, Object> generateOverview() {
if (!overviewResult.isEmpty()) {
return overviewResult;
}
final List<Map<String, Object>> apis = Lists.newArrayList();
for (Class<?> clazz : getAnnotatedClasses()) {
Api info = clazz.getAnnotation(Api.class);
Path path = clazz.getAnnotation(Path.class);
if (info == null || path == null) {
LOG.debug("Skipping REST resource with no Api or Path annotation: <{}>", clazz.getCanonicalName());
continue;
}
final String prefixedPath = prefixedPath(clazz, path.value());
final Map<String, Object> apiDescription = Maps.newHashMap();
apiDescription.put("name", prefixedPath.startsWith(pluginPathPrefix) ? "Plugins/" + info.value() : info.value());
apiDescription.put("path", prefixedPath);
apiDescription.put("description", info.description());
apis.add(apiDescription);
}
Collections.sort(apis, (o1, o2) -> ComparisonChain.start().compare(o1.get("name").toString(), o2.get("name").toString()).result());
Map<String, String> info = Maps.newHashMap();
info.put("title", "Graylog REST API");
overviewResult.put("apiVersion", ServerVersion.VERSION.toString());
overviewResult.put("swaggerVersion", EMULATED_SWAGGER_VERSION);
overviewResult.put("apis", apis);
return overviewResult;
}
public Set<Class<?>> getAnnotatedClasses() {
return reflections.getTypesAnnotatedWith(Api.class);
}
public Map<String, Object> generateForRoute(String route, String basePath) {
Map<String, Object> result = Maps.newHashMap();
Set<Class<?>> modelTypes = Sets.newHashSet();
List<Map<String, Object>> apis = Lists.newArrayList();
for (Class<?> clazz : getAnnotatedClasses()) {
Path path = clazz.getAnnotation(Path.class);
if (path == null) {
LOG.debug("Skipping REST resource with no Api or Path annotation: <{}>", clazz.getCanonicalName());
continue;
}
final String prefixedPath = prefixedPath(clazz, path.value());
if (cleanRoute(route).equals(cleanRoute(prefixedPath))) {
// This is the class representing the given route. Get all methods.
LOG.debug("Found corresponding REST resource class: <{}>", clazz.getCanonicalName());
Method[] methods = clazz.getDeclaredMethods();
if (methods == null || methods.length == 0) {
LOG.debug("REST resource <{}> has no methods. Skipping.", clazz.getCanonicalName());
break;
}
for (Method method : methods) {
if (!method.isAnnotationPresent(ApiOperation.class)) {
LOG.debug("Method <{}> has no ApiOperation annotation. Skipping.", method.toGenericString());
continue;
}
ApiOperation apiOperation = method.getAnnotation(ApiOperation.class);
Map<String, Object> api = Maps.newHashMap();
List<Map<String, Object>> operations = Lists.newArrayList();
String methodPath;
if (method.isAnnotationPresent(Path.class)) {
// Method has annotated Path.
methodPath = cleanRoute(method.getAnnotation(Path.class).value());
if (clazz.isAnnotationPresent(Path.class)) {
// The class has a Path, too. Prepend.
String classPath = cleanRoute(prefixedPath(clazz, clazz.getAnnotation(Path.class).value()));
methodPath = classPath + methodPath;
}
} else {
// Method has no annotated Path. We read from it's class.
if (clazz.isAnnotationPresent(Path.class)) {
methodPath = cleanRoute(prefixedPath(clazz, clazz.getAnnotation(Path.class).value()));
} else {
LOG.debug("Method <{}> has no Path annotation. Skipping.", method.toGenericString());
continue;
}
}
Produces produces = null;
if (clazz.isAnnotationPresent(Produces.class) || method.isAnnotationPresent(Produces.class)) {
produces = clazz.getAnnotation(Produces.class);
if (method.isAnnotationPresent(Produces.class)) {
produces = method.getAnnotation(Produces.class);
}
}
api.put("path", methodPath);
Map<String, Object> operation = Maps.newHashMap();
operation.put("method", determineHttpMethod(method));
operation.put("summary", apiOperation.value());
operation.put("notes", apiOperation.notes());
operation.put("nickname", method.getName());
if (produces != null) {
operation.put("produces", produces.value());
}
// skip Response.class because we can't reliably infer any schema information from its payload anyway.
if (!method.getReturnType().isAssignableFrom(Response.class)) {
operation.put("type", method.getReturnType().getSimpleName());
modelTypes.add(method.getReturnType());
}
List<Parameter> parameters = determineParameters(method);
if (parameters != null && !parameters.isEmpty()) {
operation.put("parameters", parameters);
}
for (Parameter parameter : parameters) {
final Class type = parameter.getType();
if (Primitives.unwrap(type).isPrimitive() || type.equals(String.class)) {
continue;
}
modelTypes.add(type);
}
operation.put("responseMessages", determineResponses(method));
operations.add(operation);
api.put("operations", operations);
apis.add(api);
}
}
}
if (basePath.endsWith("/")) {
basePath = basePath.substring(0, basePath.length() - 1);
}
Collections.sort(apis, (o1, o2) -> ComparisonChain.start()
.compare(o1.get("path").toString(), o2.get("path").toString())
.result());
// generate the json schema for the auto-mapped return types
Map<String, Object> models = Maps.newHashMap();
for (Class<?> type : modelTypes) {
// skip non-jackson mapped classes (like Response)
if (!type.isAnnotationPresent(JsonAutoDetect.class)) {
continue;
}
try {
SchemaFactoryWrapper visitor = new SchemaFactoryWrapper();
mapper.acceptJsonFormatVisitor(mapper.constructType(type), visitor);
final JsonSchema schema = visitor.finalSchema();
models.put(type.getSimpleName(), schema);
} catch (JsonMappingException e) {
LOG.error("Error generating model schema. Ignoring this model, this will likely break the API browser.", e);
}
}
result.put("apis", apis);
result.put("basePath", basePath);
result.put("models", models);
result.put("resourcePath", cleanRoute(route));
result.put("apiVersion", ServerVersion.VERSION.toString());
result.put("swaggerVersion", EMULATED_SWAGGER_VERSION);
return result;
}
private List<Parameter> determineParameters(Method method) {
final List<Parameter> params = Lists.newArrayList();
int i = 0;
for (Annotation[] annotations : method.getParameterAnnotations()) {
final Parameter param = new Parameter();
Parameter.Kind paramKind = Parameter.Kind.BODY;
for (Annotation annotation : annotations) {
if (annotation instanceof ApiParam) {
final ApiParam apiParam = (ApiParam) annotation;
param.setName(apiParam.name());
param.setDescription(apiParam.value());
param.setIsRequired(apiParam.required());
param.setType(method.getGenericParameterTypes()[i]);
if (!isNullOrEmpty(apiParam.defaultValue())) {
param.setDefaultValue(apiParam.defaultValue());
}
}
if (annotation instanceof DefaultValue) {
final DefaultValue defaultValueAnnotation = (DefaultValue) annotation;
// Only set if empty to make sure ApiParam's defaultValue has precedence!
if (isNullOrEmpty(param.getDefaultValue()) && !isNullOrEmpty(defaultValueAnnotation.value())) {
param.setDefaultValue(defaultValueAnnotation.value());
}
}
if (annotation instanceof QueryParam) {
paramKind = Parameter.Kind.QUERY;
} else if (annotation instanceof PathParam) {
paramKind = Parameter.Kind.PATH;
} else if (annotation instanceof HeaderParam) {
paramKind = Parameter.Kind.HEADER;
} else if (annotation instanceof FormParam) {
paramKind = Parameter.Kind.FORM;
}
}
param.setKind(paramKind);
if (param.getType() != null) {
params.add(param);
}
i++;
}
return params;
}
private List<Map<String, Object>> determineResponses(Method method) {
final List<Map<String, Object>> result = Lists.newArrayList();
final ApiResponses annotation = method.getAnnotation(ApiResponses.class);
if (null != annotation) {
for (ApiResponse response : annotation.value()) {
final Map<String, Object> responseDescription = ImmutableMap.<String, Object>of(
"code", response.code(),
"message", response.message());
result.add(responseDescription);
}
}
return result;
}
// Leading slash but no trailing.
private String cleanRoute(String route) {
if (!route.startsWith("/")) {
route = "/" + route;
}
if (route.endsWith("/")) {
route = route.substring(0, route.length() - 1);
}
return route;
}
@Nullable
private String determineHttpMethod(Method m) {
if (m.isAnnotationPresent(GET.class)) {
return "GET";
}
if (m.isAnnotationPresent(POST.class)) {
return "POST";
}
if (m.isAnnotationPresent(PUT.class)) {
return "PUT";
}
if (m.isAnnotationPresent(DELETE.class)) {
return "DELETE";
}
if (m.isAnnotationPresent(HEAD.class)) {
return "HEAD";
}
if (m.isAnnotationPresent(OPTIONS.class)) {
return "OPTIONS";
}
return null;
}
public static class Parameter {
private String name;
private String description;
private boolean isRequired;
private Class type;
private Kind kind;
private String defaultValue;
public void setName(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setDescription(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
public void setIsRequired(boolean required) {
isRequired = required;
}
public boolean isRequired() {
return isRequired;
}
public void setRequired(boolean required) {
isRequired = required;
}
public void setType(Type type) {
final Class<?> klass;
if (type instanceof ParameterizedType) {
klass = (Class<?>) ((ParameterizedType) type).getRawType();
} else {
klass = (Class<?>) type;
}
if (klass.isPrimitive()) {
this.type = Primitives.wrap(klass);
} else {
this.type = klass;
}
}
@JsonIgnore
public Class getType() {
return type;
}
@JsonProperty("type")
public String getTypeName() {
return type.getSimpleName();
}
public void setKind(Kind kind) {
this.kind = kind;
}
@JsonProperty("paramType")
public String getKind() {
return kind.toString().toLowerCase(Locale.ENGLISH);
}
public void setDefaultValue(String defaultValue) {
this.defaultValue = defaultValue;
}
@JsonProperty("defaultValue")
public String getDefaultValue() {
return this.defaultValue;
}
public enum Kind {
BODY,
HEADER,
PATH,
QUERY,
FORM
}
}
}