All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.sap.cds.feature.messaging.em.mt.webhook.EnterpriseMessagingWebhookAdapter Maven / Gradle / Ivy

/**************************************************************************
 * (C) 2019-2024 SAP SE or an SAP affiliate company. All rights reserved. *
 **************************************************************************/
package com.sap.cds.feature.messaging.em.mt.webhook;

import static com.sap.cds.services.messaging.utils.MessagingUtils.toStructuredMessage;

import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.Reader;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Consumer;

import org.apache.http.HttpStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.fasterxml.jackson.databind.json.JsonMapper;
import com.google.common.io.CharStreams;
import com.sap.cds.feature.messaging.em.mt.service.EnterpriseMessagingMtService;
import com.sap.cds.feature.messaging.em.mt.service.EnterpriseMessagingTenantStatus;
import com.sap.cds.impl.util.Pair;
import com.sap.cds.services.ErrorStatuses;
import com.sap.cds.services.ServiceException;
import com.sap.cds.services.messaging.MessagingService;
import com.sap.cds.services.messaging.service.MessagingBrokerQueueListener;
import com.sap.cds.services.messaging.service.MessagingBrokerQueueListener.MessageAccess;
import com.sap.cds.services.mt.TenantProviderService;
import com.sap.cds.services.outbox.OutboxService;
import com.sap.cds.services.request.RequestContext;
import com.sap.cds.services.runtime.CdsRuntime;
import com.sap.cds.services.utils.CdsErrorStatuses;
import com.sap.cds.services.utils.ErrorStatusException;
import com.sap.cds.services.utils.StringUtils;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

public class EnterpriseMessagingWebhookAdapter extends HttpServlet {

	private static final long serialVersionUID = 1L;
	private static final Logger logger = LoggerFactory.getLogger(EnterpriseMessagingWebhookAdapter.class);

	private static final String HEADER_HANDSHAKE_FROM = "webhook-request-origin";
	private static final String HEADER_HANDSHAKE_TO = "webhook-allowed-origin";
	private static final String HEADER_QUEUE = "x-queue";
	private static final String HEADER_TOPIC = "x-address";

	private static final String ROLE_EMCALLBACK = "emcallback";
	private static final String ROLE_EMMANAGEMENT  = "emmanagement";

	private static final String UNEXPECTED_ERROR_OCCURRED_MESSAGE = "An unexpected error occurred during servlet processing";

	private final CdsRuntime runtime;
	private final Map queueListeners = new HashMap<>();
	private final TenantProviderService tenantService;

	private final JsonMapper mapper = new JsonMapper();

	public EnterpriseMessagingWebhookAdapter(CdsRuntime runtime) {
		this.runtime = runtime;

		// get all EM messaging MT services in order to register the queueListeners
		runtime.getServiceCatalog().getServices(MessagingService.class).map(OutboxService::unboxed)
		.filter(EnterpriseMessagingMtService.class::isInstance).map(EnterpriseMessagingMtService.class::cast).forEach(service -> {
			MessagingBrokerQueueListener listener = service.getQueueListener();
			this.queueListeners.put(listener.getQueueName(), listener);
			logger.info("Registered webhook queue listener for '{}'", listener.getQueueName());
		});
		tenantService = runtime.getServiceCatalog().getService(TenantProviderService.class, TenantProviderService.DEFAULT_NAME);
	}

	@Override
	protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
		runInRequestContext(req, resp, requestContext -> {
			checkRole(ROLE_EMMANAGEMENT);
			String tenantId = getUrlParameterTenantId(req);
			boolean verbose = Boolean.parseBoolean(req.getParameter("verbose"));
			if (tenantId != null) {
				runtime.requestContext().systemUser(tenantId).run(tenantRequestContext -> {
					writeJsonResponse(req, resp, getTenantStatus(tenantId, verbose));
				});
			} else {
				List tenants = new ArrayList<>();
				tenantService.readTenants()
				.forEach(tenant -> runtime.requestContext().systemUser(tenant).run(tenantRequestContext -> {
					tenants.add(getTenantStatus(tenant, verbose));
				}));
				writeJsonResponse(req, resp, tenants);
			}
		});
	}

	private EnterpriseMessagingTenantStatus getTenantStatus(String tenantId, boolean verbose) {

		List statuses = runtime.getServiceCatalog()
				.getServices(MessagingService.class)
				.map(OutboxService::unboxed)
				.filter(EnterpriseMessagingMtService.class::isInstance)
				.map(EnterpriseMessagingMtService.class::cast)
				.map(s -> s.getTenantStatus(tenantId, verbose))
				.toList();

		EnterpriseMessagingTenantStatus merged = new EnterpriseMessagingTenantStatus(tenantId);
		for(EnterpriseMessagingTenantStatus status : statuses) {
			merged.getServices().putAll(status.getServices());
			merged.getUnmanagedQueues().addAll(status.getUnmanagedQueues());
			merged.getUnmanagedWebhooks().addAll(status.getUnmanagedWebhooks());
		}

		// filter unmanaged queues managed by a different service
		merged.getServices().values().forEach(s -> merged.getUnmanagedQueues().remove(s.getQueue()));
		// filter unmanaged webhooks managed by a different service
		merged.getServices().values().forEach(s -> s.getWebhooks().forEach(w -> merged.getUnmanagedWebhooks().remove(w)));

		return merged;
	}

	@Override
	protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
		runWithoutRequestContext(req, resp, () -> {
			checkRole(ROLE_EMCALLBACK);

			Message message = new Message(req, runtime.getProvidedUserInfo().getTenant());
			MessagingBrokerQueueListener listener = this.queueListeners.get(message.getBrokerQueue());

			if (listener == null) {
				throw new ServiceException(ErrorStatuses.NOT_FOUND, "Received webhook request on unknown queue '{}' with topic '{}' and ID '{}'",
						message.getBrokerQueue(), message.getBrokerTopic(), message.getId());
			}

			try {
				listener.receivedMessage(message);
				resp.setStatus(HttpStatus.SC_ACCEPTED);
			} catch (ServiceException exp) {
				if(message.isAcknowledged()) {
					logger.debug("Ignored the exception as accepted by the error handler", exp);
					resp.setStatus(HttpStatus.SC_ACCEPTED);
				} else {
					throw exp;
				}
			}
		});
	}

	@Override
	protected void doOptions(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
		runInRequestContext(req, resp, requestContext -> {
			checkRole(ROLE_EMCALLBACK);

			// EM webhook registration handshake
			String origin = req.getHeader(HEADER_HANDSHAKE_FROM);
			if (origin == null) {
				throw new ServiceException(ErrorStatuses.BAD_REQUEST, "Received invalid webhook handshake without origin");
			} else {
				resp.setHeader(HEADER_HANDSHAKE_TO, origin); // NOSONAR
				logger.info("Webhook registration handshake with origin '{}' received", origin);
				resp.setStatus(HttpStatus.SC_ACCEPTED);
			}
		});
	}

	private void runInRequestContext(HttpServletRequest req, HttpServletResponse resp, Consumer consumer) throws IOException {
		runWithoutRequestContext(req, resp, () -> {
			// open request context
			runtime.requestContext().systemUserProvider().run(consumer);
		});
	}

	private void runWithoutRequestContext(HttpServletRequest req, HttpServletResponse resp, Runnable action) throws IOException {
		// extract the locale according to request parameters
		Locale locale = runtime.getProvidedParameterInfo().getLocale();
		try {
			action.run();
		} catch (ServiceException e) {
			int httpStatus = e.getErrorStatus().getHttpStatus();
			if(httpStatus >= 500 && httpStatus < 600) {
				logger.error(UNEXPECTED_ERROR_OCCURRED_MESSAGE, e);
			} else {
				logger.debug(UNEXPECTED_ERROR_OCCURRED_MESSAGE, e);
			}

			writeErrorResponse(req, resp, httpStatus, e.getLocalizedMessage(locale));
		} catch (Exception e) { // NOSONAR
			logger.error(UNEXPECTED_ERROR_OCCURRED_MESSAGE, e);
			writeErrorResponse(req, resp, 500, new ErrorStatusException(ErrorStatuses.SERVER_ERROR).getLocalizedMessage(locale));
		}
	}

	private void checkRole(String role) {
		if (!runtime.getProvidedUserInfo().hasRole(role)) {
			throw new ErrorStatusException(CdsErrorStatuses.TENANT_ADMIN_FORBIDDEN);
		}
	}

	private String getUrlParameterTenantId(HttpServletRequest req) {
		String[] pathParameters = StringUtils.trim(req.getPathInfo(), '/').split("/");
		return pathParameters.length > 0 ? (StringUtils.isEmpty(pathParameters[0]) ? null : pathParameters[0]) : null;
	}

	private void writeJsonResponse(HttpServletRequest req, HttpServletResponse resp, Object content) {
		resp.setContentType("application/json");
		try {
			PrintWriter out = resp.getWriter();
			mapper.writeValue(out, content);
		} catch (IOException e) {
			throw new UncheckedIOException(e);
		}
	}

	private void writeErrorResponse(HttpServletRequest req, HttpServletResponse resp, int httpStatus, String message) throws IOException {
		String responseContent = "{\"error\":{\"code\":\"" + httpStatus + "\",\"message\":\"" + message + "\"}}";
		resp.setStatus(httpStatus);
		resp.setContentType("application/json");
		resp.getWriter().println(responseContent);
	}


	private static class Message implements MessageAccess {

		private String id;
		private final String queue;
		private final String topic;
		private final String message;
		private final String tenant;
		private volatile boolean acknowledged;
		private Map dataMap;
		private Map headersMap;

		public Message(HttpServletRequest req, String tenant) {
			String theQueue = req.getHeader(HEADER_QUEUE);
			if(theQueue != null) {
				theQueue = theQueue.trim();
			}
			queue = theQueue;

			this.tenant = tenant;

			String theTopic = req.getHeader(HEADER_TOPIC);
			if (theTopic != null) {
				theTopic = theTopic.trim();
				if (theTopic.startsWith("topic:")) {
					theTopic = theTopic.substring(6).trim();
				}
			} else {
				theTopic = queue;
			}
			topic = theTopic;

			logger.debug("Received webhook request from queue '{}' on topic '{}' with ID '{}'", queue, topic, id);
			try (Reader reader = new InputStreamReader(req.getInputStream(), StandardCharsets.UTF_8)) {
				this.message = CharStreams.toString(reader);
			} catch (IOException e) {
				throw new ServiceException("Failed to read body of webhook request for queue '{}'", queue, e);
			}
		}

		@Override
		public String getId() {
			return id;
		}

		@Override
		public String getTenant() {
			return tenant;
		}

		@Override
		public String getMessage() {
			return message;
		}

		@Override
		public Map getDataMap() {
			if (this.dataMap == null) {
				populateMaps();
			}

			return this.dataMap;
		}

		@Override
		public Map getHeadersMap() {
			if (this.headersMap == null) {
				populateMaps();
			}

			return this.headersMap;
		}

		private void populateMaps() {
			Pair, Map> maps = toStructuredMessage(this.message);

			this.dataMap = maps.left;
			this.headersMap = maps.right;
		}

		public String getBrokerQueue() {
			return queue;
		}

		@Override
		public String getBrokerTopic() {
			return topic;
		}

		@Override
		public void acknowledge() {
			acknowledged = true;
		}

		public boolean isAcknowledged() {
			return acknowledged;
		}
	}
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy