/**
* Licensed to The Apereo Foundation under one or more contributor license
* agreements. See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
*
*
* The Apereo Foundation licenses this file to you under the Educational
* Community 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://opensource.org/licenses/ecl2.txt
*
* 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.
*
*/
package org.opencastproject.runtimeinfo.rest;
import org.opencastproject.util.doc.DocData;
import org.opencastproject.util.doc.rest.RestParameter;
import org.opencastproject.util.doc.rest.RestQuery;
import org.opencastproject.util.doc.rest.RestResponse;
import org.apache.commons.beanutils.BeanUtils;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Vector;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
/**
* This is the document model class which holds the data about a set of rest endpoints.
*/
public class RestDocData extends DocData {
/**
* The REGEX pattern used to find the macros in the REST documentation.
*/
private static final String REST_DOC_MACRO_PATTERN = "\\$\\{(.+?)\\}";
/**
* The name to identify the endpoint holder for read endpoints (get/head).
*/
private static final String READ_ENDPOINT_HOLDER_NAME = "READ";
/**
* The name to identify the endpoint holder for write endpoints (delete,post,put).
*/
private static final String WRITE_ENDPOINT_HOLDER_NAME = "WRITE";
/**
* Regular expression used to count the number of path parameters in a path.
*/
public static final String PATH_PARAM_COUNTING_REGEX = "\\{(.+?)\\}";
/**
* Regular expression used to validate a path.
*/
// FIXME: This regex doesn't match all valid paths that can occur in rest endpoint @Path;
public static final String PATH_VALIDATION_REGEX = "^[\\w\\/{}|\\:\\.\\*\\+|\\[\\w-\\w\\]\\+]+$";
/**
* A slash character.
*/
public static final String SLASH = "/";
/**
* List of RestEndpointHolderData which each stores a group of endpoints. Currently there are 2 groups, READ group and
* WRITE group.
*/
protected List<RestEndpointHolderData> holders;
/**
* The service object which this RestDocData is about.
*/
private Object serviceObject = null;
/**
* A map of macro values for REST documentation.
*/
private Map<String, String> macros;
/**
* Create the base data object for creating REST documentation.
*
* @param name
* the name of the set of rest endpoints (must be alphanumeric (includes _) and no spaces or special chars)
* @param title
* [OPTIONAL] the title of the documentation
* @param url
* this is the absolute base URL for this endpoint, do not include the trailing slash (e.g. /workflow)
* @param notes
* [OPTIONAL] an array of notes to add into the end of the documentation
* @throws IllegalArgumentException
* if the url is null or empty
*/
public RestDocData(String name, String title, String url, String[] notes, Object service,
Map<String, String> globalMacro) throws IllegalArgumentException {
super(name, title, notes);
if (url == null || "".equals(url)) {
throw new IllegalArgumentException("URL cannot be blank.");
}
meta.put("url", url);
serviceObject = service;
macros = globalMacro;
// create the endpoint holders
holders = new Vector<RestEndpointHolderData>(2);
holders.add(new RestEndpointHolderData(READ_ENDPOINT_HOLDER_NAME, "Read"));
holders.add(new RestEndpointHolderData(WRITE_ENDPOINT_HOLDER_NAME, "Write"));
}
/**
* Verify the integrity of this object. If its data is verified to be okay, it return a map representation of this
* RestDocData object.
*
* @return a map representation of this RestDocData object if this object passes the verification
*
* @throws IllegalStateException
* if any path parameter is not present in the endpoint's path
*/
@Override
public Map<String, Object> toMap() throws IllegalStateException {
LinkedHashMap<String, Object> m = new LinkedHashMap<String, Object>();
m.put("meta", meta);
m.put("notes", notes);
// only pass through the holders with things in them
ArrayList<RestEndpointHolderData> holdersList = new ArrayList<RestEndpointHolderData>();
for (RestEndpointHolderData holder : holders) {
if (!holder.getEndpoints().isEmpty()) {
for (RestEndpointData endpoint : holder.getEndpoints()) {
// Validate the endpoint path matches the specified path parameters.
// First, it makes sure that every path parameter is present in the endpoint's path.
if (!endpoint.getPathParams().isEmpty()) {
for (RestParamData param : endpoint.getPathParams()) {
// Some endpoints allow for arbitrary characters, including slashes, in their path parameters, so we
// must check for both {param} and {param:.*}.
if (!endpoint.getPath().contains("{" + param.getName() + "}")
&& !endpoint.getPath().contains("{" + param.getName() + ":")) {
throw new IllegalStateException("Path (" + endpoint.getPath() + ") does not match path parameter ("
+ param.getName() + ") for endpoint (" + endpoint.getName()
+ "), the path must contain all path parameter names.");
}
}
}
// Then, it makes sure that the number of path parameter patterns in the path is the same as the number of
// path parameters in the endpoint.
// The following part uses a regular expression to find patterns like {something}.
Pattern pattern = Pattern.compile(PATH_PARAM_COUNTING_REGEX);
Matcher matcher = pattern.matcher(endpoint.getPath());
int count = 0;
while (matcher.find()) {
count++;
}
if (count != endpoint.getPathParams().size()) {
throw new IllegalStateException("Path (" + endpoint.getPath() + ") does not match path parameters ("
+ endpoint.getPathParams() + ") for endpoint (" + endpoint.getName()
+ "), the path must contain the same number of path parameters (" + count
+ ") as the pathParams list (" + endpoint.getPathParams().size() + ").");
}
}
holdersList.add(holder);
}
}
m.put("endpointHolders", holdersList);
return m;
}
/**
* Gets the path to the default template (a .xhtml file).
*
* @return the path to the default template file
*/
@Override
public String getDefaultTemplatePath() {
return TEMPLATE_DEFAULT;
}
/**
* Returns a string representation of this object.
*
* @return a string representation of this object
*/
@Override
public String toString() {
return "DOC:meta=" + meta + ", notes=" + notes + ", " + holders;
}
/**
* Add an endpoint to this documentation using and assign it to the correct type group (read/write).
*
* @param type
* the type of this endpoint (RestEndpointData.Type.READ or RestEndpointData.Type.WRITE)
* @param endpoint
* the endpoint to be added
* @throws IllegalStateException
* if the endpoint cannot be assigned to a group
*/
private void addEndpoint(String type, RestEndpointData endpoint) throws IllegalStateException {
RestEndpointHolderData currentHolder = null;
for (RestEndpointHolderData holder : holders) {
if (type.equalsIgnoreCase(holder.getName())) {
currentHolder = holder;
break;
}
}
if (currentHolder == null) {
throw new IllegalStateException("Could not find holder of type: " + type + ".");
}
currentHolder.addEndPoint(endpoint);
}
/**
* Creates an abstract section which is displayed at the top of the documentation page.
*
* @param abstractText
* any text to place at the top of the document, can be html markup but must be valid
*/
public void setAbstract(String abstractText) {
if (isBlank(abstractText)) {
meta.remove("abstract");
} else {
meta.put("abstract", abstractText);
}
}
/**
* Validates paths: VALID: /sample , /sample/{thing} , /{my}/{path}.xml , /my/fancy_path/is/{awesome}.{FORMAT}
* INVALID: sample, /sample/, /sa#$%mple/path
*
* @param path
* the path value to check
* @return true if this path is valid, false otherwise
*/
public static boolean isValidPath(String path) {
boolean valid = true;
if (isBlank(path)) {
valid = false;
} else {
if (SLASH.equals(path)) {
valid = true;
} else if (path.endsWith(SLASH) || !path.startsWith(SLASH)) {
valid = false;
} else {
valid = path.matches(PATH_VALIDATION_REGEX);
}
}
return valid;
}
/**
* Takes a string and replaces any REST doc macros in it with the corresponding values.
*
* @param value
* the string to check
* @return a string where all the macros are replaced by the corresponding values
*/
public String processMacro(String value) {
Pattern pattern = Pattern.compile(REST_DOC_MACRO_PATTERN);
Matcher matcher = pattern.matcher(value);
StringBuilder sb = new StringBuilder();
int begin = 0;
while (matcher.find()) {
sb.append(value.substring(begin, matcher.start()));
String macro = matcher.group(1);
// All macros that start with "this." is a "local" macro.
if (macro.startsWith("this.")) {
try {
sb.append(BeanUtils.getProperty(serviceObject, macro.substring(5)));
} catch (Exception e) {
// If there is any problem (e.g. the property cannot be found), the macro would be displayed directly.
sb.append(matcher.group());
}
} else {
if (macros.containsKey(matcher.group(1))) {
sb.append(macros.get(matcher.group(1)));
} else {
// If the macro cannot be found in the list of global macro, it would be displayed directly.
sb.append(matcher.group());
}
}
begin = matcher.end();
}
sb.append(value.substring(begin, value.length()));
return sb.toString();
}
/**
* Add an endpoint to the Rest documentation.
*
* @param restQuery
* the RestQuery annotation type storing information of an endpoint
* @param returnType
* the return type for this endpoint. If this is {@link javax.xml.bind.annotation.XmlRootElement} or
* {@link javax.xml.bind.annotation.XmlRootElement}, the XML schema for the class will be made available to
* clients
* @param produces
* the return type(s) of this endpoint, values should be constants from <a
* href="http://jackson.codehaus.org/javadoc/jax-rs/1.0/javax/ws/rs/core/MediaType.html"
* >javax.ws.rs.core.MediaType</a> or ExtendedMediaType
* (org.opencastproject.util.doc.rest.ExtendedMediaType).
* @param httpMethodString
* the HTTP method of this endpoint (e.g. GET, POST)
* @param path
* the path of this endpoint
*/
public void addEndpoint(RestQuery restQuery, Class<?> returnType, Produces produces, String httpMethodString,
Path path) {
String pathValue = path.value().startsWith("/") ? path.value() : "/" + path.value();
RestEndpointData endpoint = new RestEndpointData(returnType, this.processMacro(restQuery.name()), httpMethodString,
pathValue, processMacro(restQuery.description()));
// Add return description if needed
if (!restQuery.returnDescription().isEmpty()) {
endpoint.addNote("Return value description: " + processMacro(restQuery.returnDescription()));
}
// Add formats
if (produces != null) {
for (String format : produces.value()) {
endpoint.addFormat(new RestFormatData(format));
}
}
// Add responses
for (RestResponse restResp : restQuery.reponses()) {
endpoint.addStatus(restResp, this);
}
// Add body parameter
if (restQuery.bodyParameter().type() != RestParameter.Type.NO_PARAMETER) {
endpoint.addBodyParam(restQuery.bodyParameter(), this);
}
// Add path parameter
for (RestParameter pathParam : restQuery.pathParameters()) {
endpoint.addPathParam(new RestParamData(pathParam, this));
}
// Add query parameter (required and optional)
for (RestParameter restParam : restQuery.restParameters()) {
if (restParam.isRequired()) {
endpoint.addRequiredParam(new RestParamData(restParam, this));
} else {
endpoint.addOptionalParam(new RestParamData(restParam, this));
}
}
// Set the test form after all parameters are added.
endpoint.setTestForm(new RestFormData(endpoint));
// Add the endpoint to the corresponding group based on its HTTP method
if ("GET".equalsIgnoreCase(httpMethodString) || "HEAD".equalsIgnoreCase(httpMethodString)) {
addEndpoint(READ_ENDPOINT_HOLDER_NAME, endpoint);
} else if ("DELETE".equalsIgnoreCase(httpMethodString) || "POST".equalsIgnoreCase(httpMethodString)
|| "PUT".equalsIgnoreCase(httpMethodString)) {
addEndpoint(WRITE_ENDPOINT_HOLDER_NAME, endpoint);
}
}
}