CrestAdapter.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 2015-2016 ForgeRock AS.
 */

package org.forgerock.json.resource.http;

import static java.lang.String.format;
import static java.util.Arrays.asList;
import static org.forgerock.http.protocol.Status.CREATED;
import static org.forgerock.http.protocol.Status.NO_CONTENT;
import static org.forgerock.http.protocol.Status.OK;
import static org.forgerock.json.JsonValueFunctions.enumConstant;
import static org.forgerock.json.resource.QueryResponse.FIELD_ERROR;
import static org.forgerock.json.resource.QueryResponse.FIELD_PAGED_RESULTS_COOKIE;
import static org.forgerock.json.resource.QueryResponse.FIELD_RESULT;
import static org.forgerock.json.resource.QueryResponse.FIELD_TOTAL_PAGED_RESULTS;
import static org.forgerock.json.resource.QueryResponse.FIELD_TOTAL_PAGED_RESULTS_POLICY;
import static org.forgerock.json.resource.QueryResponse.NO_COUNT;
import static org.forgerock.json.resource.ResourceException.FIELD_CODE;
import static org.forgerock.json.resource.ResourceException.FIELD_DETAIL;
import static org.forgerock.json.resource.ResourceException.FIELD_MESSAGE;
import static org.forgerock.json.resource.ResourceException.FIELD_REASON;
import static org.forgerock.json.resource.ResourceException.newResourceException;
import static org.forgerock.json.resource.ResourceResponse.FIELD_CONTENT_ID;
import static org.forgerock.json.resource.ResourceResponse.FIELD_CONTENT_REVISION;
import static org.forgerock.json.resource.Responses.newActionResponse;
import static org.forgerock.json.resource.Responses.newQueryResponse;
import static org.forgerock.json.resource.Responses.newResourceResponse;
import static org.forgerock.json.resource.http.HttpUtils.DEFAULT_PROTOCOL_VERSION;
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_NONE_MATCH;
import static org.forgerock.json.resource.http.HttpUtils.METHOD_DELETE;
import static org.forgerock.json.resource.http.HttpUtils.METHOD_GET;
import static org.forgerock.json.resource.http.HttpUtils.METHOD_PATCH;
import static org.forgerock.json.resource.http.HttpUtils.METHOD_POST;
import static org.forgerock.json.resource.http.HttpUtils.METHOD_PUT;
import static org.forgerock.json.resource.http.HttpUtils.MIME_TYPE_APPLICATION_JSON;
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_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_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.SORT_KEYS_DELIMITER;
import static org.forgerock.util.CloseSilentlyFunction.closeSilently;
import static org.forgerock.util.Reject.checkNotNull;
import static org.forgerock.util.Utils.joinAsString;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

import org.forgerock.http.Handler;
import org.forgerock.http.MutableUri;
import org.forgerock.http.protocol.Responses;
import org.forgerock.http.header.AcceptApiVersionHeader;
import org.forgerock.http.header.ContentApiVersionHeader;
import org.forgerock.http.header.ContentTypeHeader;
import org.forgerock.http.protocol.Form;
import org.forgerock.http.protocol.Request;
import org.forgerock.http.protocol.Response;
import org.forgerock.http.protocol.Status;
import org.forgerock.http.routing.Version;
import org.forgerock.json.JsonValue;
import org.forgerock.json.resource.ActionRequest;
import org.forgerock.json.resource.ActionResponse;
import org.forgerock.json.resource.CountPolicy;
import org.forgerock.json.resource.CreateRequest;
import org.forgerock.json.resource.DeleteRequest;
import org.forgerock.json.resource.InternalServerErrorException;
import org.forgerock.json.resource.PatchOperation;
import org.forgerock.json.resource.PatchRequest;
import org.forgerock.json.resource.QueryRequest;
import org.forgerock.json.resource.QueryResourceHandler;
import org.forgerock.json.resource.QueryResponse;
import org.forgerock.json.resource.ReadRequest;
import org.forgerock.json.resource.RequestHandler;
import org.forgerock.json.resource.ResourceException;
import org.forgerock.json.resource.ResourceResponse;
import org.forgerock.json.resource.SortKey;
import org.forgerock.json.resource.UpdateRequest;
import org.forgerock.services.context.Context;
import org.forgerock.util.Function;
import org.forgerock.util.promise.Promise;

/**
 * This class is a bridge between CREST and CHF (the counter-part of {@link HttpAdapter}): it is used to transform
 * CREST {@link org.forgerock.json.resource.Request} into CHF {@link Request} and CHF {@link Response} back in
 * CREST {@link org.forgerock.json.resource.Response}.
 *
 * Example:
 * <pre>
 *     {@code
 *     RequestHandler client = CrestHttp.newRequestHandler(new HttpClientHandler(),
 *                                                         new URI("http://www.example.com/api/"));
 *     }
 * </pre>
 *
 * You can even wrap it into a {@link org.forgerock.json.resource.Connection} for the fluent API:
 * <pre>
 *     {@code
 *     Connection connection = Resources.newInternalConnection(client);
 *     ResourceResponse response = connection.create(context,
 *                                                   newCreateRequest("/users",
 *                                                                    "bjensen",
 *                                                                    json(object(field("login", "bjensen")))));
 *     }
 * </pre>
 *
 * <p><strong>Implementation note:</strong> We do not want to resurrect
 * {@link org.forgerock.json.resource.AdviceContext AdviceContext} from returned HTTP headers.
 * Contexts should not be used for communicating protocol content.
 * Anything that is to be communicated between peers should be exposed as part of the Request/Response interfaces,
 * such as preferred languages, resource API version, etc. If we have a specific use case for Advices then they should
 * be exposed as properties of the Response.
 */
final class CrestAdapter implements RequestHandler {

    private static final Status NOT_MODIFIED = Status.valueOf(304, "Not Modified");

    private final Handler handler;
    private final URI baseUri;

    /**
     * Constructs a new {@link CrestAdapter} wrapping the given HTTP {@code handler}.
     *
     * @param handler
     *         HTTP handler that will handle translated requests
     * @param uri
     *         base URI (need to end with a {@code /}) for HTTP requests
     */
    public CrestAdapter(Handler handler, URI uri) {
        this.handler = checkNotNull(handler);
        this.baseUri = checkNotNull(uri);
    }

    @Override
    public Promise<ActionResponse, ResourceException> handleAction(Context context, ActionRequest request) {

        Request httpRequest = new Request();
        prepareHttpRequest(request, httpRequest);
        httpRequest.setMethod(METHOD_POST);
        Form form = new Form();
        form.putSingle(PARAM_ACTION, request.getAction());
        form.appendRequestQuery(httpRequest);
        if (request.getContent() != null) {
            httpRequest.getEntity().setJson(request.getContent().getObject());
        }

        // Expect OK or NO_CONTENT
        return handler.handle(context, httpRequest)
                      .then(closeSilently(new Function<Response, ActionResponse, ResourceException>() {
                          @Override
                          public ActionResponse apply(Response response) throws ResourceException {
                              // Transform HTTP response to CREST ActionResponse

                              // CREST always output message with either application/json, text/plain,
                              // everything else is considered as a binary content

                              // We'll never output a request with 'mimeType'
                              // so the output content is always application/json
                              JsonValue content = loadJsonValueContent(response);

                              if (OK.equals(response.getStatus()) || NO_CONTENT.equals(response.getStatus())) {
                                  return setResourceVersion(response, newActionResponse(content));
                              } else {
                                  throw createResourceException(response, content);
                              }
                          }
                      }), Responses.<ActionResponse, ResourceException>noopExceptionFunction());
    }

    @Override
    public Promise<ResourceResponse, ResourceException> handleCreate(Context context, CreateRequest request) {
        final Request httpRequest = new Request();
        prepareHttpRequest(request, httpRequest);

        String resourceId = request.getNewResourceId();
        if (resourceId == null) {
            // container generated ID => POST
            httpRequest.setMethod(METHOD_POST);
        } else {
            // caller provided ID => PUT + If-None-Match: *
            httpRequest.setMethod(METHOD_PUT);
            MutableUri uri = httpRequest.getUri();
            try {
                // path and new resource id are not URL encoded (uri will take of that automatically)
                uri.setPath(uri.getPath() + "/" + request.getNewResourceId());
            } catch (URISyntaxException e) {
                return new InternalServerErrorException("Cannot rebuild resource path", e).asPromise();
            }
            setIfNoneMatchToAny(httpRequest);
        }

        if (request.getContent() != null) {
            httpRequest.getEntity().setJson(request.getContent().getObject());
        }

        // Expect CREATED
        return handler.handle(context, httpRequest)
                      .then(closeSilently(new Function<Response, ResourceResponse, ResourceException>() {
                          @Override
                          public ResourceResponse apply(Response response) throws ResourceException {
                              // Transform HTTP response to CREST ResourceResponse

                              // CREST always output message with either application/json, text/plain,
                              // everything else is considered as a binary content

                              // We'll never output a request with 'mimeType'
                              // so the output content is always application/json
                              JsonValue content = loadJsonValueContent(response);

                              if (CREATED.equals(response.getStatus())) {
                                  return setResourceVersion(response, createResourceResponse(content));
                              } else {
                                  throw createResourceException(response, content);
                              }
                          }
                      }), Responses.<ResourceResponse, ResourceException>noopExceptionFunction());
    }

    @Override
    public Promise<ResourceResponse, ResourceException> handleDelete(Context context, DeleteRequest request) {

        Request httpRequest = new Request();
        httpRequest.setMethod(METHOD_DELETE);
        prepareHttpRequest(request, httpRequest);
        setIfMatch(httpRequest, request.getRevision());

        // Expect OK
        return handler.handle(context, httpRequest)
                      .then(buildCrestResponse(asList(Status.OK)),
                            Responses.<ResourceResponse, ResourceException>noopExceptionFunction());
    }

    @Override
    public Promise<ResourceResponse, ResourceException> handlePatch(Context context, PatchRequest request) {
        Request httpRequest = new Request();
        httpRequest.setMethod(METHOD_PATCH);
        prepareHttpRequest(request, httpRequest);
        setIfMatch(httpRequest, request.getRevision());

        if (!request.getPatchOperations().isEmpty()) {
            JsonValue content = new JsonValue(new LinkedList<>());
            for (PatchOperation operation : request.getPatchOperations()) {
                content.add(operation.toJsonValue().getObject());
            }
            httpRequest.getEntity().setJson(content.getObject());
        }

        // Expect OK
        return handler.handle(context, httpRequest)
                      .then(buildCrestResponse(asList(Status.OK)),
                            Responses.<ResourceResponse, ResourceException>noopExceptionFunction());
    }

    @Override
    public Promise<QueryResponse, ResourceException> handleQuery(Context context,
                                                                 QueryRequest request,
                                                                 final QueryResourceHandler queryHandler) {
        Request httpRequest = new Request();
        prepareHttpRequest(request, httpRequest);
        httpRequest.setMethod(METHOD_GET);
        Form form = new Form();
        putIfNotNull(form, PARAM_QUERY_ID, request.getQueryId());
        putIfNotNull(form, PARAM_QUERY_EXPRESSION, request.getQueryExpression());
        putIfNotNull(form, PARAM_QUERY_FILTER, request.getQueryFilter());
        putIfNotNull(form, PARAM_TOTAL_PAGED_RESULTS_POLICY, request.getTotalPagedResultsPolicy());
        putIfNotNull(form, PARAM_PAGED_RESULTS_COOKIE, request.getPagedResultsCookie());
        List<SortKey> sortKeys = request.getSortKeys();
        if (sortKeys != null && !sortKeys.isEmpty()) {
            form.putSingle(PARAM_SORT_KEYS, joinAsString(SORT_KEYS_DELIMITER));
        }
        if (request.getPageSize() > 0) {
            form.putSingle(PARAM_PAGE_SIZE, String.valueOf(request.getPageSize()));
        }
        if (request.getPagedResultsOffset() >= 1) {
            form.putSingle(PARAM_PAGED_RESULTS_OFFSET, String.valueOf(request.getPagedResultsOffset()));
        }
        if (!form.isEmpty()) {
            form.appendRequestQuery(httpRequest);
        }

        // Expect OK
        return handler.handle(context, httpRequest)
                      .then(closeSilently(new Function<Response, QueryResponse, ResourceException>() {
                          @Override
                          public QueryResponse apply(Response response) throws ResourceException {
                              // Transform HTTP response to CREST ActionResponse

                              // CREST always output message with either application/json, text/plain,
                              // everything else is considered as a binary content

                              // We'll never output a request with 'mimeType'
                              // so the output content is always application/json
                              JsonValue content = loadJsonValueContent(response);

                              if (OK.equals(response.getStatus()) && !content.isDefined(FIELD_ERROR)) {

                                  String pagedResultsCookie = content.get(FIELD_PAGED_RESULTS_COOKIE).asString();
                                  CountPolicy countPolicy = content.get(FIELD_TOTAL_PAGED_RESULTS_POLICY)
                                                                   .as(enumConstant(CountPolicy.class));
                                  Integer totalPagedResults = content.get(FIELD_TOTAL_PAGED_RESULTS)
                                                                     .defaultTo(NO_COUNT)
                                                                     .asInteger();
                                  QueryResponse queryResponse = newQueryResponse(pagedResultsCookie,
                                                                                 countPolicy,
                                                                                 totalPagedResults);
                                  for (JsonValue value : content.get(FIELD_RESULT)) {
                                      queryHandler.handleResource(createResourceResponse(value));
                                  }

                                  return setResourceVersion(response, queryResponse);
                              } else {
                                  throw createResourceException(response, content);
                              }
                          }
                      }), Responses.<QueryResponse, ResourceException>noopExceptionFunction());
    }

    @Override
    public Promise<ResourceResponse, ResourceException> handleRead(Context context, ReadRequest request) {

        Request httpRequest = new Request();
        httpRequest.setMethod(METHOD_GET);
        prepareHttpRequest(request, httpRequest);

        // Expect OK or NOT_MODIFIED(304)
        return handler.handle(context, httpRequest)
                      .then(buildCrestResponse(asList(Status.OK, NOT_MODIFIED)),
                            Responses.<ResourceResponse, ResourceException>noopExceptionFunction());
    }

    @Override
    public Promise<ResourceResponse, ResourceException> handleUpdate(Context context, UpdateRequest request) {

        Request httpRequest = new Request();
        httpRequest.setMethod(METHOD_PUT);
        prepareHttpRequest(request, httpRequest);
        setIfMatch(httpRequest, request.getRevision());
        if (request.getContent() != null) {
            httpRequest.getEntity().setJson(request.getContent().getObject());
        }

        // Only expect OK
        return handler.handle(context, httpRequest)
                      .then(buildCrestResponse(asList(Status.OK)),
                            Responses.<ResourceResponse, ResourceException>noopExceptionFunction());
    }

    private static Function<Response, ResourceResponse, ResourceException> buildCrestResponse(
            final List<Status> accepted) {
        return closeSilently(new Function<Response, ResourceResponse, ResourceException>() {
            @Override
            public ResourceResponse apply(Response response) throws ResourceException {
                // Transform HTTP response to CREST ResourceResponse

                // CREST always output message with either application/json, text/plain,
                // everything else is considered as a binary content

                // We'll never output a request with 'mimeType'
                // so the output content is always application/json
                JsonValue content = loadJsonValueContent(response);

                if (accepted.contains(response.getStatus())) {
                    return setResourceVersion(response, createResourceResponse(content));
                } else {
                    throw createResourceException(response, content);
                }
            }
        });
    }

    private static void putIfNotNull(Form form, String name, Object value) {
        if (value != null) {
            form.putSingle(name, value.toString());
        }
    }

    private static JsonValue loadJsonValueContent(final Response response) throws ResourceException {
        if (MIME_TYPE_APPLICATION_JSON.equals(ContentTypeHeader.valueOf(response).getType())) {
            try {
                return new JsonValue(response.getEntity().getJson());
            } catch (IOException e) {
                throw new InternalServerErrorException("Cannot parse HTTP response content as JSON", e);
            }
        }
        throw new InternalServerErrorException("Response is not application/json");
    }

    private static ResourceResponse createResourceResponse(final JsonValue content) {
        return newResourceResponse(content.get(FIELD_CONTENT_ID).asString(),
                                   content.get(FIELD_CONTENT_REVISION).asString(),
                                   content);
    }

    private static <T extends org.forgerock.json.resource.Response> T setResourceVersion(final Response httpResponse,
                                                                                         final T result) {
        if (httpResponse.getHeaders().containsKey(ContentApiVersionHeader.NAME)) {
            Version resourceVersion = ContentApiVersionHeader.valueOf(httpResponse).getResourceVersion();
            result.setResourceApiVersion(resourceVersion);
        }
        return result;
    }

    private static void setRequestedResourceVersion(Request request, Version resourceVersion) {
        // Force protocol version to 2.0 (current) at least
        request.getHeaders().put(new AcceptApiVersionHeader(DEFAULT_PROTOCOL_VERSION,
                                                                  resourceVersion));
    }

    private static ResourceException createResourceException(final Response response, final JsonValue content) {
        ResourceException exception = newResourceException(content.get(FIELD_CODE)
                                                                  .defaultTo(response.getStatus().getCode())
                                                                  .asInteger(),
                                                           content.get(FIELD_MESSAGE).asString());

        if (content.isDefined(FIELD_DETAIL)) {
            exception.setDetail(content.get(FIELD_DETAIL));
        }
        if (content.isDefined(FIELD_REASON)) {
            exception.setReason(content.get(FIELD_REASON).asString());
        }
        // TODO Add other fields (cause) ?
        return setResourceVersion(response, exception);
    }

    private static void setIfMatch(final Request request, final String revision) {
        String value = "*";
        if (revision != null) {
            value = format("\"%s\"", revision);
        }
        request.getHeaders().put(HEADER_IF_MATCH, value);
    }

    private static void setIfNoneMatchToAny(final Request request) {
        request.getHeaders().put(HEADER_IF_NONE_MATCH, ETAG_ANY);
    }

    private void prepareHttpRequest(final org.forgerock.json.resource.Request request, final Request httpRequest) {
        setRequestedResourceVersion(httpRequest, request.getResourceVersion());

        httpRequest.setUri(baseUri.resolve(request.getResourcePath()));

        final Form form = new Form();
        if (!request.getFields().isEmpty()) {
            form.putSingle(PARAM_FIELDS, joinAsString(FIELDS_DELIMITER, request.getFields().toArray()));
        }
        for (Map.Entry<String, String> entry : request.getAdditionalParameters().entrySet()) {
            form.putSingle(entry.getKey(), entry.getValue());
        }
        if (!form.isEmpty()) {
            form.toRequestQuery(httpRequest);
        }
    }

}