org.eclipse.hono.service.amqp.RequestResponseEndpoint.java Source code

Java tutorial

Introduction

Here is the source code for org.eclipse.hono.service.amqp.RequestResponseEndpoint.java

Source

/**
 * Copyright (c) 2017, 2018 Bosch Software Innovations GmbH.
 *
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 *    Bosch Software Innovations GmbH - initial creation
 */
package org.eclipse.hono.service.amqp;

import java.net.HttpURLConnection;
import java.util.Objects;
import java.util.Optional;

import org.apache.qpid.proton.amqp.transport.AmqpError;
import org.apache.qpid.proton.message.Message;
import org.eclipse.hono.auth.HonoUser;
import org.eclipse.hono.client.ServiceInvocationException;
import org.eclipse.hono.config.ServiceConfigProperties;
import org.eclipse.hono.service.auth.AuthorizationService;
import org.eclipse.hono.service.auth.ClaimsBasedAuthorizationService;
import org.eclipse.hono.util.AmqpErrorException;
import org.eclipse.hono.util.Constants;
import org.eclipse.hono.util.EventBusMessage;
import org.eclipse.hono.util.MessageHelper;
import org.eclipse.hono.util.ResourceIdentifier;
import org.springframework.beans.factory.annotation.Autowired;

import io.vertx.core.Future;
import io.vertx.core.Vertx;
import io.vertx.core.eventbus.MessageConsumer;
import io.vertx.core.json.DecodeException;
import io.vertx.core.json.JsonObject;
import io.vertx.proton.ProtonConnection;
import io.vertx.proton.ProtonDelivery;
import io.vertx.proton.ProtonHelper;
import io.vertx.proton.ProtonQoS;
import io.vertx.proton.ProtonReceiver;
import io.vertx.proton.ProtonSender;

/**
 * An abstract base class for implementing endpoints that implement a request response pattern.
 * <p>
 * It is used e.g. in the implementation of the device registration and the credentials API endpoints.
 * 
 * @param <T> The type of configuration properties this endpoint uses.
 */
public abstract class RequestResponseEndpoint<T extends ServiceConfigProperties> extends AbstractAmqpEndpoint<T> {

    private static final int REQUEST_RESPONSE_ENDPOINT_DEFAULT_CREDITS = 20;

    private int receiverLinkCredit = REQUEST_RESPONSE_ENDPOINT_DEFAULT_CREDITS;
    private AuthorizationService authorizationService = new ClaimsBasedAuthorizationService();

    /**
     * Creates an endpoint for a Vertx instance.
     *
     * @param vertx The Vertx instance to use.
     * @throws NullPointerException if vertx is {@code null};
     */
    protected RequestResponseEndpoint(final Vertx vertx) {
        super(Objects.requireNonNull(vertx));
    }

    /**
     * Processes an AMQP message received from a client.
     *
     * @param request The Message to process. Must not be null.
     * @param targetAddress The address the message is sent to.
     * @param clientPrincipal The principal representing the client identity and its authorities.
     * @throws DecodeException if the message's payload does not contain a valid JSON string.
     * @throws NullPointerException if message is {@code null}.
     */
    public abstract void processRequest(Message request, ResourceIdentifier targetAddress,
            HonoUser clientPrincipal);

    /**
     * Creates an AMQP message for a service response.
     *
     * @param response The response to create the AMQP message for.
     * @return The AMQP message.
     * @throws NullPointerException If response is {@code null}.
     */
    protected abstract Message getAmqpReply(EventBusMessage response);

    /**
     * Gets the number of message credits this endpoint grants as a receiver.
     *
     * @return The number of credits granted.
     */
    public final int getReceiverLinkCredit() {
        return receiverLinkCredit;
    }

    /**
     * Sets the number of message credits this endpoint grants as a receiver.
     * They are replenished automatically after messages are processed.
        
     * @param receiverLinkCredit The number of credits to grant.
     * @throws IllegalArgumentException if the credit is &lt;= 0.
     */
    public final void setReceiverLinkCredit(final int receiverLinkCredit) {
        if (receiverLinkCredit <= 0) {
            throw new IllegalArgumentException("receiver link credit must be at least 1");
        }
        this.receiverLinkCredit = receiverLinkCredit;
    }

    /**
     * Gets the object to use for making authorization decisions.
     * 
     * @return The service.
     */
    public final AuthorizationService getAuthorizationService() {
        return authorizationService;
    }

    /**
     * Sets the object to use for making authorization decisions.
     * <p>
     * If not set a {@link ClaimsBasedAuthorizationService} instance is used.
     * 
     * @param authService The service.
     */
    @Autowired(required = false)
    public final void setAuthorizationService(final AuthorizationService authService) {
        this.authorizationService = authService;
    }

    /**
     * Configure and check the receiver link of the endpoint.
     * The remote link of the receiver must not demand the AT_MOST_ONCE QoS (not supported).
     * The receiver link itself is configured with the AT_LEAST_ONCE QoS and grants the configured credits ({@link #setReceiverLinkCredit(int)})
     * with autoAcknowledge.
     * <p>
     * Handling of received messages is delegated to {@link #handleMessage(ProtonConnection, ProtonReceiver, ResourceIdentifier, ProtonDelivery, Message)}.
     *
     * @param con The AMQP connection that the link is part of.
     * @param receiver The ProtonReceiver that has already been created for this endpoint.
     * @param targetAddress The resource identifier for this endpoint (see {@link ResourceIdentifier} for details).
     */
    @Override
    public final void onLinkAttach(final ProtonConnection con, final ProtonReceiver receiver,
            final ResourceIdentifier targetAddress) {
        if (ProtonQoS.AT_MOST_ONCE.equals(receiver.getRemoteQoS())) {
            logger.debug(
                    "client wants to use unsupported AT MOST ONCE delivery mode for endpoint [{}], closing link ...",
                    getName());
            receiver.setCondition(ProtonHelper.condition(AmqpError.PRECONDITION_FAILED.toString(),
                    "endpoint requires AT_LEAST_ONCE QoS"));
            receiver.close();
        } else {

            logger.debug("establishing link for receiving messages from client [{}]", receiver.getName());
            receiver.setQoS(ProtonQoS.AT_LEAST_ONCE).setAutoAccept(true) // settle received messages if the handler succeeds
                    .setPrefetch(receiverLinkCredit).handler((delivery, message) -> {
                        handleMessage(con, receiver, targetAddress, delivery, message);
                    }).closeHandler(clientDetached -> onLinkDetach(receiver)).open();
        }
    }

    /**
     * Handles a request message received from a client.
     * <p>
     * The message gets rejected if
     * <ul>
     * <li>the message does not pass {@linkplain #passesFormalVerification(ResourceIdentifier, Message) formal verification}
     * or</li>
     * <li>the client is not {@linkplain #isAuthorized(HonoUser, ResourceIdentifier, Message) authorized to execute the operation}
     * indicated by the message's <em>subject</em> or</li>
     * <li>its payload cannot be parsed</li>
     * </ul>
     * 
     * @param con The connection with the client.
     * @param receiver The link over which the message has been received.
     * @param targetAddress The address the message is sent to.
     * @param delivery The message's delivery status.
     * @param message The message.
     */
    protected final void handleMessage(final ProtonConnection con, final ProtonReceiver receiver,
            final ResourceIdentifier targetAddress, final ProtonDelivery delivery, final Message message) {

        final Future<Void> formalCheck = Future.future();
        if (passesFormalVerification(targetAddress, message)) {
            formalCheck.complete();
        } else {
            formalCheck.fail(new AmqpErrorException(AmqpError.DECODE_ERROR, "malformed payload"));
        }
        final HonoUser clientPrincipal = Constants.getClientPrincipal(con);
        formalCheck.compose(ok -> isAuthorized(clientPrincipal, targetAddress, message)).compose(authorized -> {

            logger.debug("client [{}] is {}authorized to {}:{}", clientPrincipal.getName(),
                    authorized ? "" : "not ", targetAddress, message.getSubject());
            if (authorized) {
                try {
                    processRequest(message, targetAddress, clientPrincipal);
                    ProtonHelper.accepted(delivery, true);
                    return Future.succeededFuture();
                } catch (final DecodeException e) {
                    return Future.failedFuture(new AmqpErrorException(AmqpError.DECODE_ERROR, "malformed payload"));
                }
            } else {
                return Future.failedFuture(new AmqpErrorException(AmqpError.UNAUTHORIZED_ACCESS, "unauthorized"));
            }

        }).otherwise(t -> {

            if (t instanceof AmqpErrorException) {
                final AmqpErrorException cause = (AmqpErrorException) t;
                MessageHelper.rejected(delivery, cause.asErrorCondition());
            } else {
                logger.debug("error processing request [resource: {}, op: {}]: {}", targetAddress,
                        message.getSubject(), t.getMessage());
                MessageHelper.rejected(delivery,
                        ProtonHelper.condition(AmqpError.INTERNAL_ERROR, "internal error"));
            }
            return null;
        });
    }

    /**
     * Checks if the client is authorized to execute a given operation.
     * 
     * This method is invoked for every request message received from a client.
     * <p>
     * This default implementation simply delegates to {@link AuthorizationService#isAuthorized(HonoUser, ResourceIdentifier, String)}.
     * <p>
     * Subclasses may override this method in order to do more sophisticated checks.
     * 
     * @param clientPrincipal The client.
     * @param resource The resource the message belongs to.
     * @param message The message for which the authorization shall be checked.
     * @return A future indicating the outcome of the check.
     *         The future will be succeeded if the client is authorized to execute the operation.
     *         Otherwise the future will be failed with a {@link ServiceInvocationException}.
     * @throws NullPointerException if any of the parameters is {@code null}.
     */
    protected Future<Boolean> isAuthorized(final HonoUser clientPrincipal, final ResourceIdentifier resource,
            final Message message) {

        Objects.requireNonNull(message);
        return getAuthorizationService().isAuthorized(clientPrincipal, resource, message.getSubject());
    }

    /**
     * Applies arbitrary filters on the response before it is sent to the client.
     * <p>
     * Subclasses may override this method in order to e.g. filter the payload based on
     * the client's authorities.
     * <p>
     * This default implementation simply returns a succeeded future containing the
     * original response.
     * 
     * @param clientPrincipal The client's identity and authorities.
     * @param response The response to send to the client.
     * @return A future indicating the outcome.
     *         If the future succeeds it will contain the (filtered) response to be sent to the client.
     *         Otherwise the future will fail with a {@link ServiceInvocationException} indicating the
     *         problem.
     */
    protected Future<EventBusMessage> filterResponse(final HonoUser clientPrincipal,
            final EventBusMessage response) {

        return Future.succeededFuture(Objects.requireNonNull(response));
    }

    /**
     * Handles a client's request to establish a link for receiving responses
     * to service invocations.
     * <p>
     * This method registers a consumer on the vert.x event bus for the given reply-to address.
     * Response messages received over the event bus are transformed into AMQP messages using
     * the {@link #getAmqpReply(EventBusMessage)} method and sent to the client over the established
     * link.
     *
     * @param con The AMQP connection that the link is part of.
     * @param sender The link to establish.
     * @param replyToAddress The reply-to address to create a consumer on the event bus for.
     */
    @Override
    public final void onLinkAttach(final ProtonConnection con, final ProtonSender sender,
            final ResourceIdentifier replyToAddress) {

        if (isValidReplyToAddress(replyToAddress)) {
            logger.debug("establishing sender link with client [{}]", sender.getName());
            final MessageConsumer<JsonObject> replyConsumer = vertx.eventBus().consumer(replyToAddress.toString(),
                    message -> {
                        // TODO check for correct session here...?
                        if (logger.isTraceEnabled()) {
                            logger.trace("forwarding reply to client [{}]: {}", sender.getName(),
                                    message.body().encodePrettily());
                        }
                        final EventBusMessage response = EventBusMessage.fromJson(message.body());
                        filterResponse(Constants.getClientPrincipal(con), response).recover(t -> {
                            final int status = Optional.of(t).map(cause -> {
                                if (cause instanceof ServiceInvocationException) {
                                    return ((ServiceInvocationException) cause).getErrorCode();
                                } else {
                                    return null;
                                }
                            }).orElse(HttpURLConnection.HTTP_INTERNAL_ERROR);
                            return Future.succeededFuture(response.getResponse(status));
                        }).map(filteredResponse -> {
                            final Message amqpReply = getAmqpReply(filteredResponse);
                            sender.send(amqpReply);
                            return null;
                        });
                    });

            sender.setQoS(ProtonQoS.AT_LEAST_ONCE);
            sender.closeHandler(senderClosed -> {
                logger.debug("client [{}] closed sender link, removing associated event bus consumer [{}]",
                        sender.getName(), replyConsumer.address());
                replyConsumer.unregister();
                if (senderClosed.succeeded()) {
                    senderClosed.result().close();
                }
            });
            sender.open();
        } else {
            logger.debug("client [{}] provided invalid reply-to address", sender.getName());
            sender.setCondition(ProtonHelper.condition(AmqpError.INVALID_FIELD, String.format(
                    "reply-to address must have the following format %s/<tenant>/<reply-address>", getName())));
            sender.close();
        }
    }

    /**
     * Checks if a resource identifier constitutes a valid reply-to address
     * for this service endpoint.
     * <p>
     * This method is invoked during establishment of the reply-to link between
     * the client and this endpoint. The link will only be established if this method
     * returns {@code true}.
     * <p>
     * This default implementation verifies that the address consists of three
     * segments: an endpoint identifier, a tenant identifier and a resource identifier.
     * <p>
     * Subclasses should override this method if the service they provide an endpoint for
     * use a different reply-to address format.
     * 
     * @param replyToAddress The address to check.
     * @return {@code true} if the address is valid.
     */
    protected boolean isValidReplyToAddress(final ResourceIdentifier replyToAddress) {

        if (replyToAddress == null) {
            return false;
        } else {
            return replyToAddress.getResourcePath().length >= 3;
        }
    }
}