diff --git a/sofa-boot-project/sofa-boot-autoconfigure/src/main/java/com/alipay/sofa/boot/autoconfigure/web/SofaProblemDetailAutoConfiguration.java b/sofa-boot-project/sofa-boot-autoconfigure/src/main/java/com/alipay/sofa/boot/autoconfigure/web/SofaProblemDetailAutoConfiguration.java new file mode 100644 index 000000000..ddf3bae98 --- /dev/null +++ b/sofa-boot-project/sofa-boot-autoconfigure/src/main/java/com/alipay/sofa/boot/autoconfigure/web/SofaProblemDetailAutoConfiguration.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 com.alipay.sofa.boot.autoconfigure.web; + +import com.alipay.sofa.boot.autoconfigure.rpc.SofaRpcAutoConfiguration; +import com.alipay.sofa.boot.autoconfigure.runtime.SofaRuntimeAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.core.env.Environment; +import org.springframework.http.ProblemDetail; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +/** + * Auto-configuration for SOFA ProblemDetail exception handling. + */ +@AutoConfiguration(after = { SofaRuntimeAutoConfiguration.class, SofaRpcAutoConfiguration.class }) +@ConditionalOnClass({ ProblemDetail.class, ResponseEntityExceptionHandler.class }) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnProperty(prefix = SofaProblemDetailProperties.PREFIX, name = "enabled", havingValue = "true", matchIfMissing = true) +@EnableConfigurationProperties(SofaProblemDetailProperties.class) +public class SofaProblemDetailAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(value = ResponseEntityExceptionHandler.class, ignored = { + SofaRuntimeProblemDetailExceptionHandler.class, + SofaRpcProblemDetailExceptionHandler.class }) + public SofaProblemDetailExceptionHandler sofaProblemDetailExceptionHandler(SofaProblemDetailProperties properties, + Environment environment) { + return new SofaProblemDetailExceptionHandler(properties, environment); + } + + @Bean + @ConditionalOnClass(name = "com.alipay.sofa.runtime.api.ServiceRuntimeException") + @ConditionalOnMissingBean(SofaRuntimeProblemDetailExceptionHandler.class) + public SofaRuntimeProblemDetailExceptionHandler sofaRuntimeProblemDetailExceptionHandler(SofaProblemDetailProperties properties, + Environment environment) { + return new SofaRuntimeProblemDetailExceptionHandler(properties, environment); + } + + @Bean + @ConditionalOnClass(name = { "com.alipay.sofa.rpc.boot.common.SofaBootRpcRuntimeException", + "com.alipay.sofa.rpc.core.exception.SofaRpcException", + "com.alipay.sofa.rpc.core.exception.RpcErrorType" }) + @ConditionalOnMissingBean(SofaRpcProblemDetailExceptionHandler.class) + public SofaRpcProblemDetailExceptionHandler sofaRpcProblemDetailExceptionHandler(SofaProblemDetailProperties properties, + Environment environment) { + return new SofaRpcProblemDetailExceptionHandler(properties, environment); + } +} diff --git a/sofa-boot-project/sofa-boot-autoconfigure/src/main/java/com/alipay/sofa/boot/autoconfigure/web/SofaProblemDetailExceptionHandler.java b/sofa-boot-project/sofa-boot-autoconfigure/src/main/java/com/alipay/sofa/boot/autoconfigure/web/SofaProblemDetailExceptionHandler.java new file mode 100644 index 000000000..04d688e5a --- /dev/null +++ b/sofa-boot-project/sofa-boot-autoconfigure/src/main/java/com/alipay/sofa/boot/autoconfigure/web/SofaProblemDetailExceptionHandler.java @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 com.alipay.sofa.boot.autoconfigure.web; + +import org.springframework.core.env.Environment; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.URI; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Base MVC exception handler that customizes framework-generated ProblemDetail + * responses with SOFA-specific metadata. + */ +@RestControllerAdvice +@Order(Ordered.LOWEST_PRECEDENCE) +public class SofaProblemDetailExceptionHandler extends ResponseEntityExceptionHandler { + + static final URI ABOUT_BLANK = URI.create("about:blank"); + static final URI RUNTIME_EXCEPTION_TYPE = URI + .create("https://sofastack.io/errors/runtime-exception"); + static final URI RPC_EXCEPTION_TYPE = URI + .create("https://sofastack.io/errors/rpc-exception"); + static final URI RPC_CONFIGURATION_TYPE = URI + .create("https://sofastack.io/errors/rpc-configuration-exception"); + + private static final Pattern ERROR_CODE_PATTERN = Pattern + .compile("(SOFA-BOOT-\\d{2}-\\d{5})"); + + private final SofaProblemDetailProperties properties; + private final String applicationName; + + public SofaProblemDetailExceptionHandler(SofaProblemDetailProperties properties, + Environment environment) { + this.properties = properties; + this.applicationName = environment.getProperty("spring.application.name"); + } + + @Override + protected org.springframework.http.ResponseEntity createResponseEntity(@Nullable Object body, + HttpHeaders headers, + HttpStatusCode statusCode, + WebRequest request) { + if (body instanceof ProblemDetail problemDetail) { + customize(problemDetail, request, null); + } + return super.createResponseEntity(body, headers, statusCode, request); + } + + protected org.springframework.http.ResponseEntity createSofaResponseEntity(Exception ex, + HttpStatus status, + String detail, + URI type, + String title, + WebRequest request) { + ProblemDetail problemDetail = createProblemDetail(ex, status, detail, null, null, request); + problemDetail.setType(type); + problemDetail.setTitle(title); + customize(problemDetail, request, ex); + return super.createResponseEntity(problemDetail, new HttpHeaders(), status, request); + } + + void customize(ProblemDetail problemDetail, WebRequest request, @Nullable Throwable throwable) { + if (problemDetail.getType() == null || ABOUT_BLANK.equals(problemDetail.getType())) { + problemDetail.setType(this.properties.getDefaultType()); + } + + if (problemDetail.getInstance() == null) { + URI instance = resolveInstance(request); + if (instance != null) { + problemDetail.setInstance(instance); + } + } + + if (this.properties.isIncludeServiceInfo() && StringUtils.hasText(this.applicationName) + && !hasProperty(problemDetail, "service")) { + problemDetail.setProperty("service", this.applicationName); + } + + if (throwable == null) { + return; + } + + String errorCode = extractErrorCode(throwable); + if (StringUtils.hasText(errorCode) && !hasProperty(problemDetail, "errorCode")) { + problemDetail.setProperty("errorCode", errorCode); + } + + if (this.properties.isIncludeStackTrace() && !hasProperty(problemDetail, "stackTrace")) { + problemDetail.setProperty("stackTrace", toStackTrace(throwable)); + } + } + + private boolean hasProperty(ProblemDetail problemDetail, String propertyName) { + Map properties = problemDetail.getProperties(); + return properties != null && properties.containsKey(propertyName); + } + + @Nullable + private URI resolveInstance(WebRequest request) { + if (request instanceof ServletWebRequest servletWebRequest) { + String requestUri = servletWebRequest.getRequest().getRequestURI(); + if (StringUtils.hasText(requestUri)) { + return URI.create(requestUri); + } + } + return null; + } + + @Nullable + private String extractErrorCode(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + String message = current.getMessage(); + if (StringUtils.hasText(message)) { + Matcher matcher = ERROR_CODE_PATTERN.matcher(message); + if (matcher.find()) { + return matcher.group(1); + } + } + current = current.getCause(); + } + return null; + } + + private String toStackTrace(Throwable throwable) { + StringWriter stringWriter = new StringWriter(); + try (PrintWriter printWriter = new PrintWriter(stringWriter)) { + throwable.printStackTrace(printWriter); + } + return stringWriter.toString(); + } +} diff --git a/sofa-boot-project/sofa-boot-autoconfigure/src/main/java/com/alipay/sofa/boot/autoconfigure/web/SofaProblemDetailProperties.java b/sofa-boot-project/sofa-boot-autoconfigure/src/main/java/com/alipay/sofa/boot/autoconfigure/web/SofaProblemDetailProperties.java new file mode 100644 index 000000000..19885ede4 --- /dev/null +++ b/sofa-boot-project/sofa-boot-autoconfigure/src/main/java/com/alipay/sofa/boot/autoconfigure/web/SofaProblemDetailProperties.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 com.alipay.sofa.boot.autoconfigure.web; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.net.URI; + +/** + * Configuration properties for SOFA ProblemDetail support. + */ +@ConfigurationProperties(SofaProblemDetailProperties.PREFIX) +public class SofaProblemDetailProperties { + + public static final String PREFIX = "sofa.web.problem-detail"; + + private boolean enabled = true; + + private URI defaultType = URI.create("about:blank"); + + private boolean includeStackTrace = false; + + private boolean includeServiceInfo = true; + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public URI getDefaultType() { + return this.defaultType; + } + + public void setDefaultType(URI defaultType) { + this.defaultType = defaultType; + } + + public boolean isIncludeStackTrace() { + return this.includeStackTrace; + } + + public void setIncludeStackTrace(boolean includeStackTrace) { + this.includeStackTrace = includeStackTrace; + } + + public boolean isIncludeServiceInfo() { + return this.includeServiceInfo; + } + + public void setIncludeServiceInfo(boolean includeServiceInfo) { + this.includeServiceInfo = includeServiceInfo; + } +} diff --git a/sofa-boot-project/sofa-boot-autoconfigure/src/main/java/com/alipay/sofa/boot/autoconfigure/web/SofaRpcProblemDetailExceptionHandler.java b/sofa-boot-project/sofa-boot-autoconfigure/src/main/java/com/alipay/sofa/boot/autoconfigure/web/SofaRpcProblemDetailExceptionHandler.java new file mode 100644 index 000000000..c4ac2a3cd --- /dev/null +++ b/sofa-boot-project/sofa-boot-autoconfigure/src/main/java/com/alipay/sofa/boot/autoconfigure/web/SofaRpcProblemDetailExceptionHandler.java @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 com.alipay.sofa.boot.autoconfigure.web; + +import com.alipay.sofa.rpc.boot.common.SofaBootRpcRuntimeException; +import com.alipay.sofa.rpc.core.exception.RpcErrorType; +import com.alipay.sofa.rpc.core.exception.SofaRpcException; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpStatus; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; + +import java.net.URI; + +/** + * ProblemDetail mapping for SOFA RPC exceptions. + */ +@RestControllerAdvice +@Order(Ordered.LOWEST_PRECEDENCE) +public class SofaRpcProblemDetailExceptionHandler extends SofaProblemDetailExceptionHandler { + + public SofaRpcProblemDetailExceptionHandler(SofaProblemDetailProperties properties, + Environment environment) { + super(properties, environment); + } + + @ExceptionHandler(SofaBootRpcRuntimeException.class) + public org.springframework.http.ResponseEntity handleSofaBootRpcRuntimeException(SofaBootRpcRuntimeException ex, + WebRequest request) { + RpcProblemDescriptor descriptor = classify(ex); + String detail = StringUtils.hasText(ex.getMessage()) ? ex.getMessage() : descriptor.detail; + return createSofaResponseEntity(ex, descriptor.status, detail, descriptor.type, + descriptor.title, request); + } + + private RpcProblemDescriptor classify(SofaBootRpcRuntimeException ex) { + SofaRpcException rpcCause = findCause(ex); + if (rpcCause != null && isRemoteUnavailable(rpcCause.getErrorType())) { + return new RpcProblemDescriptor(HttpStatus.SERVICE_UNAVAILABLE, RPC_EXCEPTION_TYPE, + "SOFA RPC Error", "RPC service call failed"); + } + return new RpcProblemDescriptor(HttpStatus.INTERNAL_SERVER_ERROR, RPC_CONFIGURATION_TYPE, + "SOFA RPC Configuration Error", "SOFA RPC configuration is invalid"); + } + + private SofaRpcException findCause(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof SofaRpcException sofaRpcException) { + return sofaRpcException; + } + current = current.getCause(); + } + return null; + } + + private boolean isRemoteUnavailable(int errorType) { + return errorType == RpcErrorType.SERVER_BUSY || errorType == RpcErrorType.SERVER_CLOSED + || errorType == RpcErrorType.SERVER_NOT_FOUND_INVOKER + || errorType == RpcErrorType.SERVER_NETWORK + || errorType == RpcErrorType.CLIENT_TIMEOUT + || errorType == RpcErrorType.CLIENT_NETWORK; + } + + private static final class RpcProblemDescriptor { + + private final HttpStatus status; + private final URI type; + private final String title; + private final String detail; + + private RpcProblemDescriptor(HttpStatus status, URI type, String title, String detail) { + this.status = status; + this.type = type; + this.title = title; + this.detail = detail; + } + } +} diff --git a/sofa-boot-project/sofa-boot-autoconfigure/src/main/java/com/alipay/sofa/boot/autoconfigure/web/SofaRuntimeProblemDetailExceptionHandler.java b/sofa-boot-project/sofa-boot-autoconfigure/src/main/java/com/alipay/sofa/boot/autoconfigure/web/SofaRuntimeProblemDetailExceptionHandler.java new file mode 100644 index 000000000..9718580db --- /dev/null +++ b/sofa-boot-project/sofa-boot-autoconfigure/src/main/java/com/alipay/sofa/boot/autoconfigure/web/SofaRuntimeProblemDetailExceptionHandler.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 com.alipay.sofa.boot.autoconfigure.web; + +import com.alipay.sofa.runtime.api.ServiceRuntimeException; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpStatus; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; + +/** + * ProblemDetail mapping for SOFA runtime exceptions. + */ +@RestControllerAdvice +@Order(Ordered.LOWEST_PRECEDENCE) +public class SofaRuntimeProblemDetailExceptionHandler extends SofaProblemDetailExceptionHandler { + + public SofaRuntimeProblemDetailExceptionHandler(SofaProblemDetailProperties properties, + Environment environment) { + super(properties, environment); + } + + @ExceptionHandler(ServiceRuntimeException.class) + public org.springframework.http.ResponseEntity handleServiceRuntimeException(ServiceRuntimeException ex, + WebRequest request) { + String detail = StringUtils.hasText(ex.getMessage()) ? ex.getMessage() + : "SOFA runtime execution failed"; + return createSofaResponseEntity(ex, HttpStatus.INTERNAL_SERVER_ERROR, detail, + RUNTIME_EXCEPTION_TYPE, "SOFA Runtime Error", request); + } +} diff --git a/sofa-boot-project/sofa-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/sofa-boot-project/sofa-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index b934929e7..d6dfd0207 100644 --- a/sofa-boot-project/sofa-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/sofa-boot-project/sofa-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,6 +1,7 @@ com.alipay.sofa.boot.autoconfigure.runtime.SofaRuntimeAutoConfiguration com.alipay.sofa.boot.autoconfigure.isle.SofaModuleAutoConfiguration com.alipay.sofa.boot.autoconfigure.rpc.SofaRpcAutoConfiguration +com.alipay.sofa.boot.autoconfigure.web.SofaProblemDetailAutoConfiguration com.alipay.sofa.boot.autoconfigure.tracer.SofaTracerAutoConfiguration com.alipay.sofa.boot.autoconfigure.tracer.datasource.DataSourceAutoConfiguration com.alipay.sofa.boot.autoconfigure.tracer.zipkin.ZipkinAutoConfiguration @@ -14,4 +15,4 @@ com.alipay.sofa.boot.autoconfigure.tracer.mongo.MongoAutoConfiguration com.alipay.sofa.boot.autoconfigure.tracer.flexible.FlexibleAutoConfiguration com.alipay.sofa.boot.autoconfigure.tracer.springmvc.OpenTracingSpringMvcAutoConfiguration com.alipay.sofa.boot.autoconfigure.tracer.feign.FeignClientAutoConfiguration -com.alipay.sofa.boot.autoconfigure.ark.SofaArkAutoConfiguration \ No newline at end of file +com.alipay.sofa.boot.autoconfigure.ark.SofaArkAutoConfiguration diff --git a/sofa-boot-project/sofa-boot-autoconfigure/src/test/java/com/alipay/sofa/boot/autoconfigure/web/SofaProblemDetailAutoConfigurationTests.java b/sofa-boot-project/sofa-boot-autoconfigure/src/test/java/com/alipay/sofa/boot/autoconfigure/web/SofaProblemDetailAutoConfigurationTests.java new file mode 100644 index 000000000..73766c927 --- /dev/null +++ b/sofa-boot-project/sofa-boot-autoconfigure/src/test/java/com/alipay/sofa/boot/autoconfigure/web/SofaProblemDetailAutoConfigurationTests.java @@ -0,0 +1,441 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 com.alipay.sofa.boot.autoconfigure.web; + +import com.alipay.sofa.rpc.boot.common.SofaBootRpcRuntimeException; +import com.alipay.sofa.rpc.core.exception.RpcErrorType; +import com.alipay.sofa.rpc.core.exception.SofaRpcException; +import com.alipay.sofa.runtime.api.ServiceRuntimeException; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.HttpHandlerAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.ReactiveWebServerFactoryAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.reactive.context.AnnotationConfigReactiveWebServerApplicationContext; +import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.env.MockEnvironment; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; +import org.springframework.web.context.ConfigurableWebApplicationContext; +import org.springframework.web.context.request.ServletWebRequest; + +import java.net.URI; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class SofaProblemDetailAutoConfigurationTests { + + private final WebApplicationContextRunner servletContextRunner = new WebApplicationContextRunner( + AnnotationConfigServletWebServerApplicationContext::new) + .withUserConfiguration(ServletConfiguration.class, TestController.class) + .withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class, + HttpMessageConvertersAutoConfiguration.class, + DispatcherServletAutoConfiguration.class, WebMvcAutoConfiguration.class, + SofaProblemDetailAutoConfiguration.class)) + .withPropertyValues("server.port=0", "spring.application.name=demo-service"); + + @Test + void registersExceptionHandlerByDefault() { + this.servletContextRunner.run(context -> { + assertThat(context).hasBean("sofaProblemDetailExceptionHandler"); + assertThat(context).hasSingleBean(SofaProblemDetailProperties.class); + assertThat(context).hasSingleBean(SofaRuntimeProblemDetailExceptionHandler.class); + assertThat(context).hasSingleBean(SofaRpcProblemDetailExceptionHandler.class); + assertThat(context.getBean("sofaProblemDetailExceptionHandler")) + .isExactlyInstanceOf(SofaProblemDetailExceptionHandler.class); + + SofaProblemDetailProperties properties = context.getBean(SofaProblemDetailProperties.class); + assertThat(properties.isEnabled()).isTrue(); + assertThat(properties.getDefaultType()).isEqualTo(URI.create("about:blank")); + assertThat(properties.isIncludeStackTrace()).isFalse(); + assertThat(properties.isIncludeServiceInfo()).isTrue(); + }); + } + + @Test + void backsOffWhenUserProvidesExceptionHandler() { + this.servletContextRunner.withUserConfiguration(CustomHandlerConfiguration.class).run(context -> { + assertThat(context).doesNotHaveBean("sofaProblemDetailExceptionHandler"); + assertThat(context).hasSingleBean(CustomResponseEntityExceptionHandler.class); + assertThat(context).hasSingleBean(SofaRuntimeProblemDetailExceptionHandler.class); + assertThat(context).hasSingleBean(SofaRpcProblemDetailExceptionHandler.class); + }); + } + + @Test + void doesNotRegisterWhenDisabled() { + this.servletContextRunner.withPropertyValues("sofa.web.problem-detail.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean(SofaProblemDetailExceptionHandler.class); + assertThat(context).doesNotHaveBean(SofaRuntimeProblemDetailExceptionHandler.class); + assertThat(context).doesNotHaveBean(SofaRpcProblemDetailExceptionHandler.class); + }); + } + + @Test + void bindsCustomProperties() { + this.servletContextRunner.withPropertyValues( + "sofa.web.problem-detail.default-type=https://sofastack.io/errors/default", + "sofa.web.problem-detail.include-stack-trace=true", + "sofa.web.problem-detail.include-service-info=false").run(context -> { + SofaProblemDetailProperties properties = context + .getBean(SofaProblemDetailProperties.class); + assertThat(properties.getDefaultType()) + .isEqualTo(URI.create("https://sofastack.io/errors/default")); + assertThat(properties.isIncludeStackTrace()).isTrue(); + assertThat(properties.isIncludeServiceInfo()).isFalse(); + }); + } + + @Test + void doesNotRegisterInReactiveApplication() { + new ReactiveWebApplicationContextRunner( + AnnotationConfigReactiveWebServerApplicationContext::new) + .withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, + HttpHandlerAutoConfiguration.class, + ReactiveWebServerFactoryAutoConfiguration.class, + SofaProblemDetailAutoConfiguration.class)) + .withPropertyValues("server.port=0") + .run(context -> assertThat(context) + .doesNotHaveBean(SofaProblemDetailExceptionHandler.class)); + } + + @Test + void rendersRuntimeExceptionsAsProblemDetails() throws Exception { + this.servletContextRunner.run(context -> { + mockMvc(context).perform(get("/problem-detail/runtime")) + .andExpect(status().isInternalServerError()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type") + .value("https://sofastack.io/errors/runtime-exception")) + .andExpect(jsonPath("$.title").value("SOFA Runtime Error")) + .andExpect(jsonPath("$.status").value(500)) + .andExpect(jsonPath("$.detail") + .value("SOFA-BOOT-01-00000: runtime failed")) + .andExpect(jsonPath("$.instance").value("/problem-detail/runtime")) + .andExpect(jsonPath("$.service").value("demo-service")) + .andExpect(jsonPath("$.errorCode").value("SOFA-BOOT-01-00000")); + }); + } + + @Test + void rendersRpcExceptionsAsProblemDetails() throws Exception { + this.servletContextRunner.run(context -> { + mockMvc(context).perform(get("/problem-detail/rpc")) + .andExpect(status().isInternalServerError()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type") + .value("https://sofastack.io/errors/rpc-configuration-exception")) + .andExpect(jsonPath("$.title").value("SOFA RPC Configuration Error")) + .andExpect(jsonPath("$.status").value(500)) + .andExpect(jsonPath("$.detail").value("filter name[testFilter] is not ref a Filter.")) + .andExpect(jsonPath("$.instance").value("/problem-detail/rpc")) + .andExpect(jsonPath("$.service").value("demo-service")); + }); + } + + @Test + void rendersRemoteRpcAvailabilityFailuresAs503() throws Exception { + this.servletContextRunner.run(context -> { + mockMvc(context).perform(get("/problem-detail/rpc-unavailable")) + .andExpect(status().isServiceUnavailable()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("https://sofastack.io/errors/rpc-exception")) + .andExpect(jsonPath("$.title").value("SOFA RPC Error")) + .andExpect(jsonPath("$.status").value(503)) + .andExpect(jsonPath("$.detail").value("remote rpc failed")) + .andExpect(jsonPath("$.instance").value("/problem-detail/rpc-unavailable")) + .andExpect(jsonPath("$.service").value("demo-service")); + }); + } + + @Test + void appliesDefaultTypeToFrameworkExceptions() throws Exception { + this.servletContextRunner + .withPropertyValues("sofa.web.problem-detail.default-type=https://sofastack.io/errors/default") + .run(context -> { + mockMvc(context).perform(get("/problem-detail/mvc")) + .andExpect(status().isBadRequest()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("https://sofastack.io/errors/default")) + .andExpect(jsonPath("$.title").value("Bad Request")) + .andExpect(jsonPath("$.status").value(400)) + .andExpect(jsonPath("$.detail") + .value("Required parameter 'name' is not present.")) + .andExpect(jsonPath("$.instance").value("/problem-detail/mvc")) + .andExpect(jsonPath("$.service").value("demo-service")); + }); + } + + @Test + void keepsResponseStatusExceptionsUntouched() throws Exception { + this.servletContextRunner.run(context -> { + mockMvc(context).perform(get("/problem-detail/response-status")) + .andExpect(status().isIAmATeapot()) + .andExpect(result -> { + assertThat(result.getResolvedException()).isInstanceOf(TeapotException.class); + assertThat(result.getResponse().getContentType()) + .isNotEqualTo(MediaType.APPLICATION_PROBLEM_JSON_VALUE); + }); + }); + } + + @Test + void allowsUserControllerAdviceToOverrideSofaHandlers() throws Exception { + this.servletContextRunner.withUserConfiguration(UserControllerAdvice.class).run(context -> { + mockMvc(context).perform(get("/problem-detail/runtime")) + .andExpect(status().isConflict()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect(jsonPath("$.type").value("https://example.com/custom")) + .andExpect(jsonPath("$.title").value("Custom Runtime Error")) + .andExpect(jsonPath("$.status").value(409)) + .andExpect(jsonPath("$.detail").value("handled by user advice")) + .andExpect(jsonPath("$.service").doesNotExist()); + }); + } + + @Test + void usesRuntimeFallbackDetailWhenExceptionMessageIsBlank() { + SofaRuntimeProblemDetailExceptionHandler handler = new SofaRuntimeProblemDetailExceptionHandler( + new SofaProblemDetailProperties(), new MockEnvironment()); + + ResponseEntity response = handler.handleServiceRuntimeException( + new ServiceRuntimeException(), servletWebRequest("/problem-detail/runtime-empty")); + + ProblemDetail problemDetail = (ProblemDetail) response.getBody(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(problemDetail.getDetail()).isEqualTo("SOFA runtime execution failed"); + assertThat(problemDetail.getInstance()).isEqualTo(URI.create("/problem-detail/runtime-empty")); + } + + @Test + void classifiesRpcAvailabilityErrorTypesAsServiceUnavailable() { + SofaRpcProblemDetailExceptionHandler handler = new SofaRpcProblemDetailExceptionHandler( + new SofaProblemDetailProperties(), new MockEnvironment()); + int[] errorTypes = { RpcErrorType.SERVER_BUSY, RpcErrorType.SERVER_CLOSED, + RpcErrorType.SERVER_NOT_FOUND_INVOKER, RpcErrorType.SERVER_NETWORK, + RpcErrorType.CLIENT_TIMEOUT, RpcErrorType.CLIENT_NETWORK }; + + for (int errorType : errorTypes) { + ResponseEntity response = handler.handleSofaBootRpcRuntimeException( + new SofaBootRpcRuntimeException(null, new SofaRpcException(errorType, "rpc failed")), + servletWebRequest("/problem-detail/rpc-unavailable")); + + ProblemDetail problemDetail = (ProblemDetail) response.getBody(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(problemDetail.getType()).isEqualTo(SofaProblemDetailExceptionHandler.RPC_EXCEPTION_TYPE); + assertThat(problemDetail.getDetail()).isEqualTo("RPC service call failed"); + } + } + + @Test + void classifiesRpcNonAvailabilityCauseAsConfigurationProblem() { + SofaRpcProblemDetailExceptionHandler handler = new SofaRpcProblemDetailExceptionHandler( + new SofaProblemDetailProperties(), new MockEnvironment()); + + ResponseEntity response = handler.handleSofaBootRpcRuntimeException( + new SofaBootRpcRuntimeException(null, + new SofaRpcException(RpcErrorType.CLIENT_SERIALIZE, "serialize failed")), + servletWebRequest("/problem-detail/rpc-configuration")); + + ProblemDetail problemDetail = (ProblemDetail) response.getBody(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(problemDetail.getType()) + .isEqualTo(SofaProblemDetailExceptionHandler.RPC_CONFIGURATION_TYPE); + assertThat(problemDetail.getDetail()).isEqualTo("SOFA RPC configuration is invalid"); + } + + @Test + void customizesStackTraceAndPreservesExistingExtensions() { + SofaProblemDetailProperties properties = new SofaProblemDetailProperties(); + properties.setIncludeStackTrace(true); + TestableSofaProblemDetailExceptionHandler handler = new TestableSofaProblemDetailExceptionHandler( + properties, new MockEnvironment().withProperty("spring.application.name", "demo-service")); + ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR); + problemDetail.setType(URI.create("https://example.com/type")); + problemDetail.setInstance(URI.create("/existing")); + problemDetail.setProperty("service", "custom-service"); + problemDetail.setProperty("errorCode", "CUSTOM"); + + handler.customize(problemDetail, servletWebRequest("/problem-detail/runtime"), + new ServiceRuntimeException("SOFA-BOOT-01-12345: failed")); + + Map propertiesMap = problemDetail.getProperties(); + assertThat(problemDetail.getType()).isEqualTo(URI.create("https://example.com/type")); + assertThat(problemDetail.getInstance()).isEqualTo(URI.create("/existing")); + assertThat(propertiesMap).containsEntry("service", "custom-service"); + assertThat(propertiesMap).containsEntry("errorCode", "CUSTOM"); + assertThat(propertiesMap.get("stackTrace").toString()) + .contains(ServiceRuntimeException.class.getName()); + } + + @Test + void customizesWithoutInstanceWhenRequestUriIsBlank() { + TestableSofaProblemDetailExceptionHandler handler = new TestableSofaProblemDetailExceptionHandler( + new SofaProblemDetailProperties(), new MockEnvironment()); + ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR); + problemDetail.setType(URI.create("https://example.com/type")); + + handler.customize(problemDetail, servletWebRequest(""), new ServiceRuntimeException()); + + assertThat(problemDetail.getInstance()).isNull(); + assertThat(problemDetail.getProperties()).isNull(); + } + + @Test + void leavesNonProblemDetailBodiesUntouched() { + TestableSofaProblemDetailExceptionHandler handler = new TestableSofaProblemDetailExceptionHandler( + new SofaProblemDetailProperties(), new MockEnvironment()); + + ResponseEntity response = handler.create("plain body", + servletWebRequest("/problem-detail/plain")); + + assertThat(response.getBody()).isEqualTo("plain body"); + } + + @Test + void updatesEnabledProperty() { + SofaProblemDetailProperties properties = new SofaProblemDetailProperties(); + + properties.setEnabled(false); + + assertThat(properties.isEnabled()).isFalse(); + } + + private MockMvc mockMvc(ConfigurableWebApplicationContext context) { + return MockMvcBuilders.webAppContextSetup(context).build(); + } + + private ServletWebRequest servletWebRequest(String requestUri) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRequestURI(requestUri); + return new ServletWebRequest(request); + } + + private static class TestableSofaProblemDetailExceptionHandler extends + SofaProblemDetailExceptionHandler { + + TestableSofaProblemDetailExceptionHandler(SofaProblemDetailProperties properties, + MockEnvironment environment) { + super(properties, environment); + } + + ResponseEntity create(Object body, ServletWebRequest request) { + return createResponseEntity(body, new HttpHeaders(), HttpStatus.OK, request); + } + } + + @Configuration(proxyBeanMethods = false) + static class ServletConfiguration { + + @Bean + TomcatServletWebServerFactory tomcat() { + return new TomcatServletWebServerFactory(0); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomHandlerConfiguration { + + @Bean + CustomResponseEntityExceptionHandler customProblemDetailExceptionHandler() { + return new CustomResponseEntityExceptionHandler(); + } + } + + @RestControllerAdvice + static class CustomResponseEntityExceptionHandler extends ResponseEntityExceptionHandler { + } + + @RestControllerAdvice + @Order(Ordered.HIGHEST_PRECEDENCE) + static class UserControllerAdvice { + + @ExceptionHandler(ServiceRuntimeException.class) + ProblemDetail handleServiceRuntimeException(ServiceRuntimeException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, + "handled by user advice"); + problemDetail.setType(URI.create("https://example.com/custom")); + problemDetail.setTitle("Custom Runtime Error"); + return problemDetail; + } + } + + @RestController + static class TestController { + + @GetMapping("/problem-detail/runtime") + ResponseEntity runtime() { + throw new ServiceRuntimeException("SOFA-BOOT-01-00000: runtime failed"); + } + + @GetMapping("/problem-detail/rpc") + ResponseEntity rpc() { + throw new SofaBootRpcRuntimeException("filter name[testFilter] is not ref a Filter."); + } + + @GetMapping("/problem-detail/rpc-unavailable") + ResponseEntity rpcUnavailable() { + throw new SofaBootRpcRuntimeException("remote rpc failed", new SofaRpcException( + RpcErrorType.CLIENT_TIMEOUT, "request timeout")); + } + + @GetMapping("/problem-detail/mvc") + ResponseEntity mvc(@RequestParam("name") String name) { + return ResponseEntity.ok().build(); + } + + @GetMapping("/problem-detail/response-status") + ResponseEntity responseStatus() { + throw new TeapotException(); + } + } + + @ResponseStatus(HttpStatus.I_AM_A_TEAPOT) + static class TeapotException extends RuntimeException { + } +}