GH-9416: Extract BaseMessageBuilder for easier message extensions

Fixes: #9416
Issue link: https://github.com/spring-projects/spring-integration/issues/9416

The `MessageBuilderFactory` bean could be used a central place to provide custom `Message`
implementation into the application.
For example, the `GenericMessage.toString()` can be overridden to remove or mask sensitive
information from the payload or headers.

* Extract a `BaseMessageBuilder` from the `MessageBuilder` class to simplify
a custom `MessageBuilderFactory` implementation
* Test and document new feature and its purpose
This commit is contained in:
Artem Bilan
2024-10-29 15:40:39 -04:00
parent 4ee55326df
commit f87aff3aa8
6 changed files with 497 additions and 285 deletions

View File

@@ -108,7 +108,7 @@ public class IntegrationMessageHeaderAccessor extends MessageHeaderAccessor {
* @see #isReadOnly(String)
*/
public void setReadOnlyHeaders(String... readOnlyHeaders) {
Assert.noNullElements(readOnlyHeaders, "'readOnlyHeaders' must not be contain null items.");
Assert.noNullElements(readOnlyHeaders, "'readOnlyHeaders' must not contain null items.");
if (!ObjectUtils.isEmpty(readOnlyHeaders)) {
this.readOnlyHeaders = new HashSet<>(Arrays.asList(readOnlyHeaders));
}

View File

@@ -0,0 +1,335 @@
/*
* Copyright 2024 the original author or authors.
*
* Licensed under the Apache 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* 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.springframework.integration.support;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Map;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.integration.IntegrationMessageHeaderAccessor;
import org.springframework.lang.Nullable;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.MessageHeaders;
import org.springframework.messaging.support.ErrorMessage;
import org.springframework.messaging.support.GenericMessage;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
/**
* The {@link AbstractIntegrationMessageBuilder} extension for the default logic to build message.
* The {@link MessageBuilder} is fully based on this class.
* This abstract class can be used for creating custom {@link Message} instances.
* For that purpose its {@link #build()} method has to be overridden.
* The custom {@link Message} type could be used, for example, to hide sensitive information
* from payload and headers when message is logged.
* For this goal there would be enough to override {@link GenericMessage#toString()}
* and filter out (or mask) those headers which container such sensitive information.
*
* @param <T> the payload type.
* @param <B> the target builder class type.
*
* @author Artem Bilan
*
* @since 6.4
*
* @see MessageBuilder
* @see MessageBuilderFactory
*/
public abstract class BaseMessageBuilder<T, B extends BaseMessageBuilder<T, B>>
extends AbstractIntegrationMessageBuilder<T> {
private static final Log LOGGER = LogFactory.getLog(BaseMessageBuilder.class);
private final T payload;
private final IntegrationMessageHeaderAccessor headerAccessor;
@Nullable
private final Message<T> originalMessage;
private volatile boolean modified;
private String[] readOnlyHeaders;
protected BaseMessageBuilder(T payload, @Nullable Message<T> originalMessage) {
Assert.notNull(payload, "payload must not be null");
this.payload = payload;
this.originalMessage = originalMessage;
this.headerAccessor = new IntegrationMessageHeaderAccessor(originalMessage);
if (originalMessage != null) {
this.modified = (!this.payload.equals(originalMessage.getPayload()));
}
}
@Override
public T getPayload() {
return this.payload;
}
@Override
public Map<String, Object> getHeaders() {
return this.headerAccessor.toMap();
}
@Nullable
@Override
public <V> V getHeader(String key, Class<V> type) {
return this.headerAccessor.getHeader(key, type);
}
/**
* Set the value for the given header name. If the provided value is {@code null}, the header will be removed.
* @param headerName The header name.
* @param headerValue The header value.
* @return this MessageBuilder.
*/
@Override
public B setHeader(String headerName, @Nullable Object headerValue) {
this.headerAccessor.setHeader(headerName, headerValue);
return _this();
}
/**
* Set the value for the given header name only if the header name is not already associated with a value.
* @param headerName The header name.
* @param headerValue The header value.
* @return this MessageBuilder.
*/
@Override
public B setHeaderIfAbsent(String headerName, Object headerValue) {
this.headerAccessor.setHeaderIfAbsent(headerName, headerValue);
return _this();
}
/**
* Removes all headers provided via array of 'headerPatterns'. As the name suggests the array
* may contain simple matching patterns for header names. Supported pattern styles are:
* {@code xxx*}, {@code *xxx}, {@code *xxx*} and {@code xxx*yyy}.
* @param headerPatterns The header patterns.
* @return this MessageBuilder.
*/
@Override
public B removeHeaders(String... headerPatterns) {
this.headerAccessor.removeHeaders(headerPatterns);
return _this();
}
/**
* Remove the value for the given header name.
* @param headerName The header name.
* @return this MessageBuilder.
*/
@Override
public B removeHeader(String headerName) {
if (!this.headerAccessor.isReadOnly(headerName)) {
this.headerAccessor.removeHeader(headerName);
}
else if (LOGGER.isInfoEnabled()) {
LOGGER.info("The header [" + headerName + "] is ignored for removal because it is is readOnly.");
}
return _this();
}
/**
* Copy the name-value pairs from the provided Map. This operation will overwrite any existing values. Use
* {@link #copyHeadersIfAbsent(Map)} to avoid overwriting values. Note that the 'id' and 'timestamp' header values
* will never be overwritten.
* @param headersToCopy The headers to copy.
* @return this MessageBuilder.
* @see MessageHeaders#ID
* @see MessageHeaders#TIMESTAMP
*/
@Override
public B copyHeaders(@Nullable Map<String, ?> headersToCopy) {
this.headerAccessor.copyHeaders(headersToCopy);
return _this();
}
/**
* Copy the name-value pairs from the provided Map. This operation will not override any existing values.
* @param headersToCopy The headers to copy.
* @return this MessageBuilder.
*/
@Override
public B copyHeadersIfAbsent(@Nullable Map<String, ?> headersToCopy) {
if (headersToCopy != null) {
for (Map.Entry<String, ?> entry : headersToCopy.entrySet()) {
String headerName = entry.getKey();
if (!this.headerAccessor.isReadOnly(headerName)) {
this.headerAccessor.setHeaderIfAbsent(headerName, entry.getValue());
}
}
}
return _this();
}
@SuppressWarnings("unchecked")
@Override
@Nullable
protected List<List<Object>> getSequenceDetails() {
return (List<List<Object>>) this.headerAccessor.getHeader(IntegrationMessageHeaderAccessor.SEQUENCE_DETAILS);
}
@Override
@Nullable
protected Object getCorrelationId() {
return this.headerAccessor.getCorrelationId();
}
@Override
protected Object getSequenceNumber() {
return this.headerAccessor.getSequenceNumber();
}
@Override
protected Object getSequenceSize() {
return this.headerAccessor.getSequenceSize();
}
@Override
public B pushSequenceDetails(Object correlationId, int sequenceNumber, int sequenceSize) {
super.pushSequenceDetails(correlationId, sequenceNumber, sequenceSize);
return _this();
}
@Override
public B popSequenceDetails() {
super.popSequenceDetails();
return _this();
}
@Override
public B setExpirationDate(@Nullable Long expirationDate) {
super.setExpirationDate(expirationDate);
return _this();
}
@Override
public B setExpirationDate(@Nullable Date expirationDate) {
super.setExpirationDate(expirationDate);
return _this();
}
@Override
public B setCorrelationId(Object correlationId) {
super.setCorrelationId(correlationId);
return _this();
}
@Override
public B setReplyChannel(MessageChannel replyChannel) {
super.setReplyChannel(replyChannel);
return _this();
}
@Override
public B setReplyChannelName(String replyChannelName) {
super.setReplyChannelName(replyChannelName);
return _this();
}
@Override
public B setErrorChannel(MessageChannel errorChannel) {
super.setErrorChannel(errorChannel);
return _this();
}
@Override
public B setErrorChannelName(String errorChannelName) {
super.setErrorChannelName(errorChannelName);
return _this();
}
@Override
public B setSequenceNumber(Integer sequenceNumber) {
super.setSequenceNumber(sequenceNumber);
return _this();
}
@Override
public B setSequenceSize(Integer sequenceSize) {
super.setSequenceSize(sequenceSize);
return _this();
}
@Override
public B setPriority(Integer priority) {
super.setPriority(priority);
return _this();
}
/**
* Specify a list of headers which should be considered as read only
* and prohibited from being populated in the message.
* @param readOnlyHeaders the list of headers for {@code readOnly} mode.
* Defaults to {@link MessageHeaders#ID} and {@link MessageHeaders#TIMESTAMP}.
* @return the current {@link BaseMessageBuilder}
* @see IntegrationMessageHeaderAccessor#isReadOnly(String)
*/
public B readOnlyHeaders(@Nullable String... readOnlyHeaders) {
this.readOnlyHeaders = readOnlyHeaders != null ? Arrays.copyOf(readOnlyHeaders, readOnlyHeaders.length) : null;
if (readOnlyHeaders != null) {
this.headerAccessor.setReadOnlyHeaders(readOnlyHeaders);
}
return _this();
}
/**
* Return an original message instance if it is not modified and does not have read-only headers.
* If payload is an instance of {@link Throwable}, then an {@link ErrorMessage} is built.
* Otherwise, a new instance of {@link GenericMessage} is produced.
* This method can be overridden to provide any custom message implementations.
* @return the message instance
* @see #getPayload()
* @see #getHeaders()
*/
@Override
@SuppressWarnings("unchecked")
public Message<T> build() {
if (!this.modified && !this.headerAccessor.isModified() && this.originalMessage != null
&& !containsReadOnly(this.originalMessage.getHeaders())) {
return this.originalMessage;
}
if (payload instanceof Throwable throwable) {
return (Message<T>) new ErrorMessage(throwable, getHeaders());
}
return new GenericMessage<>(payload, getHeaders());
}
private boolean containsReadOnly(MessageHeaders headers) {
if (!ObjectUtils.isEmpty(this.readOnlyHeaders)) {
for (String readOnly : this.readOnlyHeaders) {
if (headers.containsKey(readOnly)) {
return true;
}
}
}
return false;
}
@SuppressWarnings("unchecked")
private B _this() {
return (B) this;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,23 +16,10 @@
package org.springframework.integration.support;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Map;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.integration.IntegrationMessageHeaderAccessor;
import org.springframework.lang.Nullable;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.MessageHeaders;
import org.springframework.messaging.support.ErrorMessage;
import org.springframework.messaging.support.GenericMessage;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
/**
* The default message builder; creates immutable {@link GenericMessage}s.
@@ -48,52 +35,17 @@ import org.springframework.util.ObjectUtils;
* @author Gary Russell
* @author Artem Bilan
*/
public final class MessageBuilder<T> extends AbstractIntegrationMessageBuilder<T> {
private static final Log LOGGER = LogFactory.getLog(MessageBuilder.class);
private final T payload;
private final IntegrationMessageHeaderAccessor headerAccessor;
@Nullable
private final Message<T> originalMessage;
private volatile boolean modified;
private String[] readOnlyHeaders;
public final class MessageBuilder<T> extends BaseMessageBuilder<T, MessageBuilder<T>> {
/**
* Private constructor to be invoked from the static factory methods only.
*/
private MessageBuilder(T payload, @Nullable Message<T> originalMessage) {
Assert.notNull(payload, "payload must not be null");
this.payload = payload;
this.originalMessage = originalMessage;
this.headerAccessor = new IntegrationMessageHeaderAccessor(originalMessage);
if (originalMessage != null) {
this.modified = (!this.payload.equals(originalMessage.getPayload()));
}
}
@Override
public T getPayload() {
return this.payload;
}
@Override
public Map<String, Object> getHeaders() {
return this.headerAccessor.toMap();
}
@Nullable
@Override
public <V> V getHeader(String key, Class<V> type) {
return this.headerAccessor.getHeader(key, type);
super(payload, originalMessage);
}
/**
* Create a builder for a new {@link Message} instance pre-populated with all of the headers copied from the
* Create a builder for a new {@link Message} instance pre-populated with all the headers copied from the
* provided message. The payload of the provided Message will also be used as the payload for the new message.
* @param message the Message from which the payload and all headers will be copied
* @param <T> The type of the payload.
@@ -114,230 +66,4 @@ public final class MessageBuilder<T> extends AbstractIntegrationMessageBuilder<T
return new MessageBuilder<>(payload, null);
}
/**
* Set the value for the given header name. If the provided value is <code>null</code>, the header will be removed.
* @param headerName The header name.
* @param headerValue The header value.
* @return this MessageBuilder.
*/
@Override
public MessageBuilder<T> setHeader(String headerName, @Nullable Object headerValue) {
this.headerAccessor.setHeader(headerName, headerValue);
return this;
}
/**
* Set the value for the given header name only if the header name is not already associated with a value.
* @param headerName The header name.
* @param headerValue The header value.
* @return this MessageBuilder.
*/
@Override
public MessageBuilder<T> setHeaderIfAbsent(String headerName, Object headerValue) {
this.headerAccessor.setHeaderIfAbsent(headerName, headerValue);
return this;
}
/**
* Removes all headers provided via array of 'headerPatterns'. As the name suggests the array
* may contain simple matching patterns for header names. Supported pattern styles are:
* "xxx*", "*xxx", "*xxx*" and "xxx*yyy".
* @param headerPatterns The header patterns.
* @return this MessageBuilder.
*/
@Override
public MessageBuilder<T> removeHeaders(String... headerPatterns) {
this.headerAccessor.removeHeaders(headerPatterns);
return this;
}
/**
* Remove the value for the given header name.
* @param headerName The header name.
* @return this MessageBuilder.
*/
@Override
public MessageBuilder<T> removeHeader(String headerName) {
if (!this.headerAccessor.isReadOnly(headerName)) {
this.headerAccessor.removeHeader(headerName);
}
else if (LOGGER.isInfoEnabled()) {
LOGGER.info("The header [" + headerName + "] is ignored for removal because it is is readOnly.");
}
return this;
}
/**
* Copy the name-value pairs from the provided Map. This operation will overwrite any existing values. Use {
* {@link #copyHeadersIfAbsent(Map)} to avoid overwriting values. Note that the 'id' and 'timestamp' header values
* will never be overwritten.
* @param headersToCopy The headers to copy.
* @return this MessageBuilder.
* @see MessageHeaders#ID
* @see MessageHeaders#TIMESTAMP
*/
@Override
public MessageBuilder<T> copyHeaders(@Nullable Map<String, ?> headersToCopy) {
this.headerAccessor.copyHeaders(headersToCopy);
return this;
}
/**
* Copy the name-value pairs from the provided Map. This operation will <em>not</em> overwrite any existing values.
* @param headersToCopy The headers to copy.
* @return this MessageBuilder.
*/
@Override
public MessageBuilder<T> copyHeadersIfAbsent(@Nullable Map<String, ?> headersToCopy) {
if (headersToCopy != null) {
for (Map.Entry<String, ?> entry : headersToCopy.entrySet()) {
String headerName = entry.getKey();
if (!this.headerAccessor.isReadOnly(headerName)) {
this.headerAccessor.setHeaderIfAbsent(headerName, entry.getValue());
}
}
}
return this;
}
@SuppressWarnings("unchecked")
@Override
@Nullable
protected List<List<Object>> getSequenceDetails() {
return (List<List<Object>>) this.headerAccessor.getHeader(IntegrationMessageHeaderAccessor.SEQUENCE_DETAILS);
}
@Override
@Nullable
protected Object getCorrelationId() {
return this.headerAccessor.getCorrelationId();
}
@Override
protected Object getSequenceNumber() {
return this.headerAccessor.getSequenceNumber();
}
@Override
protected Object getSequenceSize() {
return this.headerAccessor.getSequenceSize();
}
/*
* The following overrides (delegating to super) are provided to ease the
* pain for existing applications that use the builder API and expect
* a MessageBuilder to be returned.
*/
@Override
public MessageBuilder<T> pushSequenceDetails(Object correlationId, int sequenceNumber, int sequenceSize) {
super.pushSequenceDetails(correlationId, sequenceNumber, sequenceSize);
return this;
}
@Override
public MessageBuilder<T> popSequenceDetails() {
super.popSequenceDetails();
return this;
}
@Override
public MessageBuilder<T> setExpirationDate(@Nullable Long expirationDate) {
super.setExpirationDate(expirationDate);
return this;
}
@Override
public MessageBuilder<T> setExpirationDate(@Nullable Date expirationDate) {
super.setExpirationDate(expirationDate);
return this;
}
@Override
public MessageBuilder<T> setCorrelationId(Object correlationId) {
super.setCorrelationId(correlationId);
return this;
}
@Override
public MessageBuilder<T> setReplyChannel(MessageChannel replyChannel) {
super.setReplyChannel(replyChannel);
return this;
}
@Override
public MessageBuilder<T> setReplyChannelName(String replyChannelName) {
super.setReplyChannelName(replyChannelName);
return this;
}
@Override
public MessageBuilder<T> setErrorChannel(MessageChannel errorChannel) {
super.setErrorChannel(errorChannel);
return this;
}
@Override
public MessageBuilder<T> setErrorChannelName(String errorChannelName) {
super.setErrorChannelName(errorChannelName);
return this;
}
@Override
public MessageBuilder<T> setSequenceNumber(Integer sequenceNumber) {
super.setSequenceNumber(sequenceNumber);
return this;
}
@Override
public MessageBuilder<T> setSequenceSize(Integer sequenceSize) {
super.setSequenceSize(sequenceSize);
return this;
}
@Override
public MessageBuilder<T> setPriority(Integer priority) {
super.setPriority(priority);
return this;
}
/**
* Specify a list of headers which should be considered as read only
* and prohibited from being populated in the message.
* @param readOnlyHeaders the list of headers for {@code readOnly} mode.
* Defaults to {@link MessageHeaders#ID} and {@link MessageHeaders#TIMESTAMP}.
* @return the current {@link MessageBuilder}
* @since 4.3.2
* @see IntegrationMessageHeaderAccessor#isReadOnly(String)
*/
public MessageBuilder<T> readOnlyHeaders(String... readOnlyHeaders) {
this.readOnlyHeaders = readOnlyHeaders != null ? Arrays.copyOf(readOnlyHeaders, readOnlyHeaders.length) : null;
this.headerAccessor.setReadOnlyHeaders(readOnlyHeaders);
return this;
}
@Override
@SuppressWarnings("unchecked")
public Message<T> build() {
if (!this.modified && !this.headerAccessor.isModified() && this.originalMessage != null
&& !containsReadOnly(this.originalMessage.getHeaders())) {
return this.originalMessage;
}
if (this.payload instanceof Throwable) {
return (Message<T>) new ErrorMessage((Throwable) this.payload, this.headerAccessor.toMap());
}
return new GenericMessage<>(this.payload, this.headerAccessor.toMap());
}
private boolean containsReadOnly(MessageHeaders headers) {
if (!ObjectUtils.isEmpty(this.readOnlyHeaders)) {
for (String readOnly : this.readOnlyHeaders) {
if (headers.containsKey(readOnly)) {
return true;
}
}
}
return false;
}
}

View File

@@ -16,16 +16,22 @@
package org.springframework.integration.support;
import org.junit.Test;
import java.io.Serial;
import java.util.Map;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.springframework.lang.Nullable;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.GenericMessage;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Gary Russell
* @author Artem Bilan
* @since 4.3.10
*
*/
public class MessageBuilderTests {
@@ -45,4 +51,71 @@ public class MessageBuilderTests {
assertThat(message.getHeaders().get("qux")).isNull();
}
@Test
public void personalInfoHeadersAreMaskedWithCustomMessage() {
Message<String> message =
MessageBuilder.withPayload("some_user")
.setHeader("password", "some_password")
.build();
Message<String> piiMessage = new PiiMessageBuilderFactory().fromMessage(message).build();
assertThat(piiMessage).isInstanceOf(PiiMessage.class);
assertThat(piiMessage.getPayload()).isEqualTo("some_user");
assertThat(piiMessage.getHeaders().get("password")).isEqualTo("some_password");
assertThat(piiMessage.toString())
.doesNotContain("some_password")
.contains("******");
}
private static class PiiMessageBuilderFactory implements MessageBuilderFactory {
@Override
public <T> PiiMessageBuilder<T> fromMessage(Message<T> message) {
return new PiiMessageBuilder<>(message.getPayload(), message);
}
@Override
public <T> PiiMessageBuilder<T> withPayload(T payload) {
return new PiiMessageBuilder<>(payload, null);
}
}
private static class PiiMessageBuilder<P> extends BaseMessageBuilder<P, PiiMessageBuilder<P>> {
public PiiMessageBuilder(P payload, @Nullable Message<P> originalMessage) {
super(payload, originalMessage);
}
@Override
public Message<P> build() {
return new PiiMessage<>(getPayload(), getHeaders());
}
}
private static class PiiMessage<P> extends GenericMessage<P> {
@Serial
private static final long serialVersionUID = -354503673433669578L;
public PiiMessage(P payload, Map<String, Object> headers) {
super(payload, headers);
}
@Override
public String toString() {
return "PiiMessage [payload=" + getPayload() + ", headers=" + maskHeaders(getHeaders()) + ']';
}
private static Map<String, Object> maskHeaders(Map<String, Object> headers) {
return headers.entrySet()
.stream()
.map((entry) -> entry.getKey().equals("password") ? Map.entry(entry.getKey(), "******") : entry)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
}
}