diff --git a/pom.xml b/pom.xml index cbc803fe7..728d28bf1 100644 --- a/pom.xml +++ b/pom.xml @@ -154,6 +154,7 @@ 1.78 1.14.13 0.0.5 + 1.13.5 diff --git a/spring-ws-core/pom.xml b/spring-ws-core/pom.xml index 9b2d0f93f..da544ab66 100644 --- a/spring-ws-core/pom.xml +++ b/spring-ws-core/pom.xml @@ -60,6 +60,12 @@ + + io.micrometer + micrometer-observation + ${micrometer-observation.version} + true + org.springframework spring-test @@ -244,6 +250,12 @@ spring-webflux test + + io.micrometer + micrometer-observation-test + ${micrometer-observation.version} + test + diff --git a/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/DefaultWebServiceTemplateConvention.java b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/DefaultWebServiceTemplateConvention.java new file mode 100644 index 000000000..baf5452ed --- /dev/null +++ b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/DefaultWebServiceTemplateConvention.java @@ -0,0 +1,106 @@ +/* + * Copyright 2005-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 + * + * http://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.ws.client.core.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import org.springframework.ws.client.core.observation.WebServiceTemplateObservationDocumentation.LowCardinalityKeyNames; + +/** + * ObservationConvention that describes how a WebServiceTemplate is observed. + * @author Johan Kindgren + */ +public class DefaultWebServiceTemplateConvention implements WebServiceTemplateConvention { + + private static final KeyValue EXCEPTION_NONE = KeyValue.of(LowCardinalityKeyNames.EXCEPTION, + KeyValue.NONE_VALUE); + private static final String NAME = "webservice.client"; + + @Override + public KeyValues getHighCardinalityKeyValues(WebServiceTemplateObservationContext context) { + if (context.getPath() != null) { + return KeyValues.of(path(context)); + } + return KeyValues.empty(); + } + + @Override + public KeyValues getLowCardinalityKeyValues(WebServiceTemplateObservationContext context) { + return KeyValues.of( + exception(context), + host(context), + localname(context), + namespace(context), + outcome(context), + soapAction(context)); + } + + private KeyValue path(WebServiceTemplateObservationContext context) { + + return WebServiceTemplateObservationDocumentation.HighCardinalityKeyNames + .PATH + .withValue(context.getPath()); + } + + private KeyValue localname(WebServiceTemplateObservationContext context) { + return LowCardinalityKeyNames + .LOCALPART + .withValue(context.getLocalPart()); + } + + private KeyValue namespace(WebServiceTemplateObservationContext context) { + return LowCardinalityKeyNames + .NAMESPACE + .withValue(context.getNamespace()); + } + private KeyValue host(WebServiceTemplateObservationContext context) { + return LowCardinalityKeyNames + .HOST + .withValue(context.getHost()); + } + + + private KeyValue outcome(WebServiceTemplateObservationContext context) { + return LowCardinalityKeyNames + .OUTCOME + .withValue(context.getOutcome()); + } + + private KeyValue soapAction(WebServiceTemplateObservationContext context) { + return LowCardinalityKeyNames + .SOAPACTION + .withValue(context.getSoapAction()); + } + + private KeyValue exception(WebServiceTemplateObservationContext context) { + if (context.getError() != null) { + return LowCardinalityKeyNames + .EXCEPTION + .withValue(context.getError().getClass().getSimpleName()); + } + return EXCEPTION_NONE; + } + + @Override + public String getName() { + return NAME; + } + + @Override + public String getContextualName(WebServiceTemplateObservationContext context) { + return context.getContextualName(); + } +} diff --git a/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceObservationInterceptor.java b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceObservationInterceptor.java new file mode 100644 index 000000000..f545c44d2 --- /dev/null +++ b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceObservationInterceptor.java @@ -0,0 +1,152 @@ +/* + * Copyright 2005-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 + * + * http://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.ws.client.core.observation; + +import io.micrometer.common.util.internal.logging.WarnThenDebugLogger; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.ws.FaultAwareWebServiceMessage; +import org.springframework.ws.WebServiceMessage; +import org.springframework.ws.client.WebServiceClientException; +import org.springframework.ws.client.support.interceptor.ClientInterceptorAdapter; +import org.springframework.ws.context.MessageContext; +import org.springframework.ws.soap.SoapMessage; +import org.springframework.ws.support.ObservationHelper; +import org.springframework.ws.transport.HeadersAwareSenderWebServiceConnection; +import org.springframework.ws.transport.TransportConstants; +import org.springframework.ws.transport.WebServiceConnection; +import org.springframework.ws.transport.context.TransportContext; +import org.springframework.ws.transport.context.TransportContextHolder; + +import javax.xml.namespace.QName; +import javax.xml.transform.Source; +import java.net.URI; +import java.net.URISyntaxException; + +/** + * Interceptor that creates an Observation for each operation. + * + * @author Johan Kindgren + * @see Observation + * @see io.micrometer.observation.ObservationConvention + */ +public class WebServiceObservationInterceptor extends ClientInterceptorAdapter { + + private static final WarnThenDebugLogger WARN_THEN_DEBUG_LOGGER = new WarnThenDebugLogger(WebServiceObservationInterceptor.class); + private static final String OBSERVATION_KEY = "observation"; + private static final WebServiceTemplateConvention DEFAULT_CONVENTION = new DefaultWebServiceTemplateConvention(); + + private final ObservationRegistry observationRegistry; + + private final WebServiceTemplateConvention customConvention; + private final ObservationHelper observationHelper; + + public WebServiceObservationInterceptor( + @NonNull + ObservationRegistry observationRegistry, + @NonNull + ObservationHelper observationHelper, + @Nullable + WebServiceTemplateConvention customConvention) { + + this.observationRegistry = observationRegistry; + this.observationHelper = observationHelper; + this.customConvention = customConvention; + } + + + @Override + public boolean handleRequest(MessageContext messageContext) throws WebServiceClientException { + + TransportContext transportContext = TransportContextHolder.getTransportContext(); + HeadersAwareSenderWebServiceConnection connection = + (HeadersAwareSenderWebServiceConnection) transportContext.getConnection(); + + Observation observation = WebServiceTemplateObservationDocumentation.WEB_SERVICE_TEMPLATE.start( + customConvention, + DEFAULT_CONVENTION, + () -> new WebServiceTemplateObservationContext(connection), + observationRegistry); + + messageContext.setProperty(OBSERVATION_KEY, observation); + + return true; + } + + @Override + public void afterCompletion(MessageContext messageContext, Exception ex) { + + Observation observation = (Observation) messageContext.getProperty(OBSERVATION_KEY); + if (observation == null) { + WARN_THEN_DEBUG_LOGGER.log("Missing expected Observation in messageContext; the request will not be observed."); + return; + } + + WebServiceTemplateObservationContext context = (WebServiceTemplateObservationContext) observation.getContext(); + + WebServiceMessage request = messageContext.getRequest(); + WebServiceMessage response = messageContext.getResponse(); + + if (request instanceof SoapMessage soapMessage) { + + Source source = soapMessage.getSoapBody().getPayloadSource(); + QName root = observationHelper.getRootElement(source); + if (root != null) { + context.setLocalPart(root.getLocalPart()); + context.setNamespace(root.getNamespaceURI()); + } + if (soapMessage.getSoapAction() != null && !soapMessage.getSoapAction().equals(TransportConstants.EMPTY_SOAP_ACTION)) { + context.setSoapAction(soapMessage.getSoapAction()); + } + } + + if (ex == null) { + context.setOutcome("success"); + } else { + context.setError(ex); + context.setOutcome("fault"); + } + + if (response instanceof FaultAwareWebServiceMessage faultAwareResponse) { + if (faultAwareResponse.hasFault()) { + context.setOutcome("fault"); + } + } + + URI uri = getUriFromConnection(); + if (uri != null) { + context.setHost(uri.getHost()); + context.setPath(uri.getPath()); + } + + context.setContextualName("POST"); + + observation.stop(); + } + + URI getUriFromConnection() { + TransportContext transportContext = TransportContextHolder.getTransportContext(); + WebServiceConnection connection = transportContext.getConnection(); + try { + return connection.getUri(); + } catch (URISyntaxException e) { + return null; + } + } +} + diff --git a/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateConvention.java b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateConvention.java new file mode 100644 index 000000000..fe2d96daa --- /dev/null +++ b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateConvention.java @@ -0,0 +1,31 @@ +/* + * Copyright 2005-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 + * + * http://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.ws.client.core.observation; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; + +/** + * ObservationConvention that can be implemented to create a custom observation. + * @author Johan Kindgren + */ +public interface WebServiceTemplateConvention extends ObservationConvention { + + @Override + default boolean supportsContext(Observation.Context context) { + return context instanceof WebServiceTemplateObservationContext; + } +} diff --git a/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationContext.java b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationContext.java new file mode 100644 index 000000000..d58e4f2f3 --- /dev/null +++ b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationContext.java @@ -0,0 +1,102 @@ +/* + * Copyright 2005-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 + * + * http://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.ws.client.core.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.util.internal.logging.WarnThenDebugLogger; +import io.micrometer.observation.transport.RequestReplySenderContext; +import org.springframework.ws.transport.HeadersAwareSenderWebServiceConnection; +import org.springframework.ws.transport.TransportInputStream; + +import java.io.IOException; +/** + * ObservationContext used to instrument a WebServiceTemplate operation. + * @author Johan Kindgren + */ +public class WebServiceTemplateObservationContext extends RequestReplySenderContext { + + private static final WarnThenDebugLogger WARN_THEN_DEBUG_LOGGER = new WarnThenDebugLogger(WebServiceTemplateObservationContext.class); + + public static final String UNKNOWN = "unknown"; + private String outcome = UNKNOWN; + private String localPart = UNKNOWN; + private String namespace = UNKNOWN; + private String host = UNKNOWN; + private String soapAction = KeyValue.NONE_VALUE; + private String path = null; + + public WebServiceTemplateObservationContext(HeadersAwareSenderWebServiceConnection connection) { + super((carrier, key, value) -> { + + if (carrier != null) { + try { + carrier.addRequestHeader(key, value); + } catch (IOException e) { + WARN_THEN_DEBUG_LOGGER.log("Could not add key to carrier", e); + } + } + }); + setCarrier(connection); + } + + public String getOutcome() { + return outcome; + } + + public void setOutcome(String outcome) { + this.outcome = outcome; + } + + public String getLocalPart() { + return localPart; + } + + public void setLocalPart(String localPart) { + this.localPart = localPart; + } + + public String getNamespace() { + return namespace; + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public String getSoapAction() { + return soapAction; + } + + public void setSoapAction(String soapAction) { + this.soapAction = soapAction; + } + + public void setPath(String path) { + this.path = path; + } + + public String getPath() { + return path; + } +} diff --git a/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationDocumentation.java b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationDocumentation.java new file mode 100644 index 000000000..9c4ff4758 --- /dev/null +++ b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationDocumentation.java @@ -0,0 +1,134 @@ +/* + * Copyright 2005-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 + * + * http://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.ws.client.core.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.docs.KeyName; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import io.micrometer.observation.docs.ObservationDocumentation; + +/** + * ObservationDocumentation for WebSeviceTemplate. + * + * @author Johan Kindgren + */ +enum WebServiceTemplateObservationDocumentation implements ObservationDocumentation { + /** + * This enum constant defines observation documentation for the WebServiceTemplate. + * It provides the default observation convention and low cardinality key names + * relevant to WebService operations. + */ + WEB_SERVICE_TEMPLATE { + + @Override + public Class> getDefaultConvention() { + return DefaultWebServiceTemplateConvention.class; + } + + @Override + public KeyName[] getLowCardinalityKeyNames() { + return LowCardinalityKeyNames.values(); + } + + @Override + public KeyName[] getHighCardinalityKeyNames() { + return HighCardinalityKeyNames.values(); + } + }; + + enum HighCardinalityKeyNames implements KeyName { + /** + * Path for the client request. + * Optional value. + */ + PATH { + @Override + public String asString() { + return "path"; + } + + @Override + public boolean isRequired() { + return false; + } + } + } + + /** + * Enum representing low cardinality key names for observing a WebServiceTemplate. + */ + enum LowCardinalityKeyNames implements KeyName { + + /** + * Name of the exception thrown during the exchange, + * or {@value KeyValue#NONE_VALUE} if no exception happened. + */ + EXCEPTION { + @Override + public String asString() { + return "exception"; + } + }, + + /** + * Outcome of the WebService exchange. + */ + OUTCOME { + @Override + public String asString() { + return "outcome"; + } + }, + /** + * Namespace of the WebService payload. + */ + NAMESPACE { + @Override + public String asString() { + return "namespace"; + } + }, + + /** + * Localpart of the WebService payload. + */ + LOCALPART { + @Override + public String asString() { + return "localpart"; + } + }, + /** + * Host for the WebService call. + */ + HOST { + @Override + public String asString() { + return "host"; + } + }, + /** + * Value from the SoapAction header. + */ + SOAPACTION { + @Override + public String asString() { + return "soapaction"; + } + } + } +} diff --git a/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/DefaultWebServiceEndpointConvention.java b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/DefaultWebServiceEndpointConvention.java new file mode 100644 index 000000000..21e453425 --- /dev/null +++ b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/DefaultWebServiceEndpointConvention.java @@ -0,0 +1,123 @@ +/* + * Copyright 2005-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 + * + * http://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.ws.server.endpoint.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import io.micrometer.observation.Observation; + +/** + * Default ObservationConvention for a WebService Endpoint. + * @author Johan Kindgren + */ +public class DefaultWebServiceEndpointConvention implements WebServiceEndpointConvention { + + private static final KeyValue EXCEPTION_NONE = KeyValue.of(EndpointObservationDocumentation.LowCardinalityKeyNames.EXCEPTION, KeyValue.NONE_VALUE); + private static final String NAME = "webservice.server"; + + + @Override + public KeyValues getLowCardinalityKeyValues(WebServiceEndpointContext context) { + return KeyValues.of( + exception(context), + localPart(context), + namespace(context), + outcome(context), + path(context), + soapAction(context)); + } + + @Override + public KeyValues getHighCardinalityKeyValues(WebServiceEndpointContext context) { + if (context.getPathInfo() != null) { + return KeyValues.of(pathInfo(context)); + } + return KeyValues.empty(); + } + + private KeyValue localPart(WebServiceEndpointContext context) { + return EndpointObservationDocumentation + .LowCardinalityKeyNames + .LOCALPART + .withValue(context.getLocalPart()); + } + + private KeyValue namespace(WebServiceEndpointContext context) { + return EndpointObservationDocumentation + .LowCardinalityKeyNames + .NAMESPACE + .withValue(context.getNamespace()); + } + + + private KeyValue outcome(WebServiceEndpointContext context) { + return EndpointObservationDocumentation + .LowCardinalityKeyNames + .OUTCOME + .withValue(context.getOutcome()); + } + + private KeyValue soapAction(WebServiceEndpointContext context) { + return EndpointObservationDocumentation + .LowCardinalityKeyNames + .SOAPACTION + .withValue(context.getSoapAction()); + } + + private KeyValue exception(WebServiceEndpointContext context) { + if (context.getError() != null) { + return EndpointObservationDocumentation + .LowCardinalityKeyNames + .EXCEPTION + .withValue(context.getError().getClass().getSimpleName()); + } else { + return EXCEPTION_NONE; + } + } + + private KeyValue path(WebServiceEndpointContext context) { + return EndpointObservationDocumentation + .LowCardinalityKeyNames + .PATH + .withValue(context.getPath()); + } + + private KeyValue pathInfo(WebServiceEndpointContext context) { + if (context.getPathInfo() != null) { + return EndpointObservationDocumentation + .HighCardinalityKeyNames + .PATH_INFO + .withValue(context.getPathInfo()); + } + return null; + } + + @Override + public String getName() { + return NAME; + } + + @Override + public String getContextualName(WebServiceEndpointContext context) { + return context.getContextualName(); + } + + @Override + public boolean supportsContext(Observation.Context context) { + return context instanceof WebServiceEndpointContext; + } + +} diff --git a/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/EndpointObservationDocumentation.java b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/EndpointObservationDocumentation.java new file mode 100644 index 000000000..37d782657 --- /dev/null +++ b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/EndpointObservationDocumentation.java @@ -0,0 +1,137 @@ +/* + * Copyright 2005-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 + * + * http://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.ws.server.endpoint.observation; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.docs.KeyName; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import io.micrometer.observation.docs.ObservationDocumentation; + +/** + * ObservationDocumentation for a WebService Endpoint. + * + * @author Johan Kindgren + */ +enum EndpointObservationDocumentation implements ObservationDocumentation { + /** + * An enumeration for ObservationDocumentation related to WebService Endpoint. + * + * The {@code WEB_SERVICE_ENDPOINT} provides default conventions and low cardinality key names for + * observing a WebService endpoint. + * + * This implementation returns the {@link DefaultWebServiceEndpointConvention} class as the default convention, + * and an array of {@link LowCardinalityKeyNames} for low cardinality key names. + */ + WEB_SERVICE_ENDPOINT { + @Override + public Class> getDefaultConvention() { + return DefaultWebServiceEndpointConvention.class; + } + + @Override + public KeyName[] getLowCardinalityKeyNames() { + return LowCardinalityKeyNames.values(); + } + + @Override + public KeyName[] getHighCardinalityKeyNames() { + return HighCardinalityKeyNames.values(); + } + }; + + enum HighCardinalityKeyNames implements KeyName { + + /** + * Possible + */ + PATH_INFO { + @Override + public String asString() { + return "pathinfo"; + } + + @Override + public boolean isRequired() { + return false; + } + } + } + + /** + * Enum representing low cardinality key names for observing a WebService endpoint. + */ + enum LowCardinalityKeyNames implements KeyName { + + /** + * Name of the exception thrown during the exchange, + * or {@value KeyValue#NONE_VALUE} if no exception happened. + */ + EXCEPTION { + @Override + public String asString() { + return "exception"; + } + }, + + /** + * Outcome of the WebService exchange. + */ + OUTCOME { + @Override + public String asString() { + return "outcome"; + } + }, + /** + * Namespace of the WebService payload. + */ + NAMESPACE { + @Override + public String asString() { + return "namespace"; + } + }, + /** + * Localpart of the WebService payload. + */ + LOCALPART { + @Override + public String asString() { + return "localpart"; + } + }, + + /** + * Value from the SoapAction header. + */ + SOAPACTION { + @Override + public String asString() { + return "soapaction"; + } + }, + /** + * Path for the current request. + */ + PATH { + @Override + public String asString() { + return "path"; + } + } + } +} diff --git a/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/ObservationInterceptor.java b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/ObservationInterceptor.java new file mode 100644 index 000000000..7f11f0974 --- /dev/null +++ b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/ObservationInterceptor.java @@ -0,0 +1,149 @@ +/* + * Copyright 2005-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 + * + * http://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.ws.server.endpoint.observation; + +import io.micrometer.common.util.internal.logging.WarnThenDebugLogger; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.ws.FaultAwareWebServiceMessage; +import org.springframework.ws.WebServiceMessage; +import org.springframework.ws.context.MessageContext; +import org.springframework.ws.server.endpoint.interceptor.EndpointInterceptorAdapter; +import org.springframework.ws.soap.SoapMessage; +import org.springframework.ws.support.ObservationHelper; +import org.springframework.ws.transport.HeadersAwareReceiverWebServiceConnection; +import org.springframework.ws.transport.TransportConstants; +import org.springframework.ws.transport.context.TransportContext; +import org.springframework.ws.transport.context.TransportContextHolder; +import org.springframework.ws.transport.http.HttpServletConnection; + +import javax.xml.namespace.QName; +import javax.xml.transform.Source; + +/** + * Interceptor implementation that creates an observation for a WebService Endpoint. + * @author Johan Kindgren + */ +public class ObservationInterceptor extends EndpointInterceptorAdapter { + + private static final WarnThenDebugLogger WARN_THEN_DEBUG_LOGGER = new WarnThenDebugLogger(ObservationInterceptor.class); + private static final String OBSERVATION_KEY = "observation"; + private static final WebServiceEndpointConvention DEFAULT_CONVENTION = new DefaultWebServiceEndpointConvention(); + + private final ObservationRegistry observationRegistry; + private final ObservationHelper observationHelper; + private final WebServiceEndpointConvention customConvention; + + public ObservationInterceptor( + @NonNull + ObservationRegistry observationRegistry, + @NonNull + ObservationHelper observationHelper, + @Nullable + WebServiceEndpointConvention customConvention) { + this.observationRegistry = observationRegistry; + this.observationHelper = observationHelper; + this.customConvention = customConvention; + } + + @Override + public boolean handleRequest(MessageContext messageContext, Object endpoint) throws Exception { + + TransportContext transportContext = TransportContextHolder.getTransportContext(); + HeadersAwareReceiverWebServiceConnection connection = + (HeadersAwareReceiverWebServiceConnection) transportContext.getConnection(); + + Observation observation = EndpointObservationDocumentation.WEB_SERVICE_ENDPOINT.start( + customConvention, + DEFAULT_CONVENTION, + () -> new WebServiceEndpointContext(connection), + observationRegistry); + + messageContext.setProperty(OBSERVATION_KEY, observation); + + return true; + } + + @Override + public void afterCompletion(MessageContext messageContext, Object endpoint, @Nullable Exception ex) { + + Observation observation = (Observation) messageContext.getProperty(OBSERVATION_KEY); + if (observation == null) { + WARN_THEN_DEBUG_LOGGER.log("Missing expected Observation in messageContext; the request will not be observed."); + return; + } + + WebServiceEndpointContext context = (WebServiceEndpointContext) observation.getContext(); + + WebServiceMessage request = messageContext.getRequest(); + WebServiceMessage response = messageContext.getResponse(); + + if (request instanceof SoapMessage soapMessage) { + + Source source = soapMessage.getSoapBody().getPayloadSource(); + QName root = observationHelper.getRootElement(source); + if (root != null) { + context.setLocalPart(root.getLocalPart()); + context.setNamespace(root.getNamespaceURI()); + } + String action = soapMessage.getSoapAction(); + if (!TransportConstants.EMPTY_SOAP_ACTION.equals(action)) { + context.setSoapAction(soapMessage.getSoapAction()); + } else { + context.setSoapAction("none"); + } + } + + if (ex == null) { + context.setOutcome("success"); + } else { + context.setError(ex); + context.setOutcome("fault"); + } + + if (response instanceof FaultAwareWebServiceMessage faultAwareResponse) { + if (faultAwareResponse.hasFault()) { + context.setOutcome("fault"); + } + } + + TransportContext transportContext = TransportContextHolder.getTransportContext(); + HeadersAwareReceiverWebServiceConnection connection = + (HeadersAwareReceiverWebServiceConnection) transportContext.getConnection(); + + if (connection instanceof HttpServletConnection servletConnection) { + HttpServletRequest servletRequest = servletConnection.getHttpServletRequest(); + String servletPath = servletRequest.getServletPath(); + String pathInfo = servletRequest.getPathInfo(); + + if (pathInfo != null) { + context.setContextualName("POST " + servletPath + "/{pathInfo}"); + context.setPath(servletPath + "/{pathInfo}"); + context.setPathInfo(pathInfo); + } else { + context.setPath(servletPath); + context.setContextualName("POST " + servletPath); + } + } else { + context.setContextualName("POST"); + } + + observation.stop(); + } +} diff --git a/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/WebServiceEndpointContext.java b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/WebServiceEndpointContext.java new file mode 100644 index 000000000..c4d9d3d0f --- /dev/null +++ b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/WebServiceEndpointContext.java @@ -0,0 +1,104 @@ +/* + * Copyright 2005-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 + * + * http://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.ws.server.endpoint.observation; + +import io.micrometer.common.util.internal.logging.WarnThenDebugLogger; +import io.micrometer.observation.transport.RequestReplyReceiverContext; +import org.springframework.ws.transport.HeadersAwareReceiverWebServiceConnection; +import org.springframework.ws.transport.TransportInputStream; + +import java.io.IOException; +import java.util.Iterator; + +/** + * ObservationContext that describes how a WebService Endpoint is observed. + * @author Johan Kindgren + */ +public class WebServiceEndpointContext extends RequestReplyReceiverContext { + + private static final WarnThenDebugLogger WARN_THEN_DEBUG_LOGGER = new WarnThenDebugLogger(WebServiceEndpointContext.class); + private static final String UNKNOWN = "unknown"; + + private String outcome = UNKNOWN; + private String localPart = UNKNOWN; + private String namespace = UNKNOWN; + private String soapAction = UNKNOWN; + private String path = UNKNOWN; + private String pathInfo = null; + + public WebServiceEndpointContext(HeadersAwareReceiverWebServiceConnection connection) { + super((carrier, key) -> { + try { + Iterator headers = carrier.getRequestHeaders(key); + if (headers.hasNext()) { + return headers.next(); + } + } catch (IOException e) { + WARN_THEN_DEBUG_LOGGER.log("Could not read key from carrier", e); + } + return null; + }); + setCarrier(connection); + } + + public String getOutcome() { + return outcome; + } + + public void setOutcome(String outcome) { + this.outcome = outcome; + } + + public String getLocalPart() { + return localPart; + } + + public void setLocalPart(String localPart) { + this.localPart = localPart; + } + + public String getNamespace() { + return namespace; + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + public String getSoapAction() { + return soapAction; + } + + public void setSoapAction(String soapAction) { + this.soapAction = soapAction; + } + + public void setPath(String path) { + this.path = path; + } + + public String getPath() { + return path; + } + + public void setPathInfo(String pathInfo) { + this.pathInfo = pathInfo; + } + + public String getPathInfo() { + return pathInfo; + } +} diff --git a/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/WebServiceEndpointConvention.java b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/WebServiceEndpointConvention.java new file mode 100644 index 000000000..4b5dd65ef --- /dev/null +++ b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/WebServiceEndpointConvention.java @@ -0,0 +1,32 @@ +/* + * Copyright 2005-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 + * + * http://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.ws.server.endpoint.observation; + +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; + +/** + * ObservationConvention that describes how a WebService Endpoint is observed. + * @author Johan Kindgren + */ +public interface WebServiceEndpointConvention extends ObservationConvention { + + @Override + default boolean supportsContext(Observation.Context context) { + return context instanceof WebServiceEndpointContext; + } + +} diff --git a/spring-ws-core/src/main/java/org/springframework/ws/support/ObservationHelper.java b/spring-ws-core/src/main/java/org/springframework/ws/support/ObservationHelper.java new file mode 100644 index 000000000..39f77716e --- /dev/null +++ b/spring-ws-core/src/main/java/org/springframework/ws/support/ObservationHelper.java @@ -0,0 +1,139 @@ +/* + * Copyright 2005-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 + * + * http://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.ws.support; + +import io.micrometer.common.util.internal.logging.WarnThenDebugLogger; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.ws.server.endpoint.observation.ObservationInterceptor; +import org.w3c.dom.Node; +import org.xml.sax.Attributes; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; +import org.xml.sax.helpers.DefaultHandler; + +import javax.xml.namespace.QName; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; +import javax.xml.transform.Source; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.sax.SAXSource; +import javax.xml.transform.stream.StreamSource; +import java.io.IOException; + +/** + * Helper class for observation tasks. + * @author Johan Kindgren + */ +public class ObservationHelper { + + private final Log logger = LogFactory.getLog(getClass()); + + private static final WarnThenDebugLogger WARN_THEN_DEBUG_LOGGER = new WarnThenDebugLogger(ObservationInterceptor.class); + private static final QName UNKNOWN_Q_NAME = new QName("unknown", "unknow"); + + private final SAXParser saxParser; + + public ObservationHelper() { + SAXParserFactory parserFactory = SAXParserFactory.newNSInstance(); + SAXParser parser = null; + try { + parser = parserFactory.newSAXParser(); + } catch (ParserConfigurationException | SAXException e) { + logger.warn("Could not create SAX parser, observation keys for Root element can be reported as 'unknown'.", e); + } + saxParser = parser; + } + + + /** + * Try to find the root element QName for the given source. + * If it isn't possible to extract the QName, a QName with the values 'unknown:unknown' is returned. + */ + public QName getRootElement(Source source) { + if (source instanceof DOMSource) { + Node payload = ((DOMSource) source).getNode(); + if (payload.getNodeType() == Node.ELEMENT_NODE) { + return new QName(payload.getNamespaceURI(), payload.getLocalName()); + } + return UNKNOWN_Q_NAME; + } + if (source instanceof StreamSource) { + if (saxParser == null) { + WARN_THEN_DEBUG_LOGGER.log("SaxParser not available, reporting Root element as 'unknown'"); + return UNKNOWN_Q_NAME; + } + RootElementSAXHandler handler = new RootElementSAXHandler(); + try { + saxParser.parse(getInputSource((StreamSource) source), handler); + return handler.getRootElementName(); + } catch (SAXException | IOException e) { + WARN_THEN_DEBUG_LOGGER.log("Exception while handling request, reporting Root element as 'unknown'", e); + return UNKNOWN_Q_NAME; + } + } + if (source instanceof SAXSource) { + if (saxParser == null) { + WARN_THEN_DEBUG_LOGGER.log("SaxParser not available, reporting Root element as 'unknown'"); + return UNKNOWN_Q_NAME; + } + RootElementSAXHandler handler = new RootElementSAXHandler(); + try { + saxParser.parse(getInputSource((SAXSource) source), handler); + return handler.getRootElementName(); + } catch (SAXException | IOException e) { + WARN_THEN_DEBUG_LOGGER.log("Exception while handling request, reporting Root element as 'unknown'", e); + return UNKNOWN_Q_NAME; + } + } + return UNKNOWN_Q_NAME; + } + + InputSource getInputSource(StreamSource source) { + + if (source.getInputStream() != null) { + return new InputSource(source.getInputStream()); + } + return new InputSource(source.getReader()); + } + + InputSource getInputSource(SAXSource source) { + return source.getInputSource(); + } + + + + /** + * DefaultHandler that extracts the root elements namespace and name. + * @author Johan Kindgren + */ + static class RootElementSAXHandler extends DefaultHandler { + + private QName rootElementName = null; + + @Override + public void startElement(String uri, String localName, String qName, Attributes attributes) { + if (rootElementName == null) { + rootElementName = new QName(uri, localName); + } + } + + public QName getRootElementName() { + return rootElementName; + } + } +} diff --git a/spring-ws-core/src/test/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationIntegrationTest.java b/spring-ws-core/src/test/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationIntegrationTest.java new file mode 100644 index 000000000..3ca5e06ff --- /dev/null +++ b/spring-ws-core/src/test/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationIntegrationTest.java @@ -0,0 +1,474 @@ +/* + * Copyright 2005-2022 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 + * + * http://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.ws.client.core.observation; + +import io.micrometer.observation.tck.TestObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistryAssert; +import jakarta.activation.CommandMap; +import jakarta.activation.DataHandler; +import jakarta.activation.MailcapCommandMap; +import jakarta.mail.util.ByteArrayDataSource; +import jakarta.servlet.ServletConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.xml.soap.*; +import org.eclipse.jetty.server.Connector; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.oxm.Marshaller; +import org.springframework.oxm.XmlMappingException; +import org.springframework.ws.client.WebServiceTransportException; +import org.springframework.ws.client.core.AbstractSoap12WebServiceTemplateIntegrationTestCase; +import org.springframework.ws.client.core.WebServiceTemplate; +import org.springframework.ws.client.support.interceptor.ClientInterceptor; +import org.springframework.ws.soap.SoapMessage; +import org.springframework.ws.soap.client.SoapFaultClientException; +import org.springframework.ws.soap.saaj.SaajSoapMessageFactory; +import org.springframework.ws.support.ObservationHelper; +import org.springframework.ws.transport.http.HttpComponentsMessageSender; +import org.springframework.ws.transport.support.FreePortScanner; +import org.springframework.xml.transform.StringResult; +import org.springframework.xml.transform.StringSource; +import org.springframework.xml.transform.TransformerFactoryUtils; +import org.xmlunit.assertj.XmlAssert; + +import javax.xml.transform.Result; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerConfigurationException; +import javax.xml.transform.TransformerException; +import java.io.IOException; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.StringTokenizer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Verifies observation for a WebServiceTemplate + * @author Johan Kindgren + */ +public class WebServiceTemplateObservationIntegrationTest { + + private TestObservationRegistry observationRegistry; + private ObservationHelper observationHelper; + + private static Server jettyServer; + + private static String baseUrl; + + private WebServiceTemplate template; + + private String messagePayload = ""; + + @BeforeAll + public static void startJetty() throws Exception { + + int port = FreePortScanner.getFreePort(); + baseUrl = "http://localhost:" + port; + + jettyServer = new Server(port); + Connector connector = new ServerConnector(jettyServer); + jettyServer.addConnector(connector); + + ServletContextHandler jettyContext = new ServletContextHandler(); + jettyContext.setContextPath("/"); + + jettyContext.addServlet(AbstractSoap12WebServiceTemplateIntegrationTestCase.EchoSoapServlet.class, "/soap/echo"); + jettyContext.addServlet(AbstractSoap12WebServiceTemplateIntegrationTestCase.SoapReceiverFaultServlet.class, "/soap/receiverFault"); + jettyContext.addServlet(AbstractSoap12WebServiceTemplateIntegrationTestCase.SoapSenderFaultServlet.class, "/soap/senderFault"); + jettyContext.addServlet(AbstractSoap12WebServiceTemplateIntegrationTestCase.NoResponseSoapServlet.class, "/soap/noResponse"); + jettyContext.addServlet(AbstractSoap12WebServiceTemplateIntegrationTestCase.AttachmentsServlet.class, "/soap/attachment"); + + ServletHolder notfound = jettyContext.addServlet(AbstractSoap12WebServiceTemplateIntegrationTestCase.ErrorServlet.class, "/errors/notfound"); + notfound.setInitParameter("sc", "404"); + + ServletHolder errors = jettyContext.addServlet(AbstractSoap12WebServiceTemplateIntegrationTestCase.ErrorServlet.class, "/errors/server"); + errors.setInitParameter("sc", "500"); + + jettyServer.setHandler(jettyContext); + jettyServer.start(); + } + + @AfterAll + public static void stopJetty() throws Exception { + + if (jettyServer.isRunning()) { + jettyServer.stop(); + } + } + + /** + * A workaround for the faulty XmlDataContentHandler in the SAAJ RI, which cannot handle mime types such as "text/xml; + * charset=UTF-8", causing issues with Axiom. We basically reset the command map + */ + @BeforeEach + public void removeXmlDataContentHandler() throws SOAPException { + + MessageFactory messageFactory = MessageFactory.newInstance(); + SOAPMessage message = messageFactory.createMessage(); + message.createAttachmentPart(); + CommandMap.setDefaultCommandMap(new MailcapCommandMap()); + } + + @BeforeEach + public void createWebServiceTemplate() throws Exception { + observationRegistry = TestObservationRegistry.create(); + observationHelper = new ObservationHelper(); + + template = new WebServiceTemplate(new SaajSoapMessageFactory(MessageFactory.newInstance(SOAPConstants.SOAP_1_2_PROTOCOL))); + template.setMessageSender(new HttpComponentsMessageSender()); + template.setInterceptors(new ClientInterceptor[]{ + new WebServiceObservationInterceptor(observationRegistry, observationHelper, null) + }); + } + + + @Test + public void sendSourceAndReceiveToResult() { + + StringResult result = new StringResult(); + boolean b = template.sendSourceAndReceiveToResult(baseUrl + "/soap/echo", new StringSource(messagePayload), result); + + assertThat(b).isTrue(); + XmlAssert.assertThat(result.toString()).and(messagePayload).ignoreWhitespace().areIdentical(); + + TestObservationRegistryAssert.assertThat(observationRegistry).hasAnObservation(observationContextAssert -> + observationContextAssert + .hasLowCardinalityKeyValue("outcome", "success") + .hasLowCardinalityKeyValue("exception", "none") + .hasLowCardinalityKeyValue("host", "localhost") + .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws") + .hasLowCardinalityKeyValue("localpart", "root") + .hasHighCardinalityKeyValue("path", "/soap/echo") + .hasContextualNameEqualTo("POST") + ); + } + + @Test + public void sendSourceAndReceiveToResultNoResponse() { + + boolean b = template.sendSourceAndReceiveToResult(baseUrl + "/soap/noResponse", new StringSource(messagePayload), + new StringResult()); + assertThat(b).isFalse(); + + TestObservationRegistryAssert.assertThat(observationRegistry).hasAnObservation(observationContextAssert -> + observationContextAssert + .hasLowCardinalityKeyValue("outcome", "success") + .hasLowCardinalityKeyValue("exception", "none") + .hasLowCardinalityKeyValue("host", "localhost") + .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws") + .hasLowCardinalityKeyValue("localpart", "root") + .hasHighCardinalityKeyValue("path", "/soap/noResponse") + .hasContextualNameEqualTo("POST") + ); + } + + + @Test + public void marshalSendAndReceiveNoResponse() throws TransformerConfigurationException { + + final Transformer transformer = TransformerFactoryUtils.newInstance().newTransformer(); + final Object requestObject = new Object(); + Marshaller marshaller = new Marshaller() { + + @Override + public void marshal(Object graph, Result result) throws XmlMappingException, IOException { + + assertThat(requestObject).isEqualTo(graph); + + try { + transformer.transform(new StringSource(messagePayload), result); + } catch (TransformerException e) { + throw new RuntimeException(e); + } + } + + @Override + public boolean supports(Class clazz) { + + assertThat(clazz).isEqualTo(Object.class); + return true; + } + }; + + template.setMarshaller(marshaller); + Object result = template.marshalSendAndReceive(baseUrl + "/soap/noResponse", requestObject); + + assertThat(result).isNull(); + + TestObservationRegistryAssert.assertThat(observationRegistry).hasAnObservation(observationContextAssert -> + observationContextAssert + .hasLowCardinalityKeyValue("outcome", "success") + .hasLowCardinalityKeyValue("exception", "none") + .hasLowCardinalityKeyValue("host", "localhost") + .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws") + .hasLowCardinalityKeyValue("localpart", "root") + .hasHighCardinalityKeyValue("path", "/soap/noResponse") + .hasContextualNameEqualTo("POST") + ); + } + + @Test + public void notFound() { + + assertThatExceptionOfType(WebServiceTransportException.class) + .isThrownBy(() -> template.sendSourceAndReceiveToResult(baseUrl + "/errors/notfound", + new StringSource(messagePayload), new StringResult())); + + TestObservationRegistryAssert.assertThat(observationRegistry).hasAnObservation(observationContextAssert -> + observationContextAssert + .hasLowCardinalityKeyValue("outcome", "fault") + .hasLowCardinalityKeyValue("exception", "WebServiceTransportException") + .hasLowCardinalityKeyValue("host", "localhost") + .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws") + .hasLowCardinalityKeyValue("localpart", "root") + .hasHighCardinalityKeyValue("path", "/errors/notfound") + .hasContextualNameEqualTo("POST") + ); + + } + + @Test + public void receiverFault() { + + Result result = new StringResult(); + + assertThatExceptionOfType(SoapFaultClientException.class).isThrownBy(() -> template + .sendSourceAndReceiveToResult(baseUrl + "/soap/receiverFault", new StringSource(messagePayload), result)); + + TestObservationRegistryAssert.assertThat(observationRegistry).hasAnObservation(observationContextAssert -> + observationContextAssert + .hasLowCardinalityKeyValue("outcome", "fault") + .hasLowCardinalityKeyValue("exception", "SoapFaultClientException") + .hasLowCardinalityKeyValue("host", "localhost") + .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws") + .hasLowCardinalityKeyValue("localpart", "root") + .hasHighCardinalityKeyValue("path", "/soap/receiverFault") + .hasContextualNameEqualTo("POST") + ); + } + + @Test + public void senderFault() { + + Result result = new StringResult(); + + assertThatExceptionOfType(SoapFaultClientException.class).isThrownBy(() -> template + .sendSourceAndReceiveToResult(baseUrl + "/soap/senderFault", new StringSource(messagePayload), result)); + + TestObservationRegistryAssert.assertThat(observationRegistry).hasAnObservation(observationContextAssert -> + observationContextAssert + .hasLowCardinalityKeyValue("outcome", "fault") + .hasLowCardinalityKeyValue("exception", "SoapFaultClientException") + .hasLowCardinalityKeyValue("host", "localhost") + .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws") + .hasLowCardinalityKeyValue("localpart", "root") + .hasHighCardinalityKeyValue("path", "/soap/senderFault") + .hasContextualNameEqualTo("POST") + ); + } + + @Test + public void attachment() { + + template.sendSourceAndReceiveToResult(baseUrl + "/soap/attachment", new StringSource(messagePayload), message -> { + + SoapMessage soapMessage = (SoapMessage) message; + final String attachmentContent = "content"; + soapMessage.addAttachment("attachment-1", + new DataHandler(new ByteArrayDataSource(attachmentContent, "text/plain"))); + }, new StringResult()); + + TestObservationRegistryAssert.assertThat(observationRegistry).hasAnObservation(observationContextAssert -> + observationContextAssert + .hasLowCardinalityKeyValue("outcome", "success") + .hasLowCardinalityKeyValue("exception", "none") + .hasLowCardinalityKeyValue("host", "localhost") + .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws") + .hasLowCardinalityKeyValue("localpart", "root") + .hasHighCardinalityKeyValue("path", "/soap/attachment") + .hasContextualNameEqualTo("POST") + ); + } + + /** + * Servlet that returns and error message for a given status code. + */ + @SuppressWarnings("serial") + public static class ErrorServlet extends HttpServlet { + + private int sc; + + private ErrorServlet(int sc) { + this.sc = sc; + } + + @Override + protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.sendError(sc); + } + } + + /** + * Abstract SOAP Servlet + */ + @SuppressWarnings("serial") + public abstract static class AbstractSoapServlet extends HttpServlet { + + protected MessageFactory messageFactory = null; + + @Override + public void init(ServletConfig servletConfig) throws ServletException { + + super.init(servletConfig); + + try { + messageFactory = MessageFactory.newInstance(SOAPConstants.SOAP_1_2_PROTOCOL); + } catch (SOAPException ex) { + throw new ServletException("Unable to create message factory" + ex.getMessage()); + } + } + + @Override + public void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException { + + try { + MimeHeaders headers = getHeaders(req); + SOAPMessage request = messageFactory.createMessage(headers, req.getInputStream()); + SOAPMessage reply = onMessage(request); + + if (reply != null) { + reply.saveChanges(); + SOAPBody replyBody = reply.getSOAPBody(); + if (!replyBody.hasFault()) { + resp.setStatus(HttpServletResponse.SC_OK); + } else { + if (replyBody.getFault().getFaultCodeAsQName().equals(SOAPConstants.SOAP_SENDER_FAULT)) { + resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); + + } else { + resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + putHeaders(reply.getMimeHeaders(), resp); + reply.writeTo(resp.getOutputStream()); + } else { + resp.setStatus(HttpServletResponse.SC_ACCEPTED); + } + } catch (Exception ex) { + throw new ServletException("SAAJ POST failed " + ex.getMessage(), ex); + } + } + + private MimeHeaders getHeaders(HttpServletRequest httpServletRequest) { + + Enumeration enumeration = httpServletRequest.getHeaderNames(); + MimeHeaders headers = new MimeHeaders(); + + while (enumeration.hasMoreElements()) { + String headerName = (String) enumeration.nextElement(); + String headerValue = httpServletRequest.getHeader(headerName); + StringTokenizer values = new StringTokenizer(headerValue, ","); + while (values.hasMoreTokens()) { + headers.addHeader(headerName, values.nextToken().trim()); + } + } + + return headers; + } + + private void putHeaders(MimeHeaders headers, HttpServletResponse res) { + + Iterator it = headers.getAllHeaders(); + + while (it.hasNext()) { + MimeHeader header = (MimeHeader) it.next(); + String[] values = headers.getHeader(header.getName()); + for (String value : values) { + res.addHeader(header.getName(), value); + } + } + } + + protected abstract SOAPMessage onMessage(SOAPMessage message) throws SOAPException; + } + + @SuppressWarnings("serial") + public static class EchoSoapServlet extends AbstractSoap12WebServiceTemplateIntegrationTestCase.AbstractSoapServlet { + + @Override + protected SOAPMessage onMessage(SOAPMessage message) { + return message; + } + } + + @SuppressWarnings("serial") + public static class NoResponseSoapServlet extends AbstractSoap12WebServiceTemplateIntegrationTestCase.AbstractSoapServlet { + + @Override + protected SOAPMessage onMessage(SOAPMessage message) { + return null; + } + } + + @SuppressWarnings("serial") + public static class SoapReceiverFaultServlet extends AbstractSoap12WebServiceTemplateIntegrationTestCase.AbstractSoapServlet { + + @Override + protected SOAPMessage onMessage(SOAPMessage message) throws SOAPException { + + SOAPMessage response = messageFactory.createMessage(); + SOAPBody body = response.getSOAPBody(); + body.addFault(SOAPConstants.SOAP_RECEIVER_FAULT, "Receiver Fault"); + return response; + } + } + + @SuppressWarnings("serial") + public static class SoapSenderFaultServlet extends AbstractSoap12WebServiceTemplateIntegrationTestCase.AbstractSoapServlet { + + @Override + protected SOAPMessage onMessage(SOAPMessage message) throws SOAPException { + + SOAPMessage response = messageFactory.createMessage(); + SOAPBody body = response.getSOAPBody(); + body.addFault(SOAPConstants.SOAP_SENDER_FAULT, "Sender Fault"); + return response; + } + } + + @SuppressWarnings("serial") + public static class AttachmentsServlet extends AbstractSoap12WebServiceTemplateIntegrationTestCase.AbstractSoapServlet { + + @Override + protected SOAPMessage onMessage(SOAPMessage message) { + + assertThat(message.countAttachments()).isEqualTo(1); + return null; + } + } + +} diff --git a/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/MyEndpoint.java b/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/MyEndpoint.java new file mode 100644 index 000000000..1540791ac --- /dev/null +++ b/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/MyEndpoint.java @@ -0,0 +1,72 @@ +/* + * Copyright 2005-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 + * + * http://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.ws.server.endpoint.observation; + +import jakarta.xml.bind.annotation.XmlRootElement; +import org.springframework.ws.server.endpoint.annotation.Endpoint; +import org.springframework.ws.server.endpoint.annotation.PayloadRoot; +import org.springframework.ws.server.endpoint.annotation.RequestPayload; +import org.springframework.ws.server.endpoint.annotation.ResponsePayload; + +/** + * Testing endpoint. + * @author Johan Kindgren + */ +@Endpoint +public class MyEndpoint { + + private static final String NAMESPACE_URI = "http://springframework.org/spring-ws"; + + @PayloadRoot(namespace = NAMESPACE_URI, localPart = "request") + @ResponsePayload + public MyResponse handleRequest(@RequestPayload MyRequest request) { + MyResponse myResponse = new MyResponse(); + myResponse.setMessage("Hello " + request.getName()); + return myResponse; + } + + + + @XmlRootElement(namespace = NAMESPACE_URI, name = "request") + static class MyRequest { + + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + @XmlRootElement(namespace = NAMESPACE_URI, name = "response") + static class MyResponse { + + private String message; + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + } +} + + diff --git a/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/ObservationInterceptorIntegrationTest.java b/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/ObservationInterceptorIntegrationTest.java new file mode 100644 index 000000000..47aa838c3 --- /dev/null +++ b/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/ObservationInterceptorIntegrationTest.java @@ -0,0 +1,138 @@ +/* + * Copyright 2005-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 + * + * http://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.ws.server.endpoint.observation; + +import io.micrometer.observation.tck.TestObservationRegistry; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.servlet.ServletContextHandler; +import org.eclipse.jetty.servlet.ServletHolder; +import org.junit.jupiter.api.*; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.ws.client.core.WebServiceTemplate; +import org.springframework.ws.transport.http.MessageDispatcherServlet; +import org.springframework.ws.transport.support.FreePortScanner; + +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerConfigurationException; +import javax.xml.transform.TransformerFactory; + +import static io.micrometer.observation.tck.TestObservationRegistryAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Verifies observation for a WebService Endpoint. + * @author Johan Kindgren + */ +public class ObservationInterceptorIntegrationTest { + + private static AnnotationConfigWebApplicationContext applicationContext; + private WebServiceTemplate webServiceTemplate; + private TestObservationRegistry registry; + private static Server server; + + private final String requestPayload = ""; + + private TransformerFactory transformerFactory = TransformerFactory.newInstance(); + private Transformer transformer; + + private static String baseUrl; + + @BeforeAll + public static void startServer() throws Exception { + + int port = FreePortScanner.getFreePort(); + + baseUrl = "http://localhost:" + port + "/ws"; + + server = new Server(port); + ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS); + context.setContextPath("/"); + + applicationContext = new AnnotationConfigWebApplicationContext(); + applicationContext.scan(WebServiceConfig.class.getPackage().getName()); + + MessageDispatcherServlet dispatcherServlet = new MessageDispatcherServlet(applicationContext); + dispatcherServlet.setTransformWsdlLocations(true); + + ServletHolder servletHolder = new ServletHolder(dispatcherServlet); + context.addServlet(servletHolder, "/ws/*"); + + server.setHandler(context); + server.start(); + } + + @AfterAll + static void tearDown() throws Exception { + applicationContext.close(); + server.stop(); + } + + @BeforeEach + void setUp() throws TransformerConfigurationException { + + webServiceTemplate = applicationContext.getBean(WebServiceTemplate.class); + registry = applicationContext.getBean(TestObservationRegistry.class); + + transformer = transformerFactory.newTransformer(); + } + + @Test + void testObservationInterceptorBehavior() { + + MyEndpoint.MyRequest request = new MyEndpoint.MyRequest(); + request.setName("John"); + MyEndpoint.MyResponse response = (MyEndpoint.MyResponse) webServiceTemplate.marshalSendAndReceive(baseUrl, request); + + // Assertions based on expected behavior of ObservationInterceptor + assertNotNull(response); + + assertThat(registry).hasAnObservation(observationContextAssert -> + observationContextAssert + .hasLowCardinalityKeyValue("outcome", "success") + .hasLowCardinalityKeyValue("exception", "none") + .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws") + .hasLowCardinalityKeyValue("localpart", "request") + .hasLowCardinalityKeyValue("soapaction", "none") + .hasLowCardinalityKeyValue("path", "/ws") + .hasContextualNameEqualTo("POST /ws") + .hasNameEqualTo("webservice.server") + ); + } + + @Test + void testPathWithVariable() { + + MyEndpoint.MyRequest request = new MyEndpoint.MyRequest(); + request.setName("John"); + MyEndpoint.MyResponse response = (MyEndpoint.MyResponse) webServiceTemplate.marshalSendAndReceive(baseUrl + "/1234", request); + + // Assertions based on expected behavior of ObservationInterceptor + assertNotNull(response); + + assertThat(registry).hasAnObservation(observationContextAssert -> + observationContextAssert + .hasLowCardinalityKeyValue("outcome", "success") + .hasLowCardinalityKeyValue("exception", "none") + .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws") + .hasLowCardinalityKeyValue("localpart", "request") + .hasLowCardinalityKeyValue("soapaction", "none") + .hasLowCardinalityKeyValue("path", "/ws/{pathInfo}") + .hasContextualNameEqualTo("POST /ws/{pathInfo}") + .hasHighCardinalityKeyValue("pathinfo", "/1234") + .hasNameEqualTo("webservice.server") + ); + } +} \ No newline at end of file diff --git a/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/WebServiceConfig.java b/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/WebServiceConfig.java new file mode 100644 index 000000000..38026f7fa --- /dev/null +++ b/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/WebServiceConfig.java @@ -0,0 +1,77 @@ +/* + * Copyright 2005-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 + * + * http://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.ws.server.endpoint.observation; + +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.tck.TestObservationRegistry; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.oxm.jaxb.Jaxb2Marshaller; +import org.springframework.ws.client.core.WebServiceTemplate; +import org.springframework.ws.config.annotation.EnableWs; +import org.springframework.ws.config.annotation.WsConfigurerAdapter; +import org.springframework.ws.server.EndpointInterceptor; +import org.springframework.ws.support.ObservationHelper; + +import java.util.List; + +/** + * Verifies observation for a WebService Endpoint. + * @author Johan Kindgren + */ +@EnableWs +@Configuration +public class WebServiceConfig extends WsConfigurerAdapter { + + @Autowired + private ObservationRegistry observationRegistry; + + @Bean + public Jaxb2Marshaller marshaller() { + Jaxb2Marshaller marshaller = new Jaxb2Marshaller(); + marshaller.setClassesToBeBound(MyEndpoint.MyRequest.class, MyEndpoint.MyResponse.class); + return marshaller; + } + + @Bean + public ObservationRegistry observationRegistry() { + return TestObservationRegistry.create(); + } + + @Bean + public WebServiceTemplate webServiceTemplate(Jaxb2Marshaller marshaller) { + WebServiceTemplate webServiceTemplate = new WebServiceTemplate(); + webServiceTemplate.setMarshaller(marshaller); + webServiceTemplate.setUnmarshaller(marshaller); + return webServiceTemplate; + } + + @Bean + public EndpointInterceptor observationInterceptor() { + return new ObservationInterceptor(observationRegistry, observationHelper(),null); // Replace with your actual interceptor + } + @Bean + public ObservationHelper observationHelper() { + return new ObservationHelper(); + } + + @Override + public void addInterceptors(List interceptors) { + interceptors.add(observationInterceptor()); + } + +} \ No newline at end of file diff --git a/spring-ws-core/src/test/java/org/springframework/ws/support/ObservationHelperTest.java b/spring-ws-core/src/test/java/org/springframework/ws/support/ObservationHelperTest.java new file mode 100644 index 000000000..0a1b756e4 --- /dev/null +++ b/spring-ws-core/src/test/java/org/springframework/ws/support/ObservationHelperTest.java @@ -0,0 +1,65 @@ +package org.springframework.ws.support; + +import org.dom4j.Namespace; +import org.dom4j.dom.DOMElement; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.xml.transform.StringSource; +import org.xml.sax.InputSource; +import org.xml.sax.XMLReader; +import org.xmlunit.builder.Input; + +import javax.xml.namespace.QName; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; +import javax.xml.transform.sax.SAXSource; +import java.io.StringReader; + +import static org.assertj.core.api.Assertions.assertThat; + +class ObservationHelperTest { + + private ObservationHelper helper; + + @BeforeEach + void setUp() { + helper = new ObservationHelper(); + } + + @Test + void getRootElementStreamSource() { + + StringSource source = new StringSource(""); + + QName name = helper.getRootElement(source); + assertThat(name.getLocalPart()).isEqualTo("root"); + assertThat(name.getNamespaceURI()).isEqualTo("http://springframework.org/spring-ws"); + } + + @Test + void getRootElementDomSource() { + + DOMElement payloadElement = new DOMElement( + new org.dom4j.QName("root", + new Namespace(null, "http://springframework.org/spring-ws"))); + payloadElement.addElement("child"); + + QName name = helper.getRootElement(Input.from(payloadElement).build()); + assertThat(name.getLocalPart()).isEqualTo("root"); + assertThat(name.getNamespaceURI()).isEqualTo("http://springframework.org/spring-ws"); + } + + @Test + void getRootElementSaxSource() throws Exception { + StringReader reader = new StringReader(""); + + SAXParserFactory factory = SAXParserFactory.newInstance(); + SAXParser saxParser = factory.newSAXParser(); + XMLReader xmlReader = saxParser.getXMLReader(); + + SAXSource saxSource = new SAXSource(xmlReader, new InputSource(reader)); + QName name = helper.getRootElement(saxSource); + assertThat(name.getLocalPart()).isEqualTo("root"); + assertThat(name.getNamespaceURI()).isEqualTo("http://springframework.org/spring-ws"); + } +} \ No newline at end of file