HttpAdapter.java
/*
* The contents of this file are subject to the terms of the Common Development and
* Distribution License (the License). You may not use this file except in compliance with the
* License.
*
* You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
* specific language governing permission and limitations under the License.
*
* When distributing Covered Software, include this CDDL Header Notice in each file and include
* the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
* Header, with the fields enclosed by brackets [] replaced by your own identifying
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2012-2016 ForgeRock AS.
*/
package org.forgerock.json.resource.http;
import static java.util.concurrent.TimeUnit.MINUTES;
import static org.forgerock.api.commons.CommonsApi.COMMONS_API_DESCRIPTION;
import static org.wrensecurity.guava.common.base.Optional.absent;
import static org.wrensecurity.guava.common.base.Strings.isNullOrEmpty;
import static org.forgerock.http.util.Paths.addLeadingSlash;
import static org.forgerock.http.util.Paths.removeTrailingSlash;
import static org.forgerock.json.resource.Applications.simpleCrestApplication;
import static org.forgerock.json.resource.Requests.newApiRequest;
import static org.forgerock.json.resource.ResourcePath.resourcePath;
import static org.forgerock.json.resource.http.HttpUtils.CONTENT_TYPE_REGEX;
import static org.forgerock.json.resource.http.HttpUtils.ETAG_ANY;
import static org.forgerock.json.resource.http.HttpUtils.FIELDS_DELIMITER;
import static org.forgerock.json.resource.http.HttpUtils.HEADER_IF_MATCH;
import static org.forgerock.json.resource.http.HttpUtils.HEADER_IF_MODIFIED_SINCE;
import static org.forgerock.json.resource.http.HttpUtils.HEADER_IF_NONE_MATCH;
import static org.forgerock.json.resource.http.HttpUtils.HEADER_IF_UNMODIFIED_SINCE;
import static org.forgerock.json.resource.http.HttpUtils.MIME_TYPE_APPLICATION_JSON;
import static org.forgerock.json.resource.http.HttpUtils.MIME_TYPE_MULTIPART_FORM_DATA;
import static org.forgerock.json.resource.http.HttpUtils.PARAM_ACTION;
import static org.forgerock.json.resource.http.HttpUtils.PARAM_FIELDS;
import static org.forgerock.json.resource.http.HttpUtils.PARAM_MIME_TYPE;
import static org.forgerock.json.resource.http.HttpUtils.PARAM_PAGED_RESULTS_COOKIE;
import static org.forgerock.json.resource.http.HttpUtils.PARAM_PAGED_RESULTS_OFFSET;
import static org.forgerock.json.resource.http.HttpUtils.PARAM_PAGE_SIZE;
import static org.forgerock.json.resource.http.HttpUtils.PARAM_PRETTY_PRINT;
import static org.forgerock.json.resource.http.HttpUtils.PARAM_QUERY_EXPRESSION;
import static org.forgerock.json.resource.http.HttpUtils.PARAM_QUERY_FILTER;
import static org.forgerock.json.resource.http.HttpUtils.PARAM_QUERY_ID;
import static org.forgerock.json.resource.http.HttpUtils.PARAM_SORT_KEYS;
import static org.forgerock.json.resource.http.HttpUtils.PARAM_TOTAL_PAGED_RESULTS_POLICY;
import static org.forgerock.json.resource.http.HttpUtils.PROTOCOL_VERSION_1;
import static org.forgerock.json.resource.http.HttpUtils.RESTRICTED_HEADER_NAMES;
import static org.forgerock.json.resource.http.HttpUtils.SORT_KEYS_DELIMITER;
import static org.forgerock.json.resource.http.HttpUtils.asBooleanValue;
import static org.forgerock.json.resource.http.HttpUtils.asIntValue;
import static org.forgerock.json.resource.http.HttpUtils.asSingleValue;
import static org.forgerock.json.resource.http.HttpUtils.determineRequestType;
import static org.forgerock.json.resource.http.HttpUtils.fail;
import static org.forgerock.json.resource.http.HttpUtils.getIfMatch;
import static org.forgerock.json.resource.http.HttpUtils.getIfNoneMatch;
import static org.forgerock.json.resource.http.HttpUtils.getJsonActionContent;
import static org.forgerock.json.resource.http.HttpUtils.getJsonContent;
import static org.forgerock.json.resource.http.HttpUtils.getJsonPatchContent;
import static org.forgerock.json.resource.http.HttpUtils.getMethod;
import static org.forgerock.json.resource.http.HttpUtils.getParameter;
import static org.forgerock.json.resource.http.HttpUtils.getRequestedResourceVersion;
import static org.forgerock.json.resource.http.HttpUtils.prepareResponse;
import static org.forgerock.json.resource.http.HttpUtils.rejectIfMatch;
import static org.forgerock.json.resource.http.HttpUtils.rejectIfNoneMatch;
import static org.forgerock.json.resource.http.HttpUtils.staticContextFactory;
import static org.forgerock.util.Reject.checkNotNull;
import static org.forgerock.util.promise.Promises.newResultPromise;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutionException;
import org.forgerock.api.CrestApiProducer;
import org.forgerock.api.jackson.PathsModule;
import org.forgerock.api.models.ApiDescription;
import org.forgerock.api.transform.OpenApiTransformer;
import org.wrensecurity.guava.common.base.Optional;
import org.wrensecurity.guava.common.cache.CacheBuilder;
import org.wrensecurity.guava.common.cache.CacheLoader;
import org.wrensecurity.guava.common.cache.LoadingCache;
import org.wrensecurity.guava.common.util.concurrent.UncheckedExecutionException;
import org.forgerock.http.ApiProducer;
import org.forgerock.http.Handler;
import org.forgerock.http.header.AcceptLanguageHeader;
import org.forgerock.http.header.ContentTypeHeader;
import org.forgerock.http.protocol.Form;
import org.forgerock.http.protocol.Response;
import org.forgerock.http.protocol.Status;
import org.forgerock.http.routing.UriRouterContext;
import org.forgerock.http.routing.Version;
import org.forgerock.http.swagger.SwaggerUtils;
import org.forgerock.http.util.Json;
import org.forgerock.http.util.Uris;
import org.forgerock.json.JsonValue;
import org.forgerock.json.resource.ActionRequest;
import org.forgerock.json.resource.AdviceContext;
import org.forgerock.json.resource.BadRequestException;
import org.forgerock.json.resource.ConflictException;
import org.forgerock.json.resource.Connection;
import org.forgerock.json.resource.ConnectionFactory;
import org.forgerock.json.resource.CountPolicy;
import org.forgerock.json.resource.CreateRequest;
import org.forgerock.json.resource.CrestApplication;
import org.forgerock.json.resource.DeleteRequest;
import org.forgerock.json.resource.NotSupportedException;
import org.forgerock.json.resource.PatchRequest;
import org.forgerock.json.resource.PreconditionFailedException;
import org.forgerock.json.resource.QueryFilters;
import org.forgerock.json.resource.QueryRequest;
import org.forgerock.json.resource.ReadRequest;
import org.forgerock.json.resource.Request;
import org.forgerock.json.resource.RequestType;
import org.forgerock.json.resource.Requests;
import org.forgerock.json.resource.ResourceException;
import org.forgerock.json.resource.ResourcePath;
import org.forgerock.json.resource.UpdateRequest;
import org.forgerock.services.context.ClientContext;
import org.forgerock.services.context.Context;
import org.forgerock.services.context.RootContext;
import org.forgerock.services.descriptor.Describable;
import org.forgerock.util.AsyncFunction;
import org.forgerock.util.i18n.PreferredLocales;
import org.forgerock.util.promise.NeverThrowsException;
import org.forgerock.util.promise.Promise;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import io.swagger.models.Path;
import io.swagger.models.Swagger;
/**
* HTTP adapter from HTTP calls to JSON resource calls. This class can be
* used in any {@link org.forgerock.http.Handler}, just create a new instance and override the handle(Context, Request)
* method in your HTTP Handler to delegate all those calls to this class's handle(Context, Request)
* method.
* <p>
* For example:
*
* <pre>
* public class TestHandler extends org.forgerock.http.Handler {
* private final HttpAdapter adapter;
*
* public TestHandler() {
* RequestHandler handler = xxx;
* ConnectionFactory connectionFactory =
* Resources.newInternalConnectionFactory(handler);
* adapter = new HttpAdapter(connectionFactory);
* }
*
* protected Promise<Response, ResponseException> handler(Context context,
* org.forgerock.http.Request req)
* throws ResponseException {
* return adapter.handle(context, req);
* }
* }
* </pre>
*
* Note that this adapter does not provide implementations for the HTTP HEAD,
* OPTIONS, or TRACE methods. A simpler approach is to use the
* {@link CrestHttp} class contained within this package to build HTTP
* Handlers since it provides support for these HTTP methods.
*/
final class HttpAdapter implements Handler, Describable<Swagger, org.forgerock.http.protocol.Request>,
Describable.Listener {
private static final Logger logger = LoggerFactory.getLogger(HttpAdapter.class);
private static final ObjectMapper API_OBJECT_MAPPER = new ObjectMapper().registerModules(
new Json.LocalizableStringModule(),
new Json.JsonValueModule(),
new PathsModule());
private final ConnectionFactory connectionFactory;
private final HttpContextFactory contextFactory;
private final String apiId;
private final String apiVersion;
private final List<Describable.Listener> apiListeners = new CopyOnWriteArrayList<>();
private ApiProducer<Swagger> apiProducer;
private LoadingCache<String, Optional<Swagger>> descriptorCache;
/**
* Creates a new HTTP adapter with the provided connection factory and a
* context factory the {@link SecurityContextFactory}.
*
* @param connectionFactory
* The connection factory.
* @deprecated Use {@link CrestHttp#newHttpHandler(CrestApplication)} instead.
*/
@Deprecated
public HttpAdapter(ConnectionFactory connectionFactory) {
this(connectionFactory, (HttpContextFactory) null);
}
/**
* Creates a new HTTP adapter with the provided connection factory and
* parent request context.
*
* @param connectionFactory
* The connection factory.
* @param parentContext
* The parent request context which should be used as the parent
* context of each request context.
* @deprecated Use {@link CrestHttp#newHttpHandler(CrestApplication, Context)} instead.
*/
@SuppressWarnings("deprecation")
@Deprecated
public HttpAdapter(ConnectionFactory connectionFactory, final Context parentContext) {
this(connectionFactory, staticContextFactory(parentContext));
}
/**
* Creates a new HTTP adapter with the provided connection factory and
* context factory.
*
* @param connectionFactory
* The connection factory.
* @param contextFactory
* The context factory which will be used to obtain the parent
* context of each request context, or {@code null} if the
* {@link SecurityContextFactory} should be used.
* @deprecated Use {@link #HttpAdapter(CrestApplication, HttpContextFactory)} instead
*/
@SuppressWarnings("deprecation")
@Deprecated
public HttpAdapter(ConnectionFactory connectionFactory, HttpContextFactory contextFactory) {
this(simpleCrestApplication(connectionFactory, null, null), contextFactory);
}
/**
* Creates a new HTTP adapter with the provided connection factory and
* context factory.
*
* @param application
* The CREST application.
* @param contextFactory
* The context factory which will be used to obtain the parent
* context of each request context, or {@code null} if the
* {@link SecurityContextFactory} should be used.
*/
@SuppressWarnings("deprecation")
public HttpAdapter(CrestApplication application, HttpContextFactory contextFactory) {
this.contextFactory = contextFactory != null ? contextFactory : SecurityContextFactory
.getHttpServletContextFactory();
this.connectionFactory = checkNotNull(application.getConnectionFactory());
this.apiId = application.getApiId();
this.apiVersion = application.getApiVersion();
try {
Optional<Describable<ApiDescription, Request>> describable = getDescribableConnection();
if (describable.isPresent()) {
describable.get().addDescriptorListener(this);
}
} catch (ResourceException e) {
logger.warn("Could not create connection", e);
}
}
/**
* Handles the incoming HTTP request and converts it to a CREST request.
*
* @param context {@inheritDoc}
* @param request {@inheritDoc}
* @return Promise containing a {@code Response} or {@code ResponseException}.
*/
@Override
public Promise<Response, NeverThrowsException> handle(Context context,
org.forgerock.http.protocol.Request request) {
try {
RequestType requestType = determineRequestType(request);
switch (requestType) {
case CREATE:
return doCreate(context, request);
case READ:
return doRead(context, request);
case UPDATE:
return doUpdate(context, request);
case DELETE:
return doDelete(context, request);
case PATCH:
return doPatch(context, request);
case ACTION:
return doAction(context, request);
case QUERY:
return doQuery(context, request);
case API:
return doApiRequest(context, request);
default:
throw new NotSupportedException("Operation " + requestType + " not supported");
}
} catch (ResourceException e) {
return fail(request, e);
}
}
Promise<Response, NeverThrowsException> doDelete(Context context, org.forgerock.http.protocol.Request req) {
try {
Version requestedResourceVersion = getRequestedResourceVersion(req);
// Prepare response.
Response resp = prepareResponse(req);
// Validate request.
preprocessRequest(req);
rejectIfNoneMatch(req);
// use the version-1 meaning of getIfMatch; i.e., treat * as null
final String ifMatchRevision = getIfMatch(req, PROTOCOL_VERSION_1);
final Form parameters = req.getForm();
final DeleteRequest request =
Requests.newDeleteRequest(getResourcePath(context, req))
.setRevision(ifMatchRevision)
.setResourceVersion(requestedResourceVersion);
for (final Map.Entry<String, List<String>> p : parameters.entrySet()) {
final String name = p.getKey();
final List<String> values = p.getValue();
if (parseCommonParameter(name, values, request)) {
continue;
} else {
request.setAdditionalParameter(name, asSingleValue(name, values));
}
}
return doRequest(context, req, resp, request);
} catch (final Exception e) {
return fail(req, e);
}
}
Promise<Response, NeverThrowsException> doRead(Context context, org.forgerock.http.protocol.Request req) {
try {
Version requestedResourceVersion = getRequestedResourceVersion(req);
// Prepare response.
Response resp = prepareResponse(req);
// Validate request.
preprocessRequest(req);
rejectIfMatch(req);
final Form parameters = req.getForm();
// Read of instance within collection or singleton.
final String rev = getIfNoneMatch(req);
if (ETAG_ANY.equals(rev)) {
// FIXME: i18n
throw new PreconditionFailedException("If-None-Match * not appropriate for "
+ getMethod(req) + " requests");
}
final ReadRequest request = Requests.newReadRequest(getResourcePath(context, req))
.setResourceVersion(requestedResourceVersion);
for (final Map.Entry<String, List<String>> p : parameters.entrySet()) {
final String name = p.getKey();
final List<String> values = p.getValue();
if (parseCommonParameter(name, values, request)) {
continue;
} else if (PARAM_MIME_TYPE.equalsIgnoreCase(name)) {
if (values.size() != 1 || values.get(0).split(FIELDS_DELIMITER).length > 1) {
// FIXME: i18n.
throw new BadRequestException("Only one mime type value allowed");
}
if (parameters.get(PARAM_FIELDS).size() != 1) {
// FIXME: i18n.
throw new BadRequestException("The mime type parameter requires only "
+ "1 field to be specified");
}
} else {
request.setAdditionalParameter(name, asSingleValue(name, values));
}
}
return doRequest(context, req, resp, request);
} catch (final Exception e) {
return fail(req, e);
}
}
Promise<Response, NeverThrowsException> doQuery(Context context, org.forgerock.http.protocol.Request req) {
try {
Version requestedResourceVersion = getRequestedResourceVersion(req);
// Prepare response.
Response resp = prepareResponse(req);
// Validate request.
preprocessRequest(req);
rejectIfMatch(req);
final Form parameters = req.getForm();
// Additional pre-validation for queries.
rejectIfNoneMatch(req);
// Query against collection.
final QueryRequest request = Requests.newQueryRequest(getResourcePath(context, req))
.setResourceVersion(requestedResourceVersion);
for (final Map.Entry<String, List<String>> p : parameters.entrySet()) {
final String name = p.getKey();
final List<String> values = p.getValue();
if (parseCommonParameter(name, values, request)) {
continue;
} else if (name.equalsIgnoreCase(PARAM_SORT_KEYS)) {
for (final String s : values) {
try {
request.addSortKey(s.split(SORT_KEYS_DELIMITER));
} catch (final IllegalArgumentException e) {
// FIXME: i18n.
throw new BadRequestException("The value '" + s
+ "' for parameter '" + name
+ "' could not be parsed as a comma "
+ "separated list of sort keys");
}
}
} else if (name.equalsIgnoreCase(PARAM_QUERY_ID)) {
request.setQueryId(asSingleValue(name, values));
} else if (name.equalsIgnoreCase(PARAM_QUERY_EXPRESSION)) {
request.setQueryExpression(asSingleValue(name, values));
} else if (name.equalsIgnoreCase(PARAM_PAGED_RESULTS_COOKIE)) {
request.setPagedResultsCookie(asSingleValue(name, values));
} else if (name.equalsIgnoreCase(PARAM_PAGED_RESULTS_OFFSET)) {
request.setPagedResultsOffset(asIntValue(name, values));
} else if (name.equalsIgnoreCase(PARAM_PAGE_SIZE)) {
request.setPageSize(asIntValue(name, values));
} else if (name.equalsIgnoreCase(PARAM_QUERY_FILTER)) {
final String s = asSingleValue(name, values);
try {
request.setQueryFilter(QueryFilters.parse(s));
} catch (final IllegalArgumentException e) {
// FIXME: i18n.
throw new BadRequestException("The value '" + s + "' for parameter '"
+ name + "' could not be parsed as a valid query filter");
}
} else if (name.equalsIgnoreCase(PARAM_TOTAL_PAGED_RESULTS_POLICY)) {
final String policy = asSingleValue(name, values);
try {
request.setTotalPagedResultsPolicy(CountPolicy.valueOf(policy.toUpperCase()));
} catch (IllegalArgumentException e) {
// FIXME: i18n.
throw new BadRequestException("The value '" + policy + "' for parameter '"
+ name + "' could not be parsed as a valid count policy");
}
} else {
request.setAdditionalParameter(name, asSingleValue(name, values));
}
}
// Check for incompatible arguments.
if (request.getQueryId() != null && request.getQueryFilter() != null) {
// FIXME: i18n.
throw new BadRequestException("The parameters " + PARAM_QUERY_ID + " and "
+ PARAM_QUERY_FILTER + " are mutually exclusive");
}
if (request.getQueryId() != null && request.getQueryExpression() != null) {
// FIXME: i18n.
throw new BadRequestException("The parameters " + PARAM_QUERY_ID + " and "
+ PARAM_QUERY_EXPRESSION + " are mutually exclusive");
}
if (request.getQueryFilter() != null && request.getQueryExpression() != null) {
// FIXME: i18n.
throw new BadRequestException("The parameters " + PARAM_QUERY_FILTER + " and "
+ PARAM_QUERY_EXPRESSION + " are mutually exclusive");
}
if (request.getPagedResultsOffset() > 0 && request.getPagedResultsCookie() != null) {
// FIXME: i18n.
throw new BadRequestException("The parameters " + PARAM_PAGED_RESULTS_OFFSET + " and "
+ PARAM_PAGED_RESULTS_COOKIE + " are mutually exclusive");
}
return doRequest(context, req, resp, request);
} catch (final Exception e) {
return fail(req, e);
}
}
Promise<Response, NeverThrowsException> doPatch(Context context, org.forgerock.http.protocol.Request req) {
try {
Version requestedResourceVersion = getRequestedResourceVersion(req);
// Prepare response.
Response resp = prepareResponse(req);
// Validate request.
preprocessRequest(req);
if (req.getHeaders().getFirst(HEADER_IF_NONE_MATCH) != null) {
// FIXME: i18n
throw new PreconditionFailedException(
"Use of If-None-Match not supported for PATCH requests");
}
// use the version 1 meaning of getIfMatch; i.e., treat * as null
final String ifMatchRevision = getIfMatch(req, PROTOCOL_VERSION_1);
final Form parameters = req.getForm();
final PatchRequest request =
Requests.newPatchRequest(getResourcePath(context, req))
.setRevision(ifMatchRevision)
.setResourceVersion(requestedResourceVersion);
request.getPatchOperations().addAll(getJsonPatchContent(req));
for (final Map.Entry<String, List<String>> p : parameters.entrySet()) {
final String name = p.getKey();
final List<String> values = p.getValue();
if (parseCommonParameter(name, values, request)) {
continue;
} else {
request.setAdditionalParameter(name, asSingleValue(name, values));
}
}
return doRequest(context, req, resp, request);
} catch (final Exception e) {
return fail(req, e);
}
}
Promise<Response, NeverThrowsException> doCreate(Context context, org.forgerock.http.protocol.Request req) {
try {
Version requestedResourceVersion = getRequestedResourceVersion(req);
// Prepare response.
Response resp = prepareResponse(req);
// Validate request.
preprocessRequest(req);
if ("POST".equals(getMethod(req))) {
rejectIfNoneMatch(req);
rejectIfMatch(req);
final Form parameters = req.getForm();
final JsonValue content = getJsonContent(req);
final CreateRequest request =
Requests.newCreateRequest(getResourcePath(context, req), content)
.setResourceVersion(requestedResourceVersion);
for (final Map.Entry<String, List<String>> p : parameters.entrySet()) {
final String name = p.getKey();
final List<String> values = p.getValue();
if (parseCommonParameter(name, values, request)) {
continue;
} else if (name.equalsIgnoreCase(PARAM_ACTION)) {
// Ignore - already handled.
} else {
request.setAdditionalParameter(name, asSingleValue(name, values));
}
}
return doRequest(context, req, resp, request);
} else {
if (req.getHeaders().getFirst(HEADER_IF_MATCH) != null
&& req.getHeaders().getFirst(HEADER_IF_NONE_MATCH) != null) {
// FIXME: i18n
throw new PreconditionFailedException(
"Simultaneous use of If-Match and If-None-Match not supported for PUT requests");
}
final Form parameters = req.getForm();
final JsonValue content = getJsonContent(req);
// This is a create with a user provided resource ID: split the
// path into the parent resource name and resource ID.
final ResourcePath resourcePath = getResourcePath(context, req);
if (resourcePath.isEmpty()) {
// FIXME: i18n.
throw new BadRequestException("No new resource ID in HTTP PUT request");
}
// We have a pathInfo of the form "{container}/{id}"
final CreateRequest request =
Requests.newCreateRequest(resourcePath.parent(), content)
.setNewResourceId(resourcePath.leaf())
.setResourceVersion(requestedResourceVersion);
for (final Map.Entry<String, List<String>> p : parameters.entrySet()) {
final String name = p.getKey();
final List<String> values = p.getValue();
if (parseCommonParameter(name, values, request)) {
continue;
} else {
request.setAdditionalParameter(name, asSingleValue(name, values));
}
}
return doRequest(context, req, resp, request);
}
} catch (final Exception e) {
return fail(req, e);
}
}
Promise<Response, NeverThrowsException> doAction(Context context, org.forgerock.http.protocol.Request req) {
try {
Version requestedResourceVersion = getRequestedResourceVersion(req);
// Prepare response.
Response resp = prepareResponse(req);
// Validate request.
preprocessRequest(req);
rejectIfNoneMatch(req);
rejectIfMatch(req);
final Form parameters = req.getForm();
final String action = asSingleValue(PARAM_ACTION, getParameter(req, PARAM_ACTION));
// Action request.
final JsonValue content = getJsonActionContent(req);
final ActionRequest request =
Requests.newActionRequest(getResourcePath(context, req), action)
.setContent(content)
.setResourceVersion(requestedResourceVersion);
for (final Map.Entry<String, List<String>> p : parameters.entrySet()) {
final String name = p.getKey();
final List<String> values = p.getValue();
if (parseCommonParameter(name, values, request)) {
continue;
} else if (name.equalsIgnoreCase(PARAM_ACTION)) {
// Ignore - already handled.
} else {
request.setAdditionalParameter(name, asSingleValue(name, values));
}
}
return doRequest(context, req, resp, request);
} catch (final Exception e) {
return fail(req, e);
}
}
Promise<Response, NeverThrowsException> doUpdate(Context context, org.forgerock.http.protocol.Request req) {
try {
Version requestedResourceVersion = getRequestedResourceVersion(req);
// Prepare response.
Response resp = prepareResponse(req);
// Validate request.
preprocessRequest(req);
if (req.getHeaders().getFirst(HEADER_IF_MATCH) != null
&& req.getHeaders().getFirst(HEADER_IF_NONE_MATCH) != null) {
// FIXME: i18n
throw new PreconditionFailedException(
"Simultaneous use of If-Match and If-None-Match not supported for PUT requests");
}
// use the version 1 meaning of getIfMatch; i.e., treat * as null
final String ifMatchRevision = getIfMatch(req, PROTOCOL_VERSION_1);
final Form parameters = req.getForm();
final JsonValue content = getJsonContent(req);
final UpdateRequest request =
Requests.newUpdateRequest(getResourcePath(context, req), content)
.setRevision(ifMatchRevision)
.setResourceVersion(requestedResourceVersion);
for (final Map.Entry<String, List<String>> p : parameters.entrySet()) {
final String name = p.getKey();
final List<String> values = p.getValue();
if (parseCommonParameter(name, values, request)) {
continue;
} else {
request.setAdditionalParameter(name, asSingleValue(name, values));
}
}
return doRequest(context, req, resp, request);
} catch (final Exception e) {
return fail(req, e);
}
}
@SuppressWarnings("unchecked")
private Promise<Response, NeverThrowsException> doApiRequest(Context context,
final org.forgerock.http.protocol.Request req) {
try {
Optional<Describable<ApiDescription, Request>> describable = getDescribableConnection();
if (!describable.isPresent()) {
throw new NotSupportedException();
}
Request request = newApiRequest(getResourcePath(context, req));
context = prepareRequest(context, req, request);
ApiDescription api = describable.get().handleApiRequest(context, request);
ObjectWriter writer = Json.makeLocalizingObjectWriter(API_OBJECT_MAPPER, request.getPreferredLocales());
// Enable pretty printer if requested.
final List<String> values = getParameter(req, PARAM_PRETTY_PRINT);
if (values != null) {
if (asBooleanValue(PARAM_PRETTY_PRINT, values)) {
writer = writer.withDefaultPrettyPrinter();
}
}
return newResultPromise(new Response(Status.OK).setEntity(writer.writeValueAsBytes(api)));
} catch (Exception e) {
return fail(req, e);
}
}
@SuppressWarnings("unchecked")
private Optional<Describable<ApiDescription, Request>> getDescribableConnection()
throws ResourceException {
if (apiId == null || apiVersion == null) {
logger.info("CREST API Descriptor API ID and Version are not set. Not describing.");
return absent();
}
Connection connection = connectionFactory.getConnection();
if (connection instanceof Describable) {
return Optional.of((Describable<ApiDescription, Request>) connection);
} else {
return absent();
}
}
private Promise<Response, NeverThrowsException> doRequest(Context context, org.forgerock.http.protocol.Request req,
Response resp, Request request) throws Exception {
Context ctx = prepareRequest(context, req, request);
final RequestRunner runner = new RequestRunner(ctx, request, req, resp);
return connectionFactory.getConnectionAsync()
.thenAsync(new AsyncFunction<Connection, Response, NeverThrowsException>() {
@Override
public Promise<Response, NeverThrowsException> apply(Connection connection) {
return runner.handleResult(connection);
}
}, new AsyncFunction<ResourceException, Response, NeverThrowsException>() {
@Override
public Promise<Response, NeverThrowsException> apply(ResourceException error) {
return runner.handleError(error);
}
});
}
private Context prepareRequest(Context context, org.forgerock.http.protocol.Request req, Request request)
throws ResourceException, org.forgerock.http.header.MalformedHeaderException {
Context ctx = newRequestContext(context, req);
final AcceptLanguageHeader acceptLanguageHeader = req.getHeaders().get(AcceptLanguageHeader.class);
request.setPreferredLocales(acceptLanguageHeader != null
? acceptLanguageHeader.getLocales()
: new PreferredLocales(null));
return ctx;
}
/**
* Gets the raw (still url-encoded) resource name from the request. Removes leading and trailing forward slashes.
*/
private ResourcePath getResourcePath(Context context, org.forgerock.http.protocol.Request req)
throws ResourceException {
try {
if (context.containsContext(UriRouterContext.class)) {
ResourcePath reqPath = ResourcePath.valueOf(req.getUri().getRawPath());
return reqPath.subSequence(getMatchedUri(context).size(), reqPath.size());
} else {
return ResourcePath.valueOf(req.getUri().getRawPath()); //TODO is this a valid assumption?
}
} catch (IllegalArgumentException e) {
throw new BadRequestException(e.getMessage());
}
}
private ResourcePath getMatchedUri(Context context) {
List<ResourcePath> matched = new ArrayList<>();
Context ctx = context;
while (ctx.containsContext(UriRouterContext.class)) {
UriRouterContext uriRouterContext = ctx.asContext(UriRouterContext.class);
matched.add(ResourcePath.valueOf(uriRouterContext.getMatchedUri()));
ctx = uriRouterContext.getParent();
}
Collections.reverse(matched);
ResourcePath matchedUri = new ResourcePath();
for (ResourcePath resourcePath : matched) {
matchedUri = matchedUri.concat(resourcePath);
}
return matchedUri;
}
private Context newRequestContext(Context context, org.forgerock.http.protocol.Request req)
throws ResourceException {
final Context parent = contextFactory.createContext(context, req);
return new AdviceContext(new HttpContext(parent, req), RESTRICTED_HEADER_NAMES);
}
private boolean parseCommonParameter(final String name, final List<String> values,
final Request request) throws ResourceException {
if (name.equalsIgnoreCase(PARAM_FIELDS)) {
for (final String s : values) {
try {
request.addField(s.split(","));
} catch (final IllegalArgumentException e) {
// FIXME: i18n.
throw new BadRequestException("The value '" + s + "' for parameter '" + name
+ "' could not be parsed as a comma separated list of JSON pointers");
}
}
return true;
} else if (name.equalsIgnoreCase(PARAM_PRETTY_PRINT)) {
// This will be handled by the completionHandlerFactory, so just validate.
asBooleanValue(name, values);
return true;
} else {
// Unrecognized - must be request specific.
return false;
}
}
private void preprocessRequest(org.forgerock.http.protocol.Request req) throws ResourceException {
// TODO: check Accept (including charset parameter) and Accept-Charset headers
// Check content-type.
final String contentType = ContentTypeHeader.valueOf(req).getType();
if (!req.getMethod().equalsIgnoreCase(HttpUtils.METHOD_GET)
&& contentType != null
&& !CONTENT_TYPE_REGEX.matcher(contentType).matches()
&& !HttpUtils.isMultiPartRequest(contentType)) {
// TODO: i18n
throw new BadRequestException(
"The request could not be processed because it specified the content-type '"
+ contentType + "' when only the content-type '"
+ MIME_TYPE_APPLICATION_JSON + "' and '"
+ MIME_TYPE_MULTIPART_FORM_DATA + "' are supported");
}
if (req.getHeaders().getFirst(HEADER_IF_MODIFIED_SINCE) != null) {
// TODO: i18n
throw new ConflictException("Header If-Modified-Since not supported");
}
if (req.getHeaders().getFirst(HEADER_IF_UNMODIFIED_SINCE) != null) {
// TODO: i18n
throw new ConflictException("Header If-Unmodified-Since not supported");
}
}
@Override
public Swagger api(ApiProducer<Swagger> producer) {
this.apiProducer = producer;
return updateDescriptor();
}
private Swagger updateDescriptor() {
if (apiProducer == null) {
// Not yet attached to CHF
return null;
}
try {
Optional<Describable<ApiDescription, Request>> describable = getDescribableConnection();
if (describable.isPresent()) {
ApiDescription api = describable.get().api(new CrestApiProducer(apiId, apiVersion));
if (api != null) {
this.descriptorCache = CacheBuilder.newBuilder().expireAfterAccess(30, MINUTES)
.build(new CacheLoader<String, Optional<Swagger>>() {
@Override
public Optional<Swagger> load(String uri) throws ResourceException {
UriRouterContext context = new UriRouterContext(new RootContext(), "", uri,
Collections.<String, String>emptyMap());
ApiDescription api = getDescribableConnection().get()
.handleApiRequest(context, newApiRequest(resourcePath(uri)));
// Avoid NPE later during transformation
if (api == null) {
return absent();
}
Swagger swagger = OpenApiTransformer.execute(api, COMMONS_API_DESCRIPTION);
uri = removeTrailingSlash(uri);
if (!isNullOrEmpty(uri)) {
uri = addLeadingSlash(Uris.urlDecodePathElement(uri));
}
Map<String, Path> paths = new TreeMap<>();
for (Map.Entry<String, Path> path : swagger.getPaths().entrySet()) {
String pathString = path.getKey();
// A path from Swagger will always start with a slash.
// Remove leading slash from only if it is also the end of the path
if ((pathString.startsWith("/#") || pathString.equals("/")) && !uri.isEmpty()) {
pathString = pathString.substring(1);
}
paths.put(uri + pathString, path.getValue());
}
swagger.setPaths(paths);
return Optional.of(apiProducer.addApiInfo(swagger));
}
});
try {
return descriptorCache.get("").orNull();
} catch (ExecutionException e) {
throw (ResourceException) e.getCause();
}
}
}
} catch (ResourceException e) {
throw new IllegalStateException("Cannot get connection", e);
}
return null;
}
@Override
public Swagger handleApiRequest(Context context, org.forgerock.http.protocol.Request request) {
if (descriptorCache == null) {
return null;
}
Optional<Swagger> result;
try {
if (context.containsContext(UriRouterContext.class)) {
result = descriptorCache.get(context.asContext(UriRouterContext.class).getRemainingUri());
} else {
result = descriptorCache.get("");
}
} catch (ExecutionException e) {
throw new UnsupportedOperationException("Cannot get connection", e);
} catch (UncheckedExecutionException e) {
throw (RuntimeException) e.getCause();
}
Swagger descriptor = result.orNull();
if (descriptor != null && descriptor.getHost() == null) {
return SwaggerUtils.clone(descriptor).host(context.asContext(ClientContext.class).getLocalAddress());
}
return descriptor;
}
@Override
public void addDescriptorListener(Listener listener) {
apiListeners.add(listener);
}
@Override
public void removeDescriptorListener(Listener listener) {
apiListeners.remove(listener);
}
@Override
public void notifyDescriptorChange() {
updateDescriptor();
for (Listener listener : apiListeners) {
listener.notifyDescriptorChange();
}
}
}