package org.restdoc.server.impl;
/*
* #%L Java Server implementation %% Copyright (C) 2012 RestDoc org %% 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%
*/
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.ws.rs.BeanParam;
import javax.ws.rs.HeaderParam;
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.Response.Status;
import org.restdoc.annotations.RestDocAccept;
import org.restdoc.annotations.RestDocHeader;
import org.restdoc.annotations.RestDocIgnore;
import org.restdoc.annotations.RestDocParam;
import org.restdoc.annotations.RestDocResponse;
import org.restdoc.annotations.RestDocReturnCode;
import org.restdoc.annotations.RestDocReturnCodes;
import org.restdoc.annotations.RestDocType;
import org.restdoc.annotations.RestDocValidation;
import org.restdoc.api.GlobalHeader;
import org.restdoc.api.HeaderDefinition;
import org.restdoc.api.MethodDefinition;
import org.restdoc.api.ParamDefinition;
import org.restdoc.api.ParamValidation;
import org.restdoc.api.Representation;
import org.restdoc.api.ResponseDefinition;
import org.restdoc.api.RestDoc;
import org.restdoc.api.RestResource;
import org.restdoc.api.Schema;
import org.restdoc.api.util.RestDocParser;
import org.restdoc.server.impl.util.MediaTypeResolver;
import org.restdoc.server.impl.util.SchemaResolver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.base.Joiner;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
/**
* Use this class to generate RestDoc
*/
public class RestDocGenerator {
private static final String VALIDATION_MATCH = "match";
private static final String PATTERN_BOOL = "true|false";
private static final String PATTERN_SIGNED_DECIMAL = "[-+]?[0-9]*\\.?[0-9]+";
private static final String PATTERN_SIGNED_INT = "[-+]?[0-9]+";
private final AtomicBoolean initialized = new AtomicBoolean(false);
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private final Map<String, RestResource> resources = Maps.newHashMap();
private final Map<String, HeaderDefinition> requestHeaderMap = Maps.newConcurrentMap();
private final Map<String, HeaderDefinition> responseHeaderMap = Maps.newConcurrentMap();
private final Map<String, Schema> schemaMap = Maps.newConcurrentMap();
private final Map<String, Object> globalAdditional = Maps.newConcurrentMap();
private final RDGEWrapper ext = new RDGEWrapper();
/**
* initialize the RestDoc Generator
*
* @param classes the array of JAX-RS classes
* @param globalHeader the global headers
* @param baseURI an optional base uri like "/api"
*/
public void init(final Class<?>[] classes, final GlobalHeader globalHeader, final String baseURI) {
if (!this.initialized.compareAndSet(false, true)) {
throw new RestDocException("Generator already initialized");
}
this.logger.info("Starting generation of RestDoc");
this.logger.info("Searching for RestDoc API classes");
if (globalHeader != null) {
if (globalHeader.getRequestHeader() != null) {
this.requestHeaderMap.putAll(globalHeader.getRequestHeader());
}
if (globalHeader.getResponseHeader() != null) {
this.responseHeaderMap.putAll(globalHeader.getResponseHeader());
}
if ((globalHeader.getAdditionalFields() != null) && !globalHeader.getAdditionalFields().isEmpty()) {
this.globalAdditional.putAll(globalHeader.getAdditionalFields());
}
}
for (final Class<?> apiClass : classes) {
// check if class provides predefined RestDoc
boolean scanNeeded = true;
if (Arrays.asList(apiClass.getInterfaces()).contains(IProvideRestDoc.class)) {
try {
this.logger.info("Class {} provides predefined RestDoc", apiClass.getCanonicalName());
final IProvideRestDoc apiObject = (IProvideRestDoc) apiClass.newInstance();
final RestResource[] restDocResources = apiObject.getRestDocResources();
for (final RestResource restResource : restDocResources) {
this.resources.put(restResource.getPath(), restResource);
}
this.schemaMap.putAll(apiObject.getRestDocSchemas());
scanNeeded = false;
} catch (final Exception e) {
// ignore it and fall back to annotation scan
}
}
if (scanNeeded) {
this.addResourcesOfClass(apiClass, baseURI);
}
}
}
private void addResourcesOfClass(final Class<?> apiClass, final String baseURI) {
this.logger.info("Scanning class: {}", apiClass.getCanonicalName());
String basepath = baseURI != null ? baseURI : "";
if (apiClass.isAnnotationPresent(Path.class)) {
final Path path = apiClass.getAnnotation(Path.class);
basepath += path.value();
}
// find methods
final Method[] methods = apiClass.getMethods();
for (final Method method : methods) {
if (method.isAnnotationPresent(Path.class) || (RestDocGenerator.getHTTPVerb(method) != null)) {
this.logger.debug("Generating RestDoc of method: " + method.toString());
this.addResourceMethod(basepath, method);
}
}
}
private void addResourceMethod(final String basepath, final Method method) {
if (method.isAnnotationPresent(RestDocIgnore.class)) {
this.logger.info("Ignoring method: " + method);
return;
}
// get needed annotations from method
final String methodType = RestDocGenerator.getHTTPVerb(method);
final org.restdoc.annotations.RestDoc docAnnotation = method.getAnnotation(org.restdoc.annotations.RestDoc.class);
final Path pathAnnotation = method.getAnnotation(Path.class);
String path = basepath;
if (pathAnnotation != null) {
path += pathAnnotation.value();
}
if ((methodType == null) && (pathAnnotation != null)) {
this.addResourcesOfClass(method.getReturnType(), path);
return;
}
// get parameter
final Type[] parameterTypes = method.getGenericParameterTypes();
final Annotation[][] parameterAnnotations = method.getParameterAnnotations();
// values from parameters
final List<String> queryParams = Lists.newArrayList();
final HashMap<String, HeaderDefinition> methodRequestHeader = Maps.newHashMap();
final HashMap<String, ParamDefinition> methodParams = Maps.newHashMap();
for (int i = 0; i < parameterTypes.length; i++) {
this.parseMethodParameter(queryParams, methodRequestHeader, methodParams, parameterTypes[i], parameterAnnotations[i]);
}
for (final String string : queryParams) {
path += "{?" + string + "}";
}
final String id;
if ((docAnnotation != null) && (docAnnotation.id() != null) && !docAnnotation.id().isEmpty()) {
id = docAnnotation.id();
} else {
id = this.getDefaultResourceId(method, path);
}
final String resourceDescription;
if (docAnnotation == null) {
if (method.getDeclaringClass().isAnnotationPresent(org.restdoc.annotations.RestDoc.class)) {
org.restdoc.annotations.RestDoc doc = method.getDeclaringClass().getAnnotation(org.restdoc.annotations.RestDoc.class);
resourceDescription = doc.resourceDescription();
} else {
resourceDescription = null;
}
} else {
resourceDescription = docAnnotation.resourceDescription();
}
final String methodDescription = (docAnnotation != null) ? docAnnotation.methodDescription() : method.getName();
if (methodType == null) {
throw new RestDocException("No suitable method found for method: " + method.toString());
}
RestResource restResource = this.resources.get(path);
if (restResource == null) {
restResource = new RestResource();
restResource.setPath(path);
this.resources.put(path, restResource);
}
if ((restResource.getId() == null) || restResource.getId().isEmpty()) {
restResource.setId(id);
restResource.setDescription(resourceDescription);
restResource.getParams().putAll(methodParams);
this.ext.newResource(restResource);
}
final MethodDefinition def;
if (!restResource.getMethods().containsKey(methodType)) {
def = new MethodDefinition();
def.setDescription(methodDescription);
def.setResponse(new ResponseDefinition());
} else {
def = restResource.getMethods().get(methodType);
}
def.getHeaders().putAll(methodRequestHeader);
def.getAccepts().addAll(this.getAccepts(method, parameterTypes, parameterAnnotations));
def.getStatusCodes().putAll(this.getStatusCodes(method));
this.addMethodResponse(def.getResponse(), method);
restResource.getMethods().put(methodType, def);
this.ext.newMethod(restResource, def, method);
}
private String getDefaultResourceId(final Method method, String path) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(path.getBytes("UTF-8"));
BigInteger bigInt = new BigInteger(1, digest);
String hashtext = bigInt.toString(16);
// Now we need to zero pad it if you actually want the full 32 chars.
while (hashtext.length() < 32) {
hashtext = "0" + hashtext;
}
return hashtext;
} catch (NoSuchAlgorithmException e) {
this.logger.warn("Failed to generate MD5 sum", e);
} catch (UnsupportedEncodingException e) {
this.logger.warn("Failed to generate MD5 sum", e);
}
return method.getName();
}
/**
* @param queryParams
* @param methodRequestHeader
* @param methodParams
* @param paramType
* @param paramAnnotations
*/
protected void parseMethodParameter(final List<String> queryParams, final Map<String, HeaderDefinition> methodRequestHeader, final Map<String, ParamDefinition> methodParams, final Type paramType, final Annotation[] paramAnnotations) {
final HeaderDefinition headerDefinition = new HeaderDefinition();
final AnnotationMap map = new AnnotationMap(paramAnnotations);
if (map.hasAnnotation(QueryParam.class)) {
final String name = map.getAnnotation(QueryParam.class).value();
queryParams.add(name);
final ParamDefinition definition = new ParamDefinition();
if (map.hasAnnotation(RestDocParam.class)) {
this.parseRestDocParameter(definition, map.getAnnotation(RestDocParam.class), paramType);
}
this.ext.queryParam(name, definition, paramType, map);
methodParams.put(name, definition);
} else if (map.hasAnnotation(PathParam.class)) {
final String name = map.getAnnotation(PathParam.class).value();
final ParamDefinition definition = new ParamDefinition();
if (map.hasAnnotation(RestDocParam.class)) {
this.parseRestDocParameter(definition, map.getAnnotation(RestDocParam.class), paramType);
}
this.ext.pathParam(name, definition, paramType, map);
methodParams.put(name, definition);
} else if (map.hasAnnotation(HeaderParam.class)) {
final String name = map.getAnnotation(HeaderParam.class).value();
final HeaderDefinition definition = new HeaderDefinition();
if (!this.requestHeaderMap.containsKey(name)) {
if (map.hasAnnotation(RestDocHeader.class)) {
final RestDocHeader docHeader = map.getAnnotation(RestDocHeader.class);
headerDefinition.setDescription(docHeader.description());
headerDefinition.setRequired(docHeader.required());
}
this.ext.headerParam(name, definition, paramType, map);
methodRequestHeader.put(name, headerDefinition);
}
} else if (map.hasAnnotation(BeanParam.class)) {
if (paramType instanceof Class) {
Class<?> beanParamClass = (Class<?>) paramType;
Field[] fields = beanParamClass.getDeclaredFields();
for (Field f : fields) {
this.parseMethodParameter(queryParams, methodRequestHeader, methodParams, f.getType(), f.getAnnotations());
}
}
} else {
// Param is body type
}
}
private void addMethodResponse(final ResponseDefinition def, final Method method) {
boolean typeFound = false;
if (method.isAnnotationPresent(RestDocResponse.class)) {
final RestDocResponse docResponse = method.getAnnotation(RestDocResponse.class);
final RestDocType[] types = docResponse.types();
for (final RestDocType restDocType : types) {
if (!restDocType.schemaClass().equals(Object.class)) {
final String schema = SchemaResolver.getSchemaFromType(restDocType.schemaClass(), this.schemaMap, this.ext);
def.type(restDocType.type(), schema);
typeFound = true;
} else {
def.type(restDocType.type(), restDocType.schema());
typeFound = true;
}
}
final RestDocHeader[] headers = docResponse.headers();
for (final RestDocHeader restDocHeader : headers) {
def.header(restDocHeader.name(), restDocHeader.description(), restDocHeader.required());
}
}
if (!typeFound && !method.getReturnType().equals(Void.TYPE)) {
final String schema = SchemaResolver.getSchemaFromTypeOrNull(method.getGenericReturnType(), this.schemaMap, this.ext);
String[] mediaTypes = MediaTypeResolver.getProducesMediaType(method);
if (mediaTypes != null) {
for (String mt : mediaTypes) {
def.type(mt, schema);
}
}
}
}
private Map<String, String> getStatusCodes(final Method method) {
final Map<String, String> codeMap = Maps.newHashMap();
if (method.isAnnotationPresent(RestDocReturnCodes.class)) {
final RestDocReturnCode[] returnCodes = method.getAnnotation(RestDocReturnCodes.class).value();
for (final RestDocReturnCode rdrc : returnCodes) {
String description = rdrc.description();
if (description.isEmpty()) {
description = Status.fromStatusCode(Integer.valueOf(rdrc.code())).getReasonPhrase();
}
codeMap.put(rdrc.code(), description);
}
}
if (method.getReturnType().equals(Void.TYPE)) {
codeMap.put(String.valueOf(Status.NO_CONTENT.getStatusCode()), Status.NO_CONTENT.getReasonPhrase());
}
return codeMap;
}
@SuppressWarnings("unchecked")
private Collection<Representation> getAccepts(final Method method, Type[] parameterTypes, Annotation[][] parameterAnnotations) {
final Collection<Representation> list = Lists.newArrayList();
if (method.isAnnotationPresent(RestDocAccept.class)) {
final RestDocAccept docAccept = method.getAnnotation(RestDocAccept.class);
final RestDocType[] types = docAccept.value();
for (final RestDocType restDocType : types) {
if (!restDocType.schemaClass().equals(Object.class)) {
final String schema = SchemaResolver.getSchemaFromType(restDocType.schemaClass(), this.schemaMap, this.ext);
list.add(new Representation(restDocType.type(), schema));
} else {
list.add(new Representation(restDocType.type(), restDocType.schema()));
}
}
} else {
String[] mediaTypes = MediaTypeResolver.getConsumesMediaType(method);
if (mediaTypes != null) {
for (int i = 0; i < parameterTypes.length; i++) {
Type param = parameterTypes[i];
final AnnotationMap map = new AnnotationMap(parameterAnnotations[i]);
if (!map.hasAnnotation(PathParam.class, QueryParam.class, HeaderParam.class)) {
final String schema = SchemaResolver.getSchemaFromType(param, this.schemaMap, this.ext);
for (String mt : mediaTypes) {
list.add(new Representation(mt, schema));
}
}
}
}
}
return list;
}
private void parseRestDocParameter(final ParamDefinition definition, final RestDocParam docParam, final Type paramType) {
definition.setDescription(docParam.description());
final RestDocValidation[] restDocValidations = docParam.validations();
for (final RestDocValidation validation : restDocValidations) {
final ParamValidation v = new ParamValidation();
v.setType(validation.type());
v.setPattern(validation.pattern());
definition.getValidations().add(v);
}
if (paramType.equals(Long.class)) {
definition.getValidations().add(new ParamValidation(RestDocGenerator.VALIDATION_MATCH, RestDocGenerator.PATTERN_SIGNED_INT));
} else if (paramType.equals(Integer.class)) {
definition.getValidations().add(new ParamValidation(RestDocGenerator.VALIDATION_MATCH, RestDocGenerator.PATTERN_SIGNED_INT));
} else if (paramType.equals(Double.class)) {
definition.getValidations().add(new ParamValidation(RestDocGenerator.VALIDATION_MATCH, RestDocGenerator.PATTERN_SIGNED_DECIMAL));
} else if (paramType.equals(BigDecimal.class)) {
definition.getValidations().add(new ParamValidation(RestDocGenerator.VALIDATION_MATCH, RestDocGenerator.PATTERN_SIGNED_DECIMAL));
} else if (paramType.equals(Boolean.class)) {
definition.getValidations().add(new ParamValidation(RestDocGenerator.VALIDATION_MATCH, RestDocGenerator.PATTERN_BOOL));
} else if ((paramType instanceof Class) && ((Class<?>) paramType).isEnum()) {
List<?> values = Arrays.asList(((Class<?>) paramType).getEnumConstants());
String join = Joiner.on('|').join(values);
definition.getValidations().add(new ParamValidation(RestDocGenerator.VALIDATION_MATCH, join));
}
}
private static String getHTTPVerb(final Method method) {
final Annotation[] annotations = method.getAnnotations();
for (final Annotation annotation : annotations) {
if (annotation.annotationType().isAnnotationPresent(HttpMethod.class)) {
final HttpMethod httpMethod = annotation.annotationType().getAnnotation(HttpMethod.class);
return httpMethod.value();
}
}
return null;
}
// ######################################################
// Retrieving RestDoc for given path
// ######################################################
/**
* @param path the basepath to start
* @return the {@link RestDoc} as string
* @throws RestDocException on generation error
*/
public String getRestDocStringForPath(final String path) {
if (!this.initialized.get()) {
throw new RestDocException("Generator is not yet initialized");
}
try {
final RestDoc doc = this.getDoc(path);
return RestDocParser.writeRestDoc(doc);
} catch (final IOException e) {
throw new RestDocException(e);
}
}
private RestDoc getDoc(final String path) {
final RestDoc doc = new RestDoc();
// populate schemas
doc.setSchemas(new HashMap<String, Schema>(this.schemaMap));
// populate header section
doc.getHeaders().getRequestHeader().putAll(this.requestHeaderMap);
doc.getHeaders().getResponseHeader().putAll(this.responseHeaderMap);
doc.getHeaders().getAdditionalFields().putAll(this.globalAdditional);
// populate resource section
final Set<Entry<String, RestResource>> entrySet = this.resources.entrySet();
for (final Entry<String, RestResource> entry : entrySet) {
if (entry.getKey().startsWith(path)) {
doc.getResources().add(entry.getValue());
}
}
this.ext.renderDoc(path, doc);
return doc;
}
// ##############################################
// Extension registry and wrapper
// ##############################################
/**
* @param extension the {@link IRestDocGeneratorExtension} to register
*/
public void registerGeneratorExtension(final IRestDocGeneratorExtension extension) {
this.ext.exts.add(extension);
}
private class RDGEWrapper implements IRestDocGeneratorExtension {
private final List<IRestDocGeneratorExtension> exts = new LinkedList<IRestDocGeneratorExtension>();
@Override
public void newResource(final RestResource restResource) {
for (final IRestDocGeneratorExtension e : this.exts) {
e.newResource(restResource);
}
}
@Override
public void queryParam(final String name, final ParamDefinition definition, final Type paramType, final AnnotationMap map) {
for (final IRestDocGeneratorExtension e : this.exts) {
e.queryParam(name, definition, paramType, map);
}
}
@Override
public void pathParam(final String name, final ParamDefinition definition, final Type paramType, final AnnotationMap map) {
for (final IRestDocGeneratorExtension e : this.exts) {
e.pathParam(name, definition, paramType, map);
}
}
@Override
public void headerParam(final String name, final HeaderDefinition definition, final Type paramType, final AnnotationMap map) {
for (final IRestDocGeneratorExtension e : this.exts) {
e.headerParam(name, definition, paramType, map);
}
}
@Override
public void newMethod(final RestResource restResource, final MethodDefinition def, final Method method) {
for (final IRestDocGeneratorExtension e : this.exts) {
e.newMethod(restResource, def, method);
}
}
@Override
public void newSchema(final String schemaURI, final Schema s, final Class<?> schemaClass) {
for (final IRestDocGeneratorExtension e : this.exts) {
e.newSchema(schemaURI, s, schemaClass);
}
}
@Override
public void renderDoc(final String path, final RestDoc doc) {
for (final IRestDocGeneratorExtension e : this.exts) {
e.renderDoc(path, doc);
}
}
}
}