diff --git a/.gitignore b/.gitignore index fc4ed6e86..365f5b205 100644 --- a/.gitignore +++ b/.gitignore @@ -24,5 +24,4 @@ _site credentials.yml .flattened-pom.xml -.mvn/.gradle-enterprise .mvn/.develocity diff --git a/.mvn/develocity.xml b/.mvn/develocity.xml deleted file mode 100644 index b54815e75..000000000 --- a/.mvn/develocity.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - https://ge.spring.io - - - false - true - true - - #{{'0.0.0.0'}} - - - - - true - - - - - ${env.GRADLE_ENTERPRISE_CACHE_USERNAME} - ${env.GRADLE_ENTERPRISE_CACHE_PASSWORD} - - - true - #{env['GRADLE_ENTERPRISE_CACHE_USERNAME'] != null and env['GRADLE_ENTERPRISE_CACHE_PASSWORD'] != null} - - - \ No newline at end of file diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml index 854714cee..fe8298f97 100644 --- a/.mvn/extensions.xml +++ b/.mvn/extensions.xml @@ -1,13 +1,8 @@ - com.gradle - develocity-maven-extension - 1.21.3 - - - com.gradle - common-custom-user-data-maven-extension - 2.0 + io.spring.develocity.conventions + develocity-conventions-maven-extension + 0.0.22 \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile index f479045e6..713d4af11 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -37,8 +37,7 @@ pipeline { options { timeout(time: 30, unit: 'MINUTES')} environment { ARTIFACTORY = credentials("${p['artifactory.credentials']}") - GRADLE_ENTERPRISE_CACHE = credentials("${p['gradle-enterprise-cache.credentials']}") - GRADLE_ENTERPRISE_ACCESS_KEY = credentials("${p['gradle-enterprise.access-key']}") + DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") } steps { script { @@ -63,8 +62,7 @@ pipeline { options { timeout(time: 30, unit: 'MINUTES')} environment { ARTIFACTORY = credentials("${p['artifactory.credentials']}") - GRADLE_ENTERPRISE_CACHE = credentials("${p['gradle-enterprise-cache.credentials']}") - GRADLE_ENTERPRISE_ACCESS_KEY = credentials("${p['gradle-enterprise.access-key']}") + DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") } steps { script { @@ -82,8 +80,7 @@ pipeline { options { timeout(time: 30, unit: 'MINUTES')} environment { ARTIFACTORY = credentials("${p['artifactory.credentials']}") - GRADLE_ENTERPRISE_CACHE = credentials("${p['gradle-enterprise-cache.credentials']}") - GRADLE_ENTERPRISE_ACCESS_KEY = credentials("${p['gradle-enterprise.access-key']}") + DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") } steps { script { @@ -101,8 +98,7 @@ pipeline { options { timeout(time: 30, unit: 'MINUTES')} environment { ARTIFACTORY = credentials("${p['artifactory.credentials']}") - GRADLE_ENTERPRISE_CACHE = credentials("${p['gradle-enterprise-cache.credentials']}") - GRADLE_ENTERPRISE_ACCESS_KEY = credentials("${p['gradle-enterprise.access-key']}") + DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") } steps { script { @@ -120,8 +116,7 @@ pipeline { options { timeout(time: 30, unit: 'MINUTES')} environment { ARTIFACTORY = credentials("${p['artifactory.credentials']}") - GRADLE_ENTERPRISE_CACHE = credentials("${p['gradle-enterprise-cache.credentials']}") - GRADLE_ENTERPRISE_ACCESS_KEY = credentials("${p['gradle-enterprise.access-key']}") + DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") } steps { script { @@ -137,8 +132,7 @@ pipeline { options { timeout(time: 30, unit: 'MINUTES')} environment { ARTIFACTORY = credentials("${p['artifactory.credentials']}") - GRADLE_ENTERPRISE_CACHE = credentials("${p['gradle-enterprise-cache.credentials']}") - GRADLE_ENTERPRISE_ACCESS_KEY = credentials("${p['gradle-enterprise.access-key']}") + DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") } steps { script { @@ -161,8 +155,7 @@ pipeline { KEYRING = credentials('spring-signing-secring.gpg') PASSPHRASE = credentials('spring-gpg-passphrase') STAGING_PROFILE_ID = credentials('spring-data-release-deployment-maven-central-staging-profile-id') - GRADLE_ENTERPRISE_CACHE = credentials("${p['gradle-enterprise-cache.credentials']}") - GRADLE_ENTERPRISE_ACCESS_KEY = credentials("${p['gradle-enterprise.access-key']}") + DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") } steps { @@ -234,8 +227,7 @@ pipeline { KEYRING = credentials('spring-signing-secring.gpg') PASSPHRASE = credentials('spring-gpg-passphrase') STAGING_PROFILE_ID = credentials('spring-data-release-deployment-maven-central-staging-profile-id') - GRADLE_ENTERPRISE_CACHE = credentials("${p['gradle-enterprise-cache.credentials']}") - GRADLE_ENTERPRISE_ACCESS_KEY = credentials("${p['gradle-enterprise.access-key']}") + DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") } steps { @@ -294,8 +286,7 @@ pipeline { environment { ARTIFACTORY = credentials("${p['artifactory.credentials']}") - GRADLE_ENTERPRISE_CACHE = credentials("${p['gradle-enterprise-cache.credentials']}") - GRADLE_ENTERPRISE_ACCESS_KEY = credentials("${p['gradle-enterprise.access-key']}") + DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") } steps { @@ -303,9 +294,7 @@ pipeline { docker.withRegistry('', "${p['dockerhub.credentials']}") { docker.image(p['docker.java.main.image']).inside(p['docker.java.inside.basic']) { sh 'MAVEN_OPTS="-Duser.name=jenkins -Duser.home=/tmp/jenkins-home" ' + - 'GRADLE_ENTERPRISE_CACHE_USERNAME=${GRADLE_ENTERPRISE_CACHE_USR} ' + - 'GRADLE_ENTERPRISE_CACHE_PASSWORD=${GRADLE_ENTERPRISE_CACHE_PSW} ' + - './mvnw -s settings.xml -Pjakarta-ee-10,distribute,docs ' + + './mvnw -s settings.xml -Pjakarta-ee-10,distribute,docs,default ' + '-Dartifactory.server=https://repo.spring.io ' + "-Dartifactory.username=${ARTIFACTORY_USR} " + "-Dartifactory.password=${ARTIFACTORY_PSW} " + diff --git a/README.adoc b/README.adoc index 00fdfc856..db09bb860 100644 --- a/README.adoc +++ b/README.adoc @@ -1,10 +1,11 @@ :doctype: book -image:https://spring.io/img/projects/logo-web-services.svg["Spring Web Services", width=50%, link="https://spring.io/projects/spring-ws#learn"] +image:https://spring.io/img/projects/logo-web-services.svg["Spring Web Services", width=20%, link="https://spring.io/projects/spring-ws#learn"] = Spring Web Services image:https://jenkins.spring.io/buildStatus/icon?job=spring-ws%2Fmain)[link='https://jenkins.spring.io/job/spring-ws/job/main/'] +image:https://jenkins.spring.io/buildStatus/icon?job=spring-ws%2F4.0.x)[link='https://jenkins.spring.io/job/spring-ws/job/4.0.x/'] Spring Web Services is a product of the Spring community focused on creating document-driven Web services. Spring Web Services aims to facilitate diff --git a/ci/Dockerfile b/ci/Dockerfile index 0a718839c..0a6137aab 100644 --- a/ci/Dockerfile +++ b/ci/Dockerfile @@ -1,4 +1,4 @@ -FROM eclipse-temurin:17.0.6_10-jdk-focal +FROM eclipse-temurin:17-jdk-jammy RUN apt-get update && apt-get install -y gpg diff --git a/ci/build-and-deploy-to-artifactory.sh b/ci/build-and-deploy-to-artifactory.sh index 5960f8b46..c1abc1e7f 100755 --- a/ci/build-and-deploy-to-artifactory.sh +++ b/ci/build-and-deploy-to-artifactory.sh @@ -4,13 +4,10 @@ set -euo pipefail RELEASE_TYPE=$1 -export GRADLE_ENTERPRISE_CACHE_USERNAME=${GRADLE_ENTERPRISE_CACHE_USR} -export GRADLE_ENTERPRISE_CACHE_PASSWORD=${GRADLE_ENTERPRISE_CACHE_PSW} - echo 'Deploying to Artifactory...' MAVEN_OPTS="-Duser.name=spring-builds+jenkins -Duser.home=/tmp/jenkins-home" ./mvnw \ -s settings.xml \ - -Pjakarta-ee-10,distribute,${RELEASE_TYPE} \ + -Pjakarta-ee-10,distribute,${RELEASE_TYPE},default \ -Dmaven.test.skip=true \ clean deploy -B diff --git a/ci/build-and-deploy-to-maven-central.sh b/ci/build-and-deploy-to-maven-central.sh index 9eb2a5730..2c93cdf9e 100755 --- a/ci/build-and-deploy-to-maven-central.sh +++ b/ci/build-and-deploy-to-maven-central.sh @@ -5,9 +5,6 @@ set -euo pipefail PROJECT_VERSION=$1 STAGING_REPOSITORY_ID=$2 -export GRADLE_ENTERPRISE_CACHE_USERNAME=${GRADLE_ENTERPRISE_CACHE_USR} -export GRADLE_ENTERPRISE_CACHE_PASSWORD=${GRADLE_ENTERPRISE_CACHE_PSW} - echo 'Staging on Maven Central...' GNUPGHOME=/tmp/gpghome @@ -18,7 +15,7 @@ cp $KEYRING $GNUPGHOME MAVEN_OPTS="-Duser.name=spring-builds+jenkins -Duser.home=/tmp/jenkins-home" ./mvnw \ -s settings.xml \ - -Pjakarta-ee-10,distribute,central \ + -Pjakarta-ee-10,distribute,central,default \ -Dmaven.test.skip=true \ -Dgpg.passphrase=${PASSPHRASE} \ -Dgpg.secretKeyring=${GNUPGHOME}/secring.gpg \ diff --git a/ci/pipeline.properties b/ci/pipeline.properties index 2aea08744..f52f0d9b3 100644 --- a/ci/pipeline.properties +++ b/ci/pipeline.properties @@ -1,7 +1,7 @@ # Java versions -java.legacy.tag=8u362-b09-jdk-focal -java.main.tag=17.0.6_10-jdk-focal -java.next.tag=20-jdk-jammy +java.legacy.tag=8-jdk-focal +java.main.tag=17-jdk-jammy +java.next.tag=21-jdk-jammy # Docker container images - standard docker.java.legacy.image=library/eclipse-temurin:${java.legacy.tag} @@ -18,5 +18,4 @@ docker.java.inside.basic=-v $HOME:/tmp/jenkins-home # Credentials artifactory.credentials=02bd1690-b54f-4c9f-819d-a77cb7a9822c dockerhub.credentials=hub.docker.com-springbuildmaster -gradle-enterprise-cache.credentials=gradle_enterprise_cache_user -gradle-enterprise.access-key=gradle_enterprise_secret_access_key +develocity.access-key=gradle_enterprise_secret_access_key diff --git a/ci/smoke-test-against-artifactory.sh b/ci/smoke-test-against-artifactory.sh index d2255919c..a8a024aad 100755 --- a/ci/smoke-test-against-artifactory.sh +++ b/ci/smoke-test-against-artifactory.sh @@ -9,7 +9,7 @@ echo 'Smoke test against Artifactory...' cd smoke-tests MAVEN_OPTS="-Duser.name=spring-builds+jenkins -Duser.home=/tmp/jenkins-home" ./mvnw \ - -Partifactory \ + -Partifactory,default \ -Dspring-ws.version="${PROJECT_VERSION}" \ clean dependency:purge-local-repository verify -B -U diff --git a/ci/smoke-test-against-maven-central.sh b/ci/smoke-test-against-maven-central.sh index f39235b39..2993edeb3 100755 --- a/ci/smoke-test-against-maven-central.sh +++ b/ci/smoke-test-against-maven-central.sh @@ -10,7 +10,7 @@ echo 'Smoke test against Maven Central...' cd smoke-tests MAVEN_OPTS="-Duser.name=spring-builds+jenkins -Duser.home=/tmp/jenkins-home" ./mvnw \ - -Pmaven-central \ + -Pmaven-central,default \ -DstagingRepositoryId="${STAGING_REPOSITORY_ID}" \ -Dspring-ws.version="${PROJECT_VERSION}" \ clean dependency:purge-local-repository verify -B -U diff --git a/ci/test.sh b/ci/test.sh index ba458fba6..28a0dce23 100755 --- a/ci/test.sh +++ b/ci/test.sh @@ -2,9 +2,10 @@ set -euo pipefail -export GRADLE_ENTERPRISE_CACHE_USERNAME=${GRADLE_ENTERPRISE_CACHE_USR} -export GRADLE_ENTERPRISE_CACHE_PASSWORD=${GRADLE_ENTERPRISE_CACHE_PSW} - MAVEN_OPTS="-Duser.name=spring-builds+jenkins -Duser.home=/tmp/jenkins-home" \ ./mvnw -s settings.xml \ - -P${PROFILE} clean dependency:list test -Dsort -B -U + -P${PROFILE},default clean dependency:list test -Dsort -B -U +# Provides for testing org.springframework.ws.observation.ObservationInWsConfigurerTests separately +MAVEN_OPTS="-Duser.name=spring-builds+jenkins -Duser.home=/tmp/jenkins-home" \ + ./mvnw -s settings.xml \ + -P-default,${PROFILE},observation clean dependency:list test -Dsort -B -U diff --git a/pom.xml b/pom.xml index ec40d5e69..4a5fa8e04 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.ws spring-ws - 4.0.12-SNAPSHOT + 4.1.0-SNAPSHOT pom Spring Web Services @@ -109,7 +109,7 @@ 2.1.3 2.1.3 6.0.0 - 3.0.1 + 3.0.2 4.0.2 4.0.5 1.1.6 @@ -119,33 +119,33 @@ 2.19.0 1.4.7 4.11.0 - 3.0.3 + 3.0.4 2.0.6 4.3.5 - 6.0.20 - 6.1.9 + 6.1.13 + 6.3.3 - 6.0.21-SNAPSHOT - 6.1.10-SNAPSHOT + 6.1.14-SNAPSHOT + 6.3.4-SNAPSHOT - 6.1.6 - 6.2.4 + 6.2.0 + 6.4.1 - 6.1.7-SNAPSHOT - 6.2.5-SNAPSHOT + 6.2.1-SNAPSHOT + 6.4.2-SNAPSHOT - 2.1.5 + 2.3.3 2.0.1 2.1.0 6.5.1 1.6.3 - 2.4.3 - 3.0.0 + 3.0.3 + 3.0.3 3.0.4 2.3.1 1.6 @@ -154,6 +154,7 @@ 1.78 1.14.13 0.0.5 + 1.13.5 @@ -181,7 +182,11 @@ pom import - + + jakarta.xml.soap + jakarta.xml.soap-api + ${jakarta-soap.version} + org.eclipse.jetty jetty-bom @@ -195,7 +200,22 @@ guava 32.1.3-jre - + + com.sun.xml.messaging.saaj + saaj-impl + ${saaj-impl.version} + + + org.jvnet.staxex + stax-ex + ${stax.version} + + + jakarta.activation + jakarta.activation-api + + + @@ -337,6 +357,19 @@ + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + true + true + + + + com.gradle develocity-maven-extension @@ -649,7 +682,7 @@ com.thoughtworks.xstream xstream - 1.4.20 + 1.4.21 diff --git a/settings.xml b/settings.xml index 28f0d1339..b474fd4ff 100644 --- a/settings.xml +++ b/settings.xml @@ -24,11 +24,6 @@ ${env.ARTIFACTORY_USR} ${env.ARTIFACTORY_PSW} - - ge.spring.io - ${env.GRADLE_ENTERPRISE_CACHE_USERNAME} - ${env.GRADLE_ENTERPRISE_CACHE_PASSWORD} - \ No newline at end of file diff --git a/spring-ws-bom/pom.xml b/spring-ws-bom/pom.xml index 4750f6c14..743147571 100644 --- a/spring-ws-bom/pom.xml +++ b/spring-ws-bom/pom.xml @@ -6,7 +6,7 @@ org.springframework.ws spring-ws-bom - 4.0.12-SNAPSHOT + 4.1.0-SNAPSHOT pom Spring Web Services - BOM @@ -71,27 +71,27 @@ org.springframework.ws spring-ws-core - 4.0.12-SNAPSHOT + 4.1.0-SNAPSHOT org.springframework.ws spring-ws-security - 4.0.12-SNAPSHOT + 4.1.0-SNAPSHOT org.springframework.ws spring-ws-support - 4.0.12-SNAPSHOT + 4.1.0-SNAPSHOT org.springframework.ws spring-ws-test - 4.0.12-SNAPSHOT + 4.1.0-SNAPSHOT org.springframework.ws spring-xml - 4.0.12-SNAPSHOT + 4.1.0-SNAPSHOT diff --git a/spring-ws-core/pom.xml b/spring-ws-core/pom.xml index 16998873f..51694f05c 100644 --- a/spring-ws-core/pom.xml +++ b/spring-ws-core/pom.xml @@ -7,7 +7,7 @@ org.springframework.ws spring-ws - 4.0.12-SNAPSHOT + 4.1.0-SNAPSHOT spring-ws-core @@ -34,6 +34,12 @@ org.springframework spring-oxm + + + jakarta.xml.bind + jakarta.xml.bind-api + + org.springframework @@ -60,6 +66,12 @@ + + io.micrometer + micrometer-observation + ${micrometer-observation.version} + true + org.springframework spring-test @@ -244,10 +256,125 @@ spring-webflux test + + io.micrometer + micrometer-observation-test + ${micrometer-observation.version} + test + + + default + + true + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + **/ObservationInWsConfigurerTests.* + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + **/ObservationInWsConfigurerTests.* + + + + + + + + observation + + 3.3.6 + + + + + org.springframework.boot + spring-boot-dependencies + pom + import + ${spring-boot.version} + + + + + + io.micrometer + micrometer-tracing-bridge-brave + 1.3.4 + test + + + org.springframework.boot + spring-boot-starter-web + test + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-actuator + test + + + org.assertj + assertj-core + test + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + **/wsdl/**/* + + + **/ObservationInWsConfigurerTests.* + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + true + + + spring-boot.run.profiles + observation + + + + **/ObservationInWsConfigurerTests.java + + + + + + docs 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/soap/addressing/server/AnnotationActionEndpointMapping.java b/spring-ws-core/src/main/java/org/springframework/ws/soap/addressing/server/AnnotationActionEndpointMapping.java index 465af85d5..f9905c5c4 100644 --- a/spring-ws-core/src/main/java/org/springframework/ws/soap/addressing/server/AnnotationActionEndpointMapping.java +++ b/spring-ws-core/src/main/java/org/springframework/ws/soap/addressing/server/AnnotationActionEndpointMapping.java @@ -21,9 +21,7 @@ import java.net.URI; import java.net.URISyntaxException; -import org.springframework.aop.support.AopUtils; -import org.springframework.beans.BeansException; -import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.util.StringUtils; import org.springframework.ws.server.endpoint.MethodEndpoint; @@ -54,11 +52,12 @@ * incoming message. * * @author Arjen Poutsma + * @author Corneil du Plessis (with thanks to Chris Bono) * @see Action * @see Address * @since 1.5.0 */ -public class AnnotationActionEndpointMapping extends AbstractActionMethodEndpointMapping implements BeanPostProcessor { +public class AnnotationActionEndpointMapping extends AbstractActionMethodEndpointMapping implements SmartInitializingSingleton { /** Returns the 'endpoint' annotation type. Default is {@link Endpoint}. */ protected Class getEndpointAnnotationType() { @@ -133,16 +132,8 @@ private URI getActionUri(String action, MethodEndpoint methodEndpoint) { } } - @Override - public final Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { - return bean; - } - - @Override - public final Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { - if (AopUtils.getTargetClass(bean).getAnnotation(getEndpointAnnotationType()) != null) { - registerMethods(bean); - } - return bean; + public void afterSingletonsInstantiated() { + this.getApplicationContext().getBeansWithAnnotation(this.getEndpointAnnotationType()) + .values().forEach(this::registerMethods); } } 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/main/java/org/springframework/ws/transport/http/HttpComponents5ClientFactory.java b/spring-ws-core/src/main/java/org/springframework/ws/transport/http/HttpComponents5ClientFactory.java index 7d23dc1de..8446561c7 100644 --- a/spring-ws-core/src/main/java/org/springframework/ws/transport/http/HttpComponents5ClientFactory.java +++ b/spring-ws-core/src/main/java/org/springframework/ws/transport/http/HttpComponents5ClientFactory.java @@ -211,13 +211,12 @@ public CloseableHttpClient getObject() throws Exception { if (this.connectionManagerBuilderCustomizer != null) { this.connectionManagerBuilderCustomizer.customize(connectionManagerBuilder); } - this.connectionManager = connectionManagerBuilder.build(); applyMaxConnectionsPerHost(connectionManager); RequestConfig.Builder requestConfigBuilder = RequestConfig.custom() // - .setConnectionRequestTimeout(Timeout.of(connectionTimeout)) // + .setConnectTimeout(Timeout.of(connectionTimeout)) // .setResponseTimeout(Timeout.of(readTimeout)); HttpClientBuilder httpClientBuilder = HttpClientBuilder.create() // 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/observation/ObservationInWsConfigurerTests.java b/spring-ws-core/src/test/java/org/springframework/ws/observation/ObservationInWsConfigurerTests.java new file mode 100644 index 000000000..5d0a8b259 --- /dev/null +++ b/spring-ws-core/src/test/java/org/springframework/ws/observation/ObservationInWsConfigurerTests.java @@ -0,0 +1,74 @@ +package org.springframework.ws.observation; + +import io.micrometer.observation.ObservationRegistry; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.ws.config.annotation.WsConfigurerAdapter; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * This test is executed by using observation Maven profile and explicitly excluding the default profile. + * This test relies on dependencies that cause problems with other tests in Spring WS Core. + * @author Corneil du Plessis + */ +@Profile("observation") +@SpringBootTest(classes = ObservationInWsConfigurerTests.WsTracingApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class ObservationInWsConfigurerTests { + @LocalServerPort + private int port; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + void responseShouldNotBeEmpty() { + final ResponseEntity response = + restTemplate.getForEntity("http://localhost:" + port + "/test", String.class); + assertThat(response.getBody()).isNotEmpty(); + } + + @RestController + public static class TestEndpoint { + private static final Logger log = LoggerFactory.getLogger(TestEndpoint.class); + + @GetMapping("/test") + public String test() { + log.info("test"); + return MDC.get("spanId"); + } + } + + @Configuration + public static class WsConfig extends WsConfigurerAdapter { + private final ObservationRegistry observationRegistry; + + public WsConfig(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + } + } + + @SpringBootApplication + @Import(WsConfig.class) + public static class WsTracingApplication { + public static void main(String[] args) { + SpringApplication.run(WsTracingApplication.class, args); + } + + } +} 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 diff --git a/spring-ws-security/pom.xml b/spring-ws-security/pom.xml index b68e8392d..c8638daad 100644 --- a/spring-ws-security/pom.xml +++ b/spring-ws-security/pom.xml @@ -7,7 +7,7 @@ org.springframework.ws spring-ws - 4.0.12-SNAPSHOT + 4.1.0-SNAPSHOT spring-ws-security @@ -178,7 +178,6 @@ org.jvnet.staxex stax-ex - ${stax.version} jakarta.activation diff --git a/spring-ws-security/src/main/java/org/springframework/ws/soap/security/wss4j2/Wss4jSecurityInterceptor.java b/spring-ws-security/src/main/java/org/springframework/ws/soap/security/wss4j2/Wss4jSecurityInterceptor.java index 3b457cf46..989772f4b 100644 --- a/spring-ws-security/src/main/java/org/springframework/ws/soap/security/wss4j2/Wss4jSecurityInterceptor.java +++ b/spring-ws-security/src/main/java/org/springframework/ws/soap/security/wss4j2/Wss4jSecurityInterceptor.java @@ -20,8 +20,11 @@ import java.security.Principal; import java.security.cert.X509Certificate; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Collectors; import javax.security.auth.callback.Callback; import javax.security.auth.callback.CallbackHandler; @@ -59,6 +62,9 @@ import org.w3c.dom.Document; import org.w3c.dom.Element; +import static java.util.Collections.emptyList; +import static java.util.Collections.unmodifiableList; + /** * A WS-Security endpoint interceptor based on Apache's WSS4J. This interceptor supports messages created by the * {@link org.springframework.ws.soap.axiom.AxiomSoapMessageFactory} and the @@ -138,7 +144,8 @@ * @author Jamin Hitchcock * @author Rob Leland * @author Lars Uffmann - * @see Apache WSS4J 2.0 + * @author Andreas Winter + * @see Apache WSS4J 2.0+ * @since 2.3.0 */ public class Wss4jSecurityInterceptor extends AbstractWsSecurityInterceptor implements InitializingBean { @@ -194,6 +201,8 @@ public class Wss4jSecurityInterceptor extends AbstractWsSecurityInterceptor impl // To maintain same behavior as default, this flag is set to true private boolean removeSecurityHeader = true; + private List signatureSubjectDnPatterns = emptyList(); + /** * Create a {@link WSSecurityEngine} by default. */ @@ -225,6 +234,15 @@ public void setSecurementActor(String securementActor) { handler.setOption(WSHandlerConstants.ACTOR, securementActor); } + /** + * Defines whether to use a single certificate or a whole certificate chain when constructing + * a BinarySecurityToken used for direct reference in signature. + * The default is "true", meaning that only a single certificate is used. + */ + public void setSecurementSignatureSingleCertificate(boolean useSingleCertificate) { + handler.setOption(WSHandlerConstants.USE_SINGLE_CERTIFICATE, useSingleCertificate); + } + public void setSecurementEncryptionCrypto(Crypto securementEncryptionCrypto) { handler.setSecurementEncryptionCrypto(securementEncryptionCrypto); } @@ -485,6 +503,19 @@ public void setValidationSignatureCrypto(Crypto signatureCrypto) { this.validationSignatureCrypto = signatureCrypto; } + /** + * Certificate constraints which will be applied to the subject DN of the certificate used for + * signature validation, after trust verification of the certificate chain associated with the + * certificate. + * + * @param patterns A list of regex patterns which will be applied to the subject DN. + * + * @see WSS4J configuration: SIG_SUBJECT_CERT_CONSTRAINTS + */ + public void setValidationSubjectDnConstraints(List patterns) { + signatureSubjectDnPatterns = patterns; + } + /** Whether to enable signatureConfirmation or not. By default signatureConfirmation is enabled */ public void setEnableSignatureConfirmation(boolean enableSignatureConfirmation) { @@ -670,6 +701,7 @@ protected RequestData initializeRequestData(MessageContext messageContext) { // allow for qualified password types for .Net interoperability requestData.setAllowNamespaceQualifiedPasswordTypes(true); + requestData.setSubjectCertConstraints(signatureSubjectDnPatterns); return requestData; } @@ -710,6 +742,8 @@ protected RequestData initializeValidationRequestData(MessageContext messageCont // allow for qualified password types for .Net interoperability requestData.setAllowNamespaceQualifiedPasswordTypes(true); + requestData.setSubjectCertConstraints(signatureSubjectDnPatterns); + return requestData; } diff --git a/spring-ws-security/src/main/java/org/springframework/ws/soap/security/wss4j2/callback/package.html b/spring-ws-security/src/main/java/org/springframework/ws/soap/security/wss4j2/callback/package.html index 8335862b9..60a429c30 100644 --- a/spring-ws-security/src/main/java/org/springframework/ws/soap/security/wss4j2/callback/package.html +++ b/spring-ws-security/src/main/java/org/springframework/ws/soap/security/wss4j2/callback/package.html @@ -1,5 +1,5 @@ -Contains CallbackHandler implementations for WSS4J 2.0. +Contains CallbackHandler implementations for WSS4J 2.0+. \ No newline at end of file diff --git a/spring-ws-security/src/main/java/org/springframework/ws/soap/security/wss4j2/package.html b/spring-ws-security/src/main/java/org/springframework/ws/soap/security/wss4j2/package.html index 08abe8df9..f6157746c 100644 --- a/spring-ws-security/src/main/java/org/springframework/ws/soap/security/wss4j2/package.html +++ b/spring-ws-security/src/main/java/org/springframework/ws/soap/security/wss4j2/package.html @@ -1,6 +1,6 @@ -Contains classes for using the Apache WSS4J 2.0 WS-Security implementation +Contains classes for using the Apache WSS4J 2.0+ WS-Security implementation within Spring-WS. diff --git a/spring-ws-security/src/main/java/org/springframework/ws/soap/security/wss4j2/support/package.html b/spring-ws-security/src/main/java/org/springframework/ws/soap/security/wss4j2/support/package.html index d2e986762..f8340f6cf 100644 --- a/spring-ws-security/src/main/java/org/springframework/ws/soap/security/wss4j2/support/package.html +++ b/spring-ws-security/src/main/java/org/springframework/ws/soap/security/wss4j2/support/package.html @@ -1,5 +1,5 @@ -Contains support classes for working with WSS4J 2.0. +Contains support classes for working with WSS4J 2.0+. \ No newline at end of file diff --git a/spring-ws-security/src/test/java/org/springframework/ws/soap/security/wss4j2/Wss4jMessageInterceptorSignTestCase.java b/spring-ws-security/src/test/java/org/springframework/ws/soap/security/wss4j2/Wss4jMessageInterceptorSignTestCase.java index 0e8490af9..729221b73 100644 --- a/spring-ws-security/src/test/java/org/springframework/ws/soap/security/wss4j2/Wss4jMessageInterceptorSignTestCase.java +++ b/spring-ws-security/src/test/java/org/springframework/ws/soap/security/wss4j2/Wss4jMessageInterceptorSignTestCase.java @@ -18,7 +18,9 @@ import static org.assertj.core.api.Assertions.*; +import java.util.List; import java.util.Properties; +import java.util.regex.Pattern; import org.junit.jupiter.api.Test; import org.springframework.ws.WebServiceMessage; @@ -121,4 +123,36 @@ public void testSignResponseWithSignatureUser() throws Exception { assertXpathExists("Absent SignatureConfirmation element", "/SOAP-ENV:Envelope/SOAP-ENV:Header/wsse:Security/ds:Signature", document); } + + @Test + public void testValidateCertificateSubjectDnConstraintsShouldMatchSubject() throws Exception { + SoapMessage message = createSignedTestSoapMessage(); + MessageContext messageContext = getSoap11MessageContext(createSignedTestSoapMessage()); + interceptor.secureMessage(message, messageContext); + + interceptor.setValidationActions("Signature"); + interceptor.setValidationSubjectDnConstraints(List.of(Pattern.compile(".*"))); + assertThatCode(() ->interceptor.validateMessage(message, messageContext)).doesNotThrowAnyException(); + } + + @Test + public void testValidateCertificateSubjectDnConstraintsShouldFailForNotMatchingSubject() throws Exception { + SoapMessage message = createSignedTestSoapMessage(); + MessageContext messageContext = getSoap11MessageContext(createSignedTestSoapMessage()); + interceptor.secureMessage(message, messageContext); + + interceptor.setValidationActions("Signature"); + interceptor.setValidationSubjectDnConstraints(List.of(Pattern.compile("O=Some Other Company"))); + Throwable catched = catchThrowable(() -> interceptor.validateMessage(message, messageContext)); + assertThat(catched).isInstanceOf(Wss4jSecurityValidationException.class); + } + + private SoapMessage createSignedTestSoapMessage() throws Exception { + interceptor.setSecurementActions("Signature"); + interceptor.setSecurementSignatureKeyIdentifier("DirectReference"); + interceptor.setSecurementSignatureSingleCertificate(false); + interceptor.setSecurementPassword("123456"); + interceptor.setSecurementUsername("testkey"); + return loadSoap11Message("empty-soap.xml"); + } } diff --git a/spring-ws-security/src/test/resources/private.jks b/spring-ws-security/src/test/resources/private.jks index b3b10e36f..15a4ebafe 100644 Binary files a/spring-ws-security/src/test/resources/private.jks and b/spring-ws-security/src/test/resources/private.jks differ diff --git a/spring-ws-support/pom.xml b/spring-ws-support/pom.xml index 737af9ab0..3fcbdef59 100644 --- a/spring-ws-support/pom.xml +++ b/spring-ws-support/pom.xml @@ -7,7 +7,7 @@ org.springframework.ws spring-ws - 4.0.12-SNAPSHOT + 4.1.0-SNAPSHOT spring-ws-support diff --git a/spring-ws-test/pom.xml b/spring-ws-test/pom.xml index fcd59c699..e6ef5f4e5 100644 --- a/spring-ws-test/pom.xml +++ b/spring-ws-test/pom.xml @@ -7,7 +7,7 @@ org.springframework.ws spring-ws - 4.0.12-SNAPSHOT + 4.1.0-SNAPSHOT spring-ws-test diff --git a/spring-xml/pom.xml b/spring-xml/pom.xml index bf6686365..8ee17386b 100644 --- a/spring-xml/pom.xml +++ b/spring-xml/pom.xml @@ -7,7 +7,7 @@ org.springframework.ws spring-ws - 4.0.12-SNAPSHOT + 4.1.0-SNAPSHOT spring-xml @@ -44,7 +44,11 @@ 4.4 test - + + com.sun.xml.messaging.saaj + saaj-impl + ${saaj-impl.version} + diff --git a/src/main/asciidoctor/security.adoc b/src/main/asciidoctor/security.adoc index c43ce797d..2aff6f425 100644 --- a/src/main/asciidoctor/security.adoc +++ b/src/main/asciidoctor/security.adoc @@ -7,7 +7,7 @@ This chapter explains how to add WS-Security aspects to your Web services. We fo * *Digital signatures*: The digital signature of a message is a piece of information based on both the document and the signer's private key. It is created through the use of a hash function and a private signing function (encrypting with the signer's private key). -* *Encryption and Decryption*: Encryption is the process of transforming data into a form that is impossible to read without the appropriate key. It is mainly used to keep information hidden from anyone for whom it is not intended. Decryption is the reverse of encryption. It is the process of transforming encrypted data back into an readable form. +* *Encryption and Decryption*: Encryption is the process of transforming data into a form that is impossible to read without the appropriate key. It is mainly used to keep information hidden from anyone for whom it is not intended. Decryption is the reverse of encryption. It is the process of transforming encrypted data back into a readable form. These three areas are implemented by using the `XwsSecurityInterceptor` or `Wss4jSecurityInterceptor`, which we describe in <> and <>, respectively @@ -302,9 +302,9 @@ The `SpringDigestPasswordValidationCallbackHandler` requires a Spring Security ` [[security-certificate-authentication]] ==== Certificate Authentication -A more secure way of authentication uses X509 certificates. In this scenario, the SOAP message contains a`BinarySecurityToken`, which contains a Base 64-encoded version of a X509 certificate. The certificate is used by the recipient to authenticate. The certificate stored in the message is also used to sign the message (see <>). +A more secure way of authentication uses X509 certificates. In this scenario, the SOAP message contains a `BinarySecurityToken`, which contains a Base 64-encoded version of a X509 certificate. The certificate is used by the recipient to authenticate. The certificate stored in the message is also used to sign the message (see <>). -To make sure that all incoming SOAP messages carry a`BinarySecurityToken`, the security policy file should contain a `RequireSignature` element. This element can further carry other elements, which are covered in <>. You can find a reference of possible child elements http://java.sun.com/webservices/docs/1.6/tutorial/doc/XWS-SecurityIntro4.html#wp565769[here]. The following listing shows how to define a `RequireSignature` element: +To make sure that all incoming SOAP messages carry a `BinarySecurityToken`, the security policy file should contain a `RequireSignature` element. This element can further carry other elements, which are covered in <>. You can find a reference of possible child elements http://java.sun.com/webservices/docs/1.6/tutorial/doc/XWS-SecurityIntro4.html#wp565769[here]. The following listing shows how to define a `RequireSignature` element: ==== [source,xml]