mirror of https://github.com/halo-dev/halo
Refine exception detail with i18n (#3042)
#### What type of PR is this? /kind feature /kind api-change /area core /milestone 2.1.x #### What this PR does / why we need it: - Configuring message source location and name enables i18n message resolution. - Simple global error handler. - Refactor some exceptions with `ResponseStatusException` to control what HTTP status and problem detail need to be returned. **TODO** - [x] Add more UTs. #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/3020 #### Special notes for your reviewer: Steps to test: 1. Try to refine `src/main/resources/config/i18n/messages_zh.properties` and switch Browser language with Chinese. 2. Delibrately make a mistake as you wish and see the error tips in console. 3. Try to access one page which doesn't exist and see the rendered result. #### Does this PR introduce a user-facing change? ```release-note 完成系统异常的国际化 ```pull/3052/head v2.1.0-rc.1
parent
3a1c264afb
commit
da55532777
|
@ -68,11 +68,13 @@ public class CommentServiceImpl implements CommentService {
|
|||
.flatMap(commentSetting -> {
|
||||
if (Boolean.FALSE.equals(commentSetting.getEnable())) {
|
||||
return Mono.error(
|
||||
new AccessDeniedException("The comment function has been turned off."));
|
||||
new AccessDeniedException("The comment function has been turned off.",
|
||||
"problemDetail.comment.turnedOff", null));
|
||||
}
|
||||
if (checkCommentOwner(comment, commentSetting.getSystemUserOnly())) {
|
||||
return Mono.error(
|
||||
new AccessDeniedException("Allow system user comments only."));
|
||||
new AccessDeniedException("Allow only system users to comment.",
|
||||
"problemDetail.comment.systemUsersOnly", null));
|
||||
}
|
||||
|
||||
if (comment.getSpec().getTop() == null) {
|
||||
|
|
|
@ -55,10 +55,12 @@ public class ReplyServiceImpl implements ReplyService {
|
|||
.map(commentSetting -> {
|
||||
if (Boolean.FALSE.equals(commentSetting.getEnable())) {
|
||||
throw new AccessDeniedException(
|
||||
"The comment function has been turned off.");
|
||||
"The comment function has been turned off.",
|
||||
"problemDetail.comment.turnedOff", null);
|
||||
}
|
||||
if (checkReplyOwner(reply, commentSetting.getSystemUserOnly())) {
|
||||
throw new AccessDeniedException("Allow system user reply only.");
|
||||
throw new AccessDeniedException("Allow only system users to comment.",
|
||||
"problemDetail.comment.systemUsersOnly", null);
|
||||
}
|
||||
reply.getSpec().setApproved(
|
||||
Boolean.FALSE.equals(commentSetting.getRequireReviewForNew()));
|
||||
|
|
|
@ -5,6 +5,7 @@ import static run.halo.app.infra.utils.FileUtils.checkDirectoryTraversal;
|
|||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
|
@ -26,6 +27,7 @@ import run.halo.app.core.extension.attachment.Attachment.AttachmentSpec;
|
|||
import run.halo.app.core.extension.attachment.Constant;
|
||||
import run.halo.app.core.extension.attachment.Policy;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.infra.exception.AttachmentAlreadyExistsException;
|
||||
import run.halo.app.infra.properties.HaloProperties;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
|
||||
|
@ -108,7 +110,9 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
|
|||
attachment.setMetadata(metadata);
|
||||
attachment.setSpec(spec);
|
||||
return attachment;
|
||||
}));
|
||||
}))
|
||||
.onErrorMap(FileAlreadyExistsException.class,
|
||||
e -> new AttachmentAlreadyExistsException(e.getFile()));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -28,6 +28,8 @@ import run.halo.app.core.extension.Role;
|
|||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.core.extension.service.UserService;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.exception.ExtensionNotFoundException;
|
||||
import run.halo.app.infra.exception.UserNotFoundException;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
|
||||
@Component
|
||||
|
@ -115,7 +117,9 @@ public class UserEndpoint implements CustomEndpoint {
|
|||
return ReactiveSecurityContextHolder.getContext()
|
||||
.flatMap(ctx -> {
|
||||
var name = ctx.getAuthentication().getName();
|
||||
return client.get(User.class, name);
|
||||
return client.get(User.class, name)
|
||||
.onErrorMap(ExtensionNotFoundException.class,
|
||||
e -> new UserNotFoundException(name));
|
||||
})
|
||||
.flatMap(user -> ServerResponse.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
|
|
|
@ -19,7 +19,6 @@ import java.util.function.Predicate;
|
|||
import java.util.zip.ZipInputStream;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
import org.springframework.retry.RetryException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
@ -39,8 +38,7 @@ import run.halo.app.extension.ExtensionUtil;
|
|||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.Unstructured;
|
||||
import run.halo.app.infra.ThemeRootGetter;
|
||||
import run.halo.app.infra.exception.AsyncRequestTimeoutException;
|
||||
import run.halo.app.infra.exception.ThemeInstallationException;
|
||||
import run.halo.app.infra.exception.ThemeUpgradeException;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
|
@ -80,9 +78,10 @@ public class ThemeServiceImpl implements ThemeService {
|
|||
.flatMap(oldTheme -> {
|
||||
try (var zis = new ZipInputStream(is)) {
|
||||
unzip(zis, tempDir.get());
|
||||
return locateThemeManifest(tempDir.get())
|
||||
.switchIfEmpty(Mono.error(() -> new ThemeInstallationException(
|
||||
"Missing theme manifest file: theme.yaml or theme.yml")));
|
||||
return locateThemeManifest(tempDir.get()).switchIfEmpty(Mono.error(
|
||||
() -> new ThemeUpgradeException(
|
||||
"Missing theme manifest file: theme.yaml or theme.yml",
|
||||
"problemDetail.theme.upgrade.missingManifest", null)));
|
||||
} catch (IOException e) {
|
||||
return Mono.error(e);
|
||||
}
|
||||
|
@ -100,7 +99,9 @@ public class ThemeServiceImpl implements ThemeService {
|
|||
log.error("Want theme name: {}, but provided: {}", themeName,
|
||||
newTheme.getMetadata().getName());
|
||||
}
|
||||
throw new ServerWebInputException("please make sure the theme name is correct");
|
||||
throw new ThemeUpgradeException("Please make sure the theme name is correct",
|
||||
"problemDetail.theme.upgrade.nameMismatch",
|
||||
new Object[] {newTheme.getMetadata().getName(), themeName});
|
||||
}
|
||||
})
|
||||
.flatMap(newTheme -> {
|
||||
|
@ -182,18 +183,7 @@ public class ThemeServiceImpl implements ThemeService {
|
|||
.flatMap(oldTheme -> {
|
||||
String settingName = oldTheme.getSpec().getSettingName();
|
||||
return waitForSettingDeleted(settingName)
|
||||
.doOnError(error -> {
|
||||
log.error("Failed to delete setting: {}", settingName,
|
||||
ExceptionUtils.getRootCause(error));
|
||||
throw new AsyncRequestTimeoutException("Reload theme timeout.");
|
||||
})
|
||||
.then(waitForAnnotationSettingsDeleted(name)
|
||||
.doOnError(error -> {
|
||||
log.error("Failed to delete AnnotationSetting by theme [{}]", name,
|
||||
ExceptionUtils.getRootCause(error));
|
||||
throw new AsyncRequestTimeoutException("Reload theme timeout.");
|
||||
})
|
||||
);
|
||||
.then(waitForAnnotationSettingsDeleted(name));
|
||||
})
|
||||
.then(Mono.defer(() -> {
|
||||
Path themePath = themeRoot.get().resolve(name);
|
||||
|
@ -261,9 +251,8 @@ public class ThemeServiceImpl implements ThemeService {
|
|||
return client.fetch(Setting.class, settingName)
|
||||
.flatMap(setting -> client.delete(setting)
|
||||
.flatMap(deleted -> client.fetch(Setting.class, settingName)
|
||||
.doOnNext(latest -> {
|
||||
throw new RetryException("Setting is not deleted yet.");
|
||||
})
|
||||
.flatMap(s -> Mono.error(
|
||||
() -> new RetryException("Re-check if the setting is deleted.")))
|
||||
.retryWhen(Retry.fixedDelay(10, Duration.ofMillis(100))
|
||||
.filter(t -> t instanceof RetryException))
|
||||
)
|
||||
|
@ -317,9 +306,9 @@ public class ThemeServiceImpl implements ThemeService {
|
|||
throw new RetryException("Re-check if the theme is deleted successfully");
|
||||
})
|
||||
.retryWhen(Retry.fixedDelay(20, Duration.ofMillis(100))
|
||||
.filter(t -> t instanceof RetryException))
|
||||
.onErrorMap(Exceptions::isRetryExhausted,
|
||||
throwable -> new ServerErrorException("Wait timeout for theme deleted", throwable))
|
||||
.filter(t -> t instanceof RetryException)
|
||||
.onRetryExhaustedThrow((spec, signal) ->
|
||||
new ServerErrorException("Wait timeout for theme deleted", null)))
|
||||
.then();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -122,14 +122,16 @@ class ThemeUtils {
|
|||
})
|
||||
.flatMap(is -> ThemeUtils.locateThemeManifest(tempDir.get()))
|
||||
.switchIfEmpty(
|
||||
Mono.error(() -> new ThemeInstallationException("Missing theme manifest")))
|
||||
Mono.error(() -> new ThemeInstallationException("Missing theme manifest",
|
||||
"problemDetail.theme.install.missingManifest", null)))
|
||||
.map(themeManifestPath -> {
|
||||
var theme = loadThemeManifest(themeManifestPath);
|
||||
var themeName = theme.getMetadata().getName();
|
||||
var themeTargetPath = themeWorkDir.resolve(themeName);
|
||||
try {
|
||||
if (!override && !FileUtils.isEmpty(themeTargetPath)) {
|
||||
throw new ThemeInstallationException("Theme already exists.");
|
||||
throw new ThemeInstallationException("Theme already exists.",
|
||||
"problemDetail.theme.install.alreadyExists", new Object[] {themeName});
|
||||
}
|
||||
// install theme to theme work dir
|
||||
copyRecursively(themeManifestPath.getParent(), themeTargetPath);
|
||||
|
|
|
@ -49,7 +49,7 @@ public record GroupVersionKind(String group, String version, String kind) {
|
|||
return new GroupVersionKind(gv.group(), gv.version(), kind);
|
||||
}
|
||||
|
||||
public static <T extends AbstractExtension> GroupVersionKind fromExtension(Class<T> extension) {
|
||||
public static <T extends Extension> GroupVersionKind fromExtension(Class<T> extension) {
|
||||
GVK gvk = extension.getAnnotation(GVK.class);
|
||||
return new GroupVersionKind(gvk.group(), gvk.version(), gvk.kind());
|
||||
}
|
||||
|
|
|
@ -62,8 +62,8 @@ public class JSONExtensionConverter implements ExtensionConverter {
|
|||
if (!validation.isValid()) {
|
||||
log.debug("Failed to validate Extension: {}, and errors were: {}",
|
||||
extension.getClass(), validation.results());
|
||||
throw new SchemaViolationException("Failed to validate Extension "
|
||||
+ extension.getClass(), validation.results());
|
||||
throw new SchemaViolationException(extension.groupVersionKind(),
|
||||
validation.results());
|
||||
}
|
||||
|
||||
var version = extension.getMetadata().getVersion();
|
||||
|
|
|
@ -84,14 +84,15 @@ public class ReactiveExtensionClientImpl implements ReactiveExtensionClient {
|
|||
@Override
|
||||
public <E extends Extension> Mono<E> get(Class<E> type, String name) {
|
||||
return fetch(type, name)
|
||||
.switchIfEmpty(Mono.error(() -> new ExtensionNotFoundException(
|
||||
"Extension " + type.getName() + " with name " + name + " not found")));
|
||||
.switchIfEmpty(Mono.error(() -> {
|
||||
var gvk = GroupVersionKind.fromExtension(type);
|
||||
return new ExtensionNotFoundException(gvk, name);
|
||||
}));
|
||||
}
|
||||
|
||||
private Mono<Unstructured> get(GroupVersionKind gvk, String name) {
|
||||
return fetch(gvk, name)
|
||||
.switchIfEmpty(Mono.error(() -> new ExtensionNotFoundException(
|
||||
"Extension " + gvk + " with name " + name + " not found")));
|
||||
.switchIfEmpty(Mono.error(() -> new ExtensionNotFoundException(gvk, name)));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -68,11 +68,8 @@ public record Scheme(Class<? extends Extension> type,
|
|||
@NonNull
|
||||
public static GVK getGvkFromType(@NonNull Class<? extends Extension> type) {
|
||||
var gvk = type.getAnnotation(GVK.class);
|
||||
if (gvk == null) {
|
||||
throw new ExtensionException(
|
||||
String.format("Annotation %s needs to be on Extension %s", GVK.class.getName(),
|
||||
type.getName()));
|
||||
}
|
||||
Assert.notNull(gvk,
|
||||
"Missing annotation " + GVK.class.getName() + " on type " + type.getName());
|
||||
return gvk;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@ public interface SchemeManager {
|
|||
@NonNull
|
||||
default Scheme get(@NonNull GroupVersionKind gvk) {
|
||||
return fetch(gvk).orElseThrow(
|
||||
() -> new SchemeNotFoundException("Scheme was not found for " + gvk));
|
||||
() -> new SchemeNotFoundException(gvk));
|
||||
}
|
||||
|
||||
@NonNull
|
||||
|
|
|
@ -7,23 +7,11 @@ package run.halo.app.extension.exception;
|
|||
*/
|
||||
public class ExtensionConvertException extends ExtensionException {
|
||||
|
||||
public ExtensionConvertException() {
|
||||
public ExtensionConvertException(String reason) {
|
||||
super(reason);
|
||||
}
|
||||
|
||||
public ExtensionConvertException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ExtensionConvertException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public ExtensionConvertException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public ExtensionConvertException(String message, Throwable cause, boolean enableSuppression,
|
||||
boolean writableStackTrace) {
|
||||
super(message, cause, enableSuppression, writableStackTrace);
|
||||
public ExtensionConvertException(String reason, Throwable cause) {
|
||||
super(reason, cause);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,30 +1,26 @@
|
|||
package run.halo.app.extension.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* ExtensionException is the superclass of those exceptions that can be thrown by Extension module.
|
||||
*
|
||||
* @author johnniang
|
||||
*/
|
||||
public class ExtensionException extends RuntimeException {
|
||||
public class ExtensionException extends ResponseStatusException {
|
||||
|
||||
public ExtensionException() {
|
||||
public ExtensionException(String reason) {
|
||||
this(reason, null);
|
||||
}
|
||||
|
||||
public ExtensionException(String message) {
|
||||
super(message);
|
||||
public ExtensionException(String reason, Throwable cause) {
|
||||
this(HttpStatus.INTERNAL_SERVER_ERROR, reason, cause, null, new Object[] {reason});
|
||||
}
|
||||
|
||||
public ExtensionException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
protected ExtensionException(HttpStatusCode status, String reason, Throwable cause,
|
||||
String messageDetailCode, Object[] messageDetailArguments) {
|
||||
super(status, reason, cause, messageDetailCode, messageDetailArguments);
|
||||
}
|
||||
|
||||
public ExtensionException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public ExtensionException(String message, Throwable cause, boolean enableSuppression,
|
||||
boolean writableStackTrace) {
|
||||
super(message, cause, enableSuppression, writableStackTrace);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,24 +1,13 @@
|
|||
package run.halo.app.extension.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import run.halo.app.extension.GroupVersionKind;
|
||||
|
||||
public class ExtensionNotFoundException extends ExtensionException {
|
||||
|
||||
public ExtensionNotFoundException() {
|
||||
public ExtensionNotFoundException(GroupVersionKind gvk, String name) {
|
||||
super(HttpStatus.NOT_FOUND, "Extension " + gvk + "/" + name + " was not found.",
|
||||
null, null, new Object[] {gvk, name});
|
||||
}
|
||||
|
||||
public ExtensionNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ExtensionNotFoundException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public ExtensionNotFoundException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public ExtensionNotFoundException(String message, Throwable cause, boolean enableSuppression,
|
||||
boolean writableStackTrace) {
|
||||
super(message, cause, enableSuppression, writableStackTrace);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package run.halo.app.extension.exception;
|
||||
|
||||
import org.openapi4j.core.validation.ValidationResults;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import run.halo.app.extension.GroupVersionKind;
|
||||
|
||||
/**
|
||||
* This exception is thrown when Schema is violation.
|
||||
|
@ -14,28 +16,9 @@ public class SchemaViolationException extends ExtensionException {
|
|||
*/
|
||||
private final ValidationResults errors;
|
||||
|
||||
public SchemaViolationException(ValidationResults errors) {
|
||||
this.errors = errors;
|
||||
}
|
||||
|
||||
public SchemaViolationException(String message, ValidationResults errors) {
|
||||
super(message);
|
||||
this.errors = errors;
|
||||
}
|
||||
|
||||
public SchemaViolationException(String message, Throwable cause, ValidationResults errors) {
|
||||
super(message, cause);
|
||||
this.errors = errors;
|
||||
}
|
||||
|
||||
public SchemaViolationException(Throwable cause, ValidationResults errors) {
|
||||
super(cause);
|
||||
this.errors = errors;
|
||||
}
|
||||
|
||||
public SchemaViolationException(String message, Throwable cause, boolean enableSuppression,
|
||||
boolean writableStackTrace, ValidationResults errors) {
|
||||
super(message, cause, enableSuppression, writableStackTrace);
|
||||
public SchemaViolationException(GroupVersionKind gvk, ValidationResults errors) {
|
||||
super(HttpStatus.BAD_REQUEST, "Failed to validate " + gvk, null, null,
|
||||
new Object[] {gvk, errors});
|
||||
this.errors = errors;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
package run.halo.app.extension.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import run.halo.app.extension.GroupVersionKind;
|
||||
|
||||
/**
|
||||
* SchemeNotFoundException is thrown while we try to get a scheme but not found.
|
||||
*
|
||||
|
@ -7,23 +10,9 @@ package run.halo.app.extension.exception;
|
|||
*/
|
||||
public class SchemeNotFoundException extends ExtensionException {
|
||||
|
||||
public SchemeNotFoundException() {
|
||||
public SchemeNotFoundException(GroupVersionKind gvk) {
|
||||
super(HttpStatus.INTERNAL_SERVER_ERROR, "Scheme not found for " + gvk, null, null,
|
||||
new Object[] {gvk});
|
||||
}
|
||||
|
||||
public SchemeNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public SchemeNotFoundException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public SchemeNotFoundException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public SchemeNotFoundException(String message, Throwable cause, boolean enableSuppression,
|
||||
boolean writableStackTrace) {
|
||||
super(message, cause, enableSuppression, writableStackTrace);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,24 +1,24 @@
|
|||
package run.halo.app.infra.exception;
|
||||
|
||||
public class AccessDeniedException extends HaloException {
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* AccessDeniedException will resolve i18n message and response 403 status.
|
||||
*
|
||||
* @author johnniang
|
||||
*/
|
||||
public class AccessDeniedException extends ResponseStatusException {
|
||||
|
||||
public AccessDeniedException() {
|
||||
this("Access to the resource is forbidden");
|
||||
}
|
||||
|
||||
public AccessDeniedException(String message) {
|
||||
super(message);
|
||||
public AccessDeniedException(String reason) {
|
||||
this(reason, null, null);
|
||||
}
|
||||
|
||||
public AccessDeniedException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public AccessDeniedException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public AccessDeniedException(String message, Throwable cause, boolean enableSuppression,
|
||||
boolean writableStackTrace) {
|
||||
super(message, cause, enableSuppression, writableStackTrace);
|
||||
public AccessDeniedException(String reason, String detailCode, Object[] detailArgs) {
|
||||
super(HttpStatus.FORBIDDEN, reason, null, detailCode, detailArgs);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
package run.halo.app.infra.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.web.ErrorResponse;
|
||||
|
||||
/**
|
||||
* <p>Exception to be thrown when an async request times out.</p>
|
||||
* By default the exception will be handled as a {@link HttpStatus#REQUEST_TIMEOUT} error.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class AsyncRequestTimeoutException extends RuntimeException implements ErrorResponse {
|
||||
public AsyncRequestTimeoutException() {
|
||||
super();
|
||||
}
|
||||
|
||||
public AsyncRequestTimeoutException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public AsyncRequestTimeoutException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public AsyncRequestTimeoutException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
protected AsyncRequestTimeoutException(String message, Throwable cause,
|
||||
boolean enableSuppression, boolean writableStackTrace) {
|
||||
super(message, cause, enableSuppression, writableStackTrace);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public HttpStatusCode getStatusCode() {
|
||||
return HttpStatus.REQUEST_TIMEOUT;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public ProblemDetail getBody() {
|
||||
return ProblemDetail.forStatusAndDetail(getStatusCode(), getMessage());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
package run.halo.app.infra.exception;
|
||||
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
|
||||
/**
|
||||
* AttachmentAlreadyExistsException accepts filename parameter as detail message arguments.
|
||||
*
|
||||
* @author johnniang
|
||||
*/
|
||||
public class AttachmentAlreadyExistsException extends ServerWebInputException {
|
||||
|
||||
public AttachmentAlreadyExistsException(String filename) {
|
||||
super("File " + filename + " already exists.", null, null, null, new Object[] {filename});
|
||||
}
|
||||
|
||||
}
|
|
@ -1,24 +0,0 @@
|
|||
package run.halo.app.infra.exception;
|
||||
|
||||
public class HaloException extends RuntimeException {
|
||||
|
||||
public HaloException() {
|
||||
}
|
||||
|
||||
public HaloException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public HaloException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public HaloException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public HaloException(String message, Throwable cause, boolean enableSuppression,
|
||||
boolean writableStackTrace) {
|
||||
super(message, cause, enableSuppression, writableStackTrace);
|
||||
}
|
||||
}
|
|
@ -6,7 +6,7 @@ package run.halo.app.infra.exception;
|
|||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class NotFoundException extends HaloException {
|
||||
public class NotFoundException extends RuntimeException {
|
||||
public NotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
package run.halo.app.infra.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @author johnniang
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class ThemeInstallationException extends HaloException {
|
||||
public ThemeInstallationException(String message) {
|
||||
super(message);
|
||||
public class ThemeInstallationException extends ResponseStatusException {
|
||||
|
||||
public ThemeInstallationException(String reason, String detailCode, Object[] detailArgs) {
|
||||
super(HttpStatus.BAD_REQUEST, reason, null, detailCode, detailArgs);
|
||||
}
|
||||
|
||||
public ThemeInstallationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ package run.halo.app.infra.exception;
|
|||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class ThemeUninstallException extends HaloException {
|
||||
public class ThemeUninstallException extends RuntimeException {
|
||||
|
||||
public ThemeUninstallException(String message) {
|
||||
super(message);
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
package run.halo.app.infra.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* ThemeUpgradeException will response bad request status if failed to upgrade theme.
|
||||
*
|
||||
* @author johnniang
|
||||
*/
|
||||
public class ThemeUpgradeException extends ResponseStatusException {
|
||||
|
||||
public ThemeUpgradeException(String reason, String detailCode, Object[] detailArgs) {
|
||||
super(HttpStatus.BAD_REQUEST, reason, null, detailCode, detailArgs);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package run.halo.app.infra.exception;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
public class UserNotFoundException extends ResponseStatusException {
|
||||
|
||||
public UserNotFoundException(String username) {
|
||||
super(HttpStatus.NOT_FOUND, "User " + username + " was not found", null, null,
|
||||
new Object[] {username});
|
||||
}
|
||||
|
||||
}
|
|
@ -1,113 +0,0 @@
|
|||
package run.halo.app.infra.exception.handlers;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
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.web.ErrorResponse;
|
||||
import org.springframework.web.ErrorResponseException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import run.halo.app.extension.exception.ExtensionConvertException;
|
||||
import run.halo.app.extension.exception.ExtensionNotFoundException;
|
||||
import run.halo.app.extension.exception.SchemaViolationException;
|
||||
import run.halo.app.extension.exception.SchemeNotFoundException;
|
||||
import run.halo.app.infra.exception.AccessDeniedException;
|
||||
import run.halo.app.infra.exception.NotFoundException;
|
||||
import run.halo.app.infra.exception.ThemeInstallationException;
|
||||
import run.halo.app.infra.exception.ThemeUninstallException;
|
||||
|
||||
/**
|
||||
* Handle exceptions and convert them to {@link ProblemDetail}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class ExceptionHandlingProblemDetailsHandler {
|
||||
|
||||
@ExceptionHandler({SchemeNotFoundException.class, ExtensionNotFoundException.class,
|
||||
NotFoundException.class})
|
||||
public ProblemDetail handleNotFoundException(Throwable error,
|
||||
ServerWebExchange exchange) {
|
||||
return createProblemDetail(error, HttpStatus.NOT_FOUND,
|
||||
error.getMessage(), exchange);
|
||||
}
|
||||
|
||||
@ExceptionHandler(SchemaViolationException.class)
|
||||
public ProblemDetail handleSchemaViolationException(SchemaViolationException exception,
|
||||
ServerWebExchange exchange) {
|
||||
List<InvalidParam> invalidParams =
|
||||
exception.getErrors().items()
|
||||
.stream()
|
||||
.map(item -> new InvalidParam(item.dataCrumbs(),
|
||||
item.message())
|
||||
)
|
||||
.collect(Collectors.toList());
|
||||
ProblemDetail problemDetail = createProblemDetail(exception, HttpStatus.BAD_REQUEST,
|
||||
exception.getMessage(), exchange);
|
||||
problemDetail.setTitle("Your request parameters didn't validate.");
|
||||
problemDetail.setProperty("invalidParams", invalidParams);
|
||||
return problemDetail;
|
||||
}
|
||||
|
||||
@ExceptionHandler({ExtensionConvertException.class, ThemeInstallationException.class,
|
||||
ThemeUninstallException.class, IllegalArgumentException.class, IllegalStateException.class})
|
||||
public ProblemDetail handleBadRequestException(Throwable error,
|
||||
ServerWebExchange exchange) {
|
||||
return createProblemDetail(error, HttpStatus.BAD_REQUEST,
|
||||
error.getMessage(), exchange);
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ProblemDetail handleAccessDeniedException(AccessDeniedException e,
|
||||
ServerWebExchange exchange) {
|
||||
return createProblemDetail(e, HttpStatus.FORBIDDEN, e.getMessage(), exchange);
|
||||
}
|
||||
|
||||
@ExceptionHandler(OptimisticLockingFailureException.class)
|
||||
public ProblemDetail handleOptimisticLockingFailureException(
|
||||
OptimisticLockingFailureException e, ServerWebExchange exchange) {
|
||||
return createProblemDetail(e, HttpStatus.CONFLICT,
|
||||
e.getMessage(), exchange);
|
||||
}
|
||||
|
||||
public record InvalidParam(String name, String reason) {
|
||||
}
|
||||
|
||||
protected ProblemDetail createProblemDetail(Throwable ex, HttpStatusCode status,
|
||||
String defaultDetail, ServerWebExchange exchange) {
|
||||
|
||||
ErrorResponse response = createFor(
|
||||
ex, status, null, defaultDetail, null, null);
|
||||
|
||||
ProblemDetail problemDetail = response.getBody();
|
||||
problemDetail.setInstance(URI.create(exchange.getRequest().getPath()
|
||||
.pathWithinApplication().value()));
|
||||
return problemDetail;
|
||||
}
|
||||
|
||||
static ErrorResponse createFor(
|
||||
Throwable ex, HttpStatusCode status, @Nullable HttpHeaders headers,
|
||||
String defaultDetail, @Nullable String detailMessageCode,
|
||||
@Nullable Object[] detailMessageArguments) {
|
||||
|
||||
if (detailMessageCode == null) {
|
||||
detailMessageCode = ErrorResponse.getDefaultDetailMessageCode(ex.getClass(), null);
|
||||
}
|
||||
|
||||
ErrorResponseException errorResponse = new ErrorResponseException(
|
||||
status, ProblemDetail.forStatusAndDetail(status, defaultDetail), null,
|
||||
detailMessageCode, detailMessageArguments);
|
||||
|
||||
if (headers != null) {
|
||||
errorResponse.getHeaders().putAll(headers);
|
||||
}
|
||||
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
|
@ -1,161 +0,0 @@
|
|||
package run.halo.app.infra.exception.handlers;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.web.ErrorProperties;
|
||||
import org.springframework.boot.autoconfigure.web.WebProperties;
|
||||
import org.springframework.boot.autoconfigure.web.reactive.error.DefaultErrorWebExceptionHandler;
|
||||
import org.springframework.boot.web.reactive.error.ErrorAttributes;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.web.ErrorResponse;
|
||||
import org.springframework.web.method.annotation.ExceptionHandlerMethodResolver;
|
||||
import org.springframework.web.reactive.BindingContext;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.reactive.result.method.InvocableHandlerMethod;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.theme.ThemeResolver;
|
||||
|
||||
/**
|
||||
* Global error web exception handler.
|
||||
*
|
||||
* @author guqing
|
||||
* @see DefaultErrorWebExceptionHandler
|
||||
* @see ExceptionHandlingProblemDetailsHandler
|
||||
* @see ExceptionHandlerMethodResolver
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Slf4j
|
||||
public class GlobalErrorWebExceptionHandler extends DefaultErrorWebExceptionHandler {
|
||||
private static final MediaType TEXT_HTML_UTF8 =
|
||||
new MediaType("text", "html", StandardCharsets.UTF_8);
|
||||
|
||||
private static final Map<HttpStatus.Series, String> SERIES_VIEWS;
|
||||
|
||||
private final ExceptionHandlingProblemDetailsHandler exceptionHandler =
|
||||
new ExceptionHandlingProblemDetailsHandler();
|
||||
private final ExceptionHandlerMethodResolver handlerMethodResolver =
|
||||
new ExceptionHandlerMethodResolver(ExceptionHandlingProblemDetailsHandler.class);
|
||||
|
||||
private final ErrorProperties errorProperties;
|
||||
|
||||
private final ThemeResolver themeResolver;
|
||||
|
||||
static {
|
||||
Map<HttpStatus.Series, String> views = new EnumMap<>(HttpStatus.Series.class);
|
||||
views.put(HttpStatus.Series.CLIENT_ERROR, "4xx");
|
||||
views.put(HttpStatus.Series.SERVER_ERROR, "5xx");
|
||||
SERIES_VIEWS = Collections.unmodifiableMap(views);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new {@code DefaultErrorWebExceptionHandler} instance.
|
||||
*
|
||||
* @param errorAttributes the error attributes
|
||||
* @param resources the resources configuration properties
|
||||
* @param errorProperties the error configuration properties
|
||||
* @param applicationContext the current application context
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public GlobalErrorWebExceptionHandler(ErrorAttributes errorAttributes,
|
||||
WebProperties.Resources resources,
|
||||
ErrorProperties errorProperties,
|
||||
ApplicationContext applicationContext) {
|
||||
super(errorAttributes, resources, errorProperties, applicationContext);
|
||||
this.errorProperties = errorProperties;
|
||||
this.themeResolver = applicationContext.getBean(ThemeResolver.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Mono<ServerResponse> renderErrorResponse(ServerRequest request) {
|
||||
Throwable error = getError(request);
|
||||
|
||||
if (error instanceof ErrorResponse errorResponse) {
|
||||
return ServerResponse.status(errorResponse.getStatusCode())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(BodyInserters.fromValue(errorResponse.getBody()));
|
||||
}
|
||||
Method exceptionHandlerMethod = handlerMethodResolver.resolveMethodByThrowable(error);
|
||||
if (exceptionHandlerMethod == null) {
|
||||
return noMatchExceptionHandler(error);
|
||||
}
|
||||
|
||||
InvocableHandlerMethod invocable =
|
||||
new InvocableHandlerMethod(exceptionHandler, exceptionHandlerMethod);
|
||||
BindingContext bindingContext = new BindingContext();
|
||||
ServerWebExchange exchange = request.exchange();
|
||||
return invocable.invoke(exchange, bindingContext, error, exchange)
|
||||
.mapNotNull(handleResult -> (ProblemDetail) handleResult.getReturnValue())
|
||||
.flatMap(problemDetail -> ServerResponse.status(problemDetail.getStatus())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(BodyInserters.fromValue(problemDetail)))
|
||||
.switchIfEmpty(Mono.defer(() -> noMatchExceptionHandler(error)));
|
||||
}
|
||||
|
||||
protected Mono<ServerResponse> renderErrorView(ServerRequest request) {
|
||||
Map<String, Object> errorAttributes =
|
||||
getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.TEXT_HTML));
|
||||
int errorStatus = getHttpStatus(errorAttributes);
|
||||
|
||||
ProblemDetail problemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatusCode.valueOf(errorStatus),
|
||||
(String) errorAttributes.get("message"));
|
||||
problemDetail.setInstance(URI.create(request.path()));
|
||||
Map<String, Object> error = Map.of("error", problemDetail);
|
||||
|
||||
ServerResponse.BodyBuilder responseBody =
|
||||
ServerResponse.status(errorStatus).contentType(TEXT_HTML_UTF8);
|
||||
return Flux.just(getData(errorStatus).toArray(new String[] {}))
|
||||
.flatMap((viewName) -> renderErrorViewBy(request, viewName, responseBody, error))
|
||||
.switchIfEmpty(this.errorProperties.getWhitelabel().isEnabled()
|
||||
? renderDefaultErrorView(responseBody, error) : Mono.error(getError(request)))
|
||||
.next();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> renderErrorViewBy(ServerRequest request, String viewName,
|
||||
ServerResponse.BodyBuilder responseBody,
|
||||
Map<String, Object> error) {
|
||||
return themeResolver.isTemplateAvailable(request.exchange().getRequest(), viewName)
|
||||
.flatMap(isAvailable -> {
|
||||
if (isAvailable) {
|
||||
return responseBody.render(viewName, error);
|
||||
}
|
||||
return super.renderErrorView(viewName, responseBody, error);
|
||||
});
|
||||
}
|
||||
|
||||
private List<String> getData(int errorStatus) {
|
||||
List<String> data = new ArrayList<>();
|
||||
data.add("error/" + errorStatus);
|
||||
HttpStatus.Series series = HttpStatus.Series.resolve(errorStatus);
|
||||
if (series != null) {
|
||||
data.add("error/" + SERIES_VIEWS.get(series));
|
||||
}
|
||||
data.add("error/error");
|
||||
return data;
|
||||
}
|
||||
|
||||
Mono<ServerResponse> noMatchExceptionHandler(Throwable error) {
|
||||
return ServerResponse.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(BodyInserters.fromValue(
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
error.getMessage())
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
package run.halo.app.infra.exception.handlers;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.web.ServerProperties;
|
||||
import org.springframework.boot.autoconfigure.web.WebProperties;
|
||||
import org.springframework.boot.autoconfigure.web.reactive.error.ErrorWebFluxAutoConfiguration;
|
||||
import org.springframework.boot.web.reactive.error.ErrorAttributes;
|
||||
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.codec.ServerCodecConfigurer;
|
||||
import org.springframework.web.reactive.result.view.ViewResolver;
|
||||
|
||||
/**
|
||||
* Configuration to render errors via a WebFlux
|
||||
* {@link org.springframework.web.server.WebExceptionHandler}.
|
||||
* <br/>
|
||||
* <br/>
|
||||
* See
|
||||
* {@link org.springframework.boot.autoconfigure.web.reactive.error.ErrorWebFluxAutoConfiguration}
|
||||
* for more.
|
||||
*
|
||||
* @author guqing
|
||||
* @author johnniang
|
||||
* @since 2.1.0
|
||||
*/
|
||||
@Configuration
|
||||
public class HaloErrorConfiguration {
|
||||
|
||||
/**
|
||||
* This bean will replace ErrorWebExceptionHandler defined at
|
||||
* {@link ErrorWebFluxAutoConfiguration#errorWebExceptionHandler}.
|
||||
*/
|
||||
@Bean
|
||||
@Order(-1)
|
||||
ErrorWebExceptionHandler errorWebExceptionHandler(ErrorAttributes errorAttributes,
|
||||
WebProperties webProperties,
|
||||
ObjectProvider<ViewResolver> viewResolvers,
|
||||
ServerCodecConfigurer serverCodecConfigurer,
|
||||
ApplicationContext applicationContext,
|
||||
ServerProperties serverProperties) {
|
||||
var exceptionHandler = new HaloErrorWebExceptionHandler(
|
||||
errorAttributes,
|
||||
webProperties.getResources(),
|
||||
serverProperties.getError(),
|
||||
applicationContext);
|
||||
exceptionHandler.setViewResolvers(viewResolvers.orderedStream().toList());
|
||||
exceptionHandler.setMessageWriters(serverCodecConfigurer.getWriters());
|
||||
exceptionHandler.setMessageReaders(serverCodecConfigurer.getReaders());
|
||||
return exceptionHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* This bean will replace ErrorAttributes defined at
|
||||
* {@link ErrorWebFluxAutoConfiguration#errorAttributes}.
|
||||
*/
|
||||
@Bean
|
||||
ErrorAttributes errorAttributes(MessageSource messageSource) {
|
||||
return new ProblemDetailErrorAttributes(messageSource);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
package run.halo.app.infra.exception.handlers;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.boot.autoconfigure.web.ErrorProperties;
|
||||
import org.springframework.boot.autoconfigure.web.WebProperties;
|
||||
import org.springframework.boot.autoconfigure.web.reactive.error.DefaultErrorWebExceptionHandler;
|
||||
import org.springframework.boot.web.reactive.error.ErrorAttributes;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
public class HaloErrorWebExceptionHandler extends DefaultErrorWebExceptionHandler {
|
||||
|
||||
/**
|
||||
* Create a new {@code DefaultErrorWebExceptionHandler} instance.
|
||||
*
|
||||
* @param errorAttributes the error attributes
|
||||
* @param resources the resources configuration properties
|
||||
* @param errorProperties the error configuration properties
|
||||
* @param applicationContext the current application context
|
||||
* @since 2.4.0
|
||||
*/
|
||||
public HaloErrorWebExceptionHandler(
|
||||
ErrorAttributes errorAttributes,
|
||||
WebProperties.Resources resources,
|
||||
ErrorProperties errorProperties,
|
||||
ApplicationContext applicationContext) {
|
||||
super(errorAttributes, resources, errorProperties, applicationContext);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getHttpStatus(Map<String, Object> errorAttributes) {
|
||||
var problemDetail = (ProblemDetail) errorAttributes.get("error");
|
||||
return problemDetail.getStatus();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Mono<ServerResponse> renderErrorResponse(ServerRequest request) {
|
||||
var errorAttributes =
|
||||
getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
|
||||
return ServerResponse.status(getHttpStatus(errorAttributes))
|
||||
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
|
||||
.bodyValue(errorAttributes.get("error"));
|
||||
}
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
package run.halo.app.infra.exception.handlers;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.web.ServerProperties;
|
||||
import org.springframework.boot.autoconfigure.web.WebProperties;
|
||||
import org.springframework.boot.autoconfigure.web.reactive.error.ErrorWebFluxAutoConfiguration;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.web.reactive.error.ErrorAttributes;
|
||||
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.codec.ServerCodecConfigurer;
|
||||
import org.springframework.web.reactive.result.view.ViewResolver;
|
||||
|
||||
/**
|
||||
* Global exception handler auto configuration.
|
||||
*
|
||||
* @author guqing
|
||||
* @see GlobalErrorWebExceptionHandler
|
||||
* @see
|
||||
* <a href="https://docs.spring.io/spring-framework/docs/6.0.0-SNAPSHOT/reference/html/web-reactive.html#webflux-ann-rest-exceptions-render>webflux-ann-rest-exceptions-render</a>
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({ServerProperties.class, WebProperties.class})
|
||||
public class HaloErrorWebFluxAutoConfiguration {
|
||||
private final ServerProperties serverProperties;
|
||||
|
||||
public HaloErrorWebFluxAutoConfiguration(ServerProperties serverProperties) {
|
||||
this.serverProperties = serverProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default exception handler order is <code>-1</code>, so it is set before it here.
|
||||
*
|
||||
* @see ErrorWebFluxAutoConfiguration
|
||||
*/
|
||||
@Bean
|
||||
@Order(-2)
|
||||
public ErrorWebExceptionHandler errorWebExceptionHandler(ErrorAttributes errorAttributes,
|
||||
WebProperties webProperties, ObjectProvider<ViewResolver> viewResolvers,
|
||||
ServerCodecConfigurer serverCodecConfigurer, ApplicationContext applicationContext) {
|
||||
GlobalErrorWebExceptionHandler exceptionHandler =
|
||||
new GlobalErrorWebExceptionHandler(errorAttributes,
|
||||
webProperties.getResources(), this.serverProperties.getError(), applicationContext);
|
||||
exceptionHandler.setViewResolvers(viewResolvers.orderedStream().toList());
|
||||
exceptionHandler.setMessageWriters(serverCodecConfigurer.getWriters());
|
||||
exceptionHandler.setMessageReaders(serverCodecConfigurer.getReaders());
|
||||
return exceptionHandler;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
package run.halo.app.infra.exception.handlers;
|
||||
|
||||
import static org.springframework.core.annotation.MergedAnnotations.SearchStrategy;
|
||||
import static org.springframework.core.annotation.MergedAnnotations.from;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import org.springframework.boot.web.error.ErrorAttributeOptions;
|
||||
import org.springframework.boot.web.reactive.error.DefaultErrorAttributes;
|
||||
import org.springframework.boot.web.reactive.error.ErrorAttributes;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.core.annotation.MergedAnnotation;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.web.ErrorResponse;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
|
||||
/**
|
||||
* See {@link DefaultErrorAttributes} for more.
|
||||
*
|
||||
* @author johnn
|
||||
*/
|
||||
public class ProblemDetailErrorAttributes implements ErrorAttributes {
|
||||
|
||||
private static final String ERROR_INTERNAL_ATTRIBUTE =
|
||||
ProblemDetailErrorAttributes.class.getName() + ".ERROR";
|
||||
|
||||
private final MessageSource messageSource;
|
||||
|
||||
public ProblemDetailErrorAttributes(MessageSource messageSource) {
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getErrorAttributes(ServerRequest request,
|
||||
ErrorAttributeOptions options) {
|
||||
var errAttributes = new LinkedHashMap<String, Object>();
|
||||
|
||||
var error = getError(request);
|
||||
var responseStatusAnno = from(error.getClass(), SearchStrategy.TYPE_HIERARCHY)
|
||||
.get(ResponseStatus.class);
|
||||
|
||||
var status = determineHttpStatus(error, responseStatusAnno);
|
||||
final ErrorResponse errorResponse;
|
||||
if (error instanceof ErrorResponse er) {
|
||||
errorResponse = er;
|
||||
} else {
|
||||
var reason = responseStatusAnno.getValue("reason", String.class)
|
||||
.orElse(error.getMessage());
|
||||
errorResponse = ErrorResponse.create(error, status, reason);
|
||||
}
|
||||
var problemDetail =
|
||||
errorResponse.updateAndGetBody(messageSource, getLocale(request.exchange()));
|
||||
|
||||
problemDetail.setInstance(URI.create(request.path()));
|
||||
problemDetail.setProperty("requestId", request.exchange().getRequest().getId());
|
||||
problemDetail.setProperty("timestamp", Instant.now());
|
||||
|
||||
// For backward compatibility(rendering view need)
|
||||
errAttributes.put("error", problemDetail);
|
||||
return errAttributes;
|
||||
}
|
||||
|
||||
private HttpStatusCode determineHttpStatus(Throwable t,
|
||||
MergedAnnotation<ResponseStatus> responseStatusAnno) {
|
||||
if (t instanceof ErrorResponse rse) {
|
||||
return rse.getStatusCode();
|
||||
}
|
||||
return responseStatusAnno.getValue("code", HttpStatus.class)
|
||||
.orElse(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
private Locale getLocale(ServerWebExchange exchange) {
|
||||
var locale = exchange.getLocaleContext().getLocale();
|
||||
return locale != null ? locale : Locale.getDefault();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Throwable getError(ServerRequest request) {
|
||||
return (Throwable) request.attribute(ERROR_INTERNAL_ATTRIBUTE).stream()
|
||||
.peek(error -> request.attributes().putIfAbsent(ERROR_ATTRIBUTE, error))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"Missing exception attribute in ServerWebExchange"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void storeErrorInformation(Throwable error, ServerWebExchange exchange) {
|
||||
exchange.getAttributes().putIfAbsent(ERROR_INTERNAL_ATTRIBUTE, error);
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -194,7 +194,8 @@ public abstract class FileUtils {
|
|||
return;
|
||||
}
|
||||
|
||||
throw new AccessDeniedException(pathToCheck.toString());
|
||||
throw new AccessDeniedException("Directory traversal detected: " + pathToCheck,
|
||||
"problemDetail.directoryTraversal", new Object[] {parentPath, pathToCheck});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -28,6 +28,7 @@ halo:
|
|||
logging:
|
||||
level:
|
||||
run.halo.app: DEBUG
|
||||
org.springframework.boot.autoconfigure.web.reactive.error.AbstractErrorWebExceptionHandler: DEBUG
|
||||
springdoc:
|
||||
api-docs:
|
||||
enabled: true
|
||||
|
|
|
@ -2,6 +2,9 @@ server:
|
|||
port: 8090
|
||||
compression:
|
||||
enabled: true
|
||||
error:
|
||||
whitelabel:
|
||||
enabled: false
|
||||
spring:
|
||||
output:
|
||||
ansi:
|
||||
|
@ -16,6 +19,8 @@ spring:
|
|||
platform: h2
|
||||
codec:
|
||||
max-in-memory-size: 10MB
|
||||
messages:
|
||||
basename: config.i18n.messages
|
||||
|
||||
halo:
|
||||
external-url: "http://${server.address:localhost}:${server.port}"
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
# Title definitions
|
||||
problemDetail.title.org.springframework.web.server.ServerWebInputException=Bad Request
|
||||
problemDetail.title.org.springframework.web.server.UnsupportedMediaTypeStatusException=Unsupported Media Type
|
||||
problemDetail.title.org.springframework.web.server.MissingRequestValueException=Missing Request Value
|
||||
problemDetail.title.org.springframework.web.server.UnsatisfiedRequestParameterException=Unsatisfied Request Parameter
|
||||
problemDetail.title.org.springframework.web.bind.support.WebExchangeBindException=Data Binding or Validation Failure
|
||||
problemDetail.title.org.springframework.web.server.NotAcceptableStatusException=Not Acceptable
|
||||
problemDetail.title.org.springframework.web.server.ServerErrorException=Server Error
|
||||
problemDetail.title.org.springframework.web.server.MethodNotAllowedException=Method Not Allowed
|
||||
problemDetail.title.run.halo.app.extension.exception.SchemaViolationException=Schema Violation
|
||||
problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsException=Attachment Already Exists
|
||||
problemDetail.title.run.halo.app.infra.exception.AccessDeniedException=Access Denied
|
||||
problemDetail.title.reactor.core.Exceptions.RetryExhaustedException=Retry Exhausted
|
||||
problemDetail.title.run.halo.app.infra.exception.ThemeInstallationException=Theme Install Error
|
||||
problemDetail.title.run.halo.app.infra.exception.ThemeUpgradeException=Theme Upgrade Error
|
||||
|
||||
# Detail definitions
|
||||
problemDetail.org.springframework.web.server.UnsupportedMediaTypeStatusException=Content type {0} is not supported. Supported media types: {1}.
|
||||
problemDetail.org.springframework.web.server.UnsupportedMediaTypeStatusException.parseError=Could not parse Content-Type.
|
||||
problemDetail.org.springframework.web.server.MissingRequestValueException=Required {0} '{1}' is not present.
|
||||
problemDetail.org.springframework.web.server.UnsatisfiedRequestParameterException=Parameter conditions "{0}" not met for actual request parameters.
|
||||
problemDetail.org.springframework.web.bind.support.WebExchangeBindException=Invalid request content. Global errors: {0}. Field errors: {1}.
|
||||
problemDetail.org.springframework.web.server.NotAcceptableStatusException=Acceptable representations: {0}.
|
||||
problemDetail.org.springframework.web.server.NotAcceptableStatusException.parseError=Could not parse Accept header.
|
||||
problemDetail.org.springframework.web.server.ServerErrorException={0}.
|
||||
problemDetail.org.springframework.web.server.MethodNotAllowedException=Request method {0} is not supported. Supported methods: {1}.
|
||||
problemDetail.run.halo.app.extension.exception.SchemaViolationException={1} of schema {0}.
|
||||
problemDetail.run.halo.app.infra.exception.AttachmentAlreadyExistsException=File {0} already exists, please rename it and try again.
|
||||
|
||||
problemDetail.comment.turnedOff=The comment function has been turned off.
|
||||
problemDetail.comment.systemUsersOnly=Allow only system users to comment
|
||||
problemDetail.theme.upgrade.missingManifest=Missing theme manifest file "theme.yaml" or "theme.yml".
|
||||
problemDetail.theme.upgrade.nameMismatch=The current theme name {0} did not match the installed theme name.
|
||||
problemDetail.theme.install.missingManifest=Missing theme manifest file "theme.yaml" or "theme.yml".
|
||||
problemDetail.theme.install.alreadyExists=Theme {0} already exists.
|
||||
problemDetail.directoryTraversal=Directory traversal detected. Base path is {0}, but real path is {1}.
|
|
@ -0,0 +1,4 @@
|
|||
problemDetail.title.org.springframework.web.server.ServerWebInputException=请求参数有误
|
||||
problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsException=附件已存在
|
||||
|
||||
problemDetail.run.halo.app.infra.exception.AttachmentAlreadyExistsException=文件 {0} 已存在,建议更名后重试。
|
|
@ -8,6 +8,7 @@ import static org.mockito.Mockito.times;
|
|||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf;
|
||||
import static run.halo.app.extension.GroupVersionKind.fromExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
@ -72,7 +73,8 @@ class UserEndpointTest {
|
|||
@Test
|
||||
void shouldResponseErrorIfUserNotFound() {
|
||||
when(client.get(User.class, "fake-user"))
|
||||
.thenReturn(Mono.error(new ExtensionNotFoundException()));
|
||||
.thenReturn(Mono.error(
|
||||
new ExtensionNotFoundException(fromExtension(User.class), "fake-user")));
|
||||
webClient.get().uri("/apis/api.console.halo.run/v1alpha1/users/-")
|
||||
.exchange()
|
||||
.expectStatus().isNotFound();
|
||||
|
@ -142,7 +144,8 @@ class UserEndpointTest {
|
|||
void setUp() {
|
||||
when(client.list(same(RoleBinding.class), any(), any())).thenReturn(Flux.empty());
|
||||
when(client.get(User.class, "fake-user"))
|
||||
.thenReturn(Mono.error(new ExtensionNotFoundException()));
|
||||
.thenReturn(Mono.error(
|
||||
new ExtensionNotFoundException(fromExtension(User.class), "fake-user")));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -14,6 +14,7 @@ import static org.mockito.Mockito.spy;
|
|||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static run.halo.app.extension.GroupVersionKind.fromExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
@ -51,7 +52,7 @@ class UserServiceImplTest {
|
|||
@Test
|
||||
void shouldThrowExceptionIfUserNotFoundInExtension() {
|
||||
when(client.get(User.class, "faker")).thenReturn(
|
||||
Mono.error(new ExtensionNotFoundException()));
|
||||
Mono.error(new ExtensionNotFoundException(fromExtension(User.class), "faker")));
|
||||
|
||||
StepVerifier.create(userService.getUser("faker"))
|
||||
.verifyError(ExtensionNotFoundException.class);
|
||||
|
@ -275,7 +276,8 @@ class UserServiceImplTest {
|
|||
@Test
|
||||
void shouldThrowExceptionIfUserNotFound() {
|
||||
when(client.get(User.class, "fake-user"))
|
||||
.thenReturn(Mono.error(new ExtensionNotFoundException()));
|
||||
.thenReturn(Mono.error(
|
||||
new ExtensionNotFoundException(fromExtension(User.class), "fake-user")));
|
||||
|
||||
StepVerifier.create(userService.updateWithRawPassword("fake-user", "new-password"))
|
||||
.verifyError(ExtensionNotFoundException.class);
|
||||
|
@ -301,7 +303,8 @@ class UserServiceImplTest {
|
|||
@Test
|
||||
void shouldGetNotFoundIfUserNotFound() {
|
||||
when(client.get(User.class, "invalid-user"))
|
||||
.thenReturn(Mono.error(new ExtensionNotFoundException()));
|
||||
.thenReturn(Mono.error(
|
||||
new ExtensionNotFoundException(fromExtension(User.class), "invalid-user")));
|
||||
|
||||
var grantRolesMono = userService.grantRoles("invalid-user", Set.of("fake-role"));
|
||||
StepVerifier.create(grantRolesMono)
|
||||
|
|
|
@ -20,7 +20,6 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
|||
import run.halo.app.extension.SchemeWatcherManager.SchemeRegistered;
|
||||
import run.halo.app.extension.SchemeWatcherManager.SchemeUnregistered;
|
||||
import run.halo.app.extension.SchemeWatcherManager.SchemeWatcher;
|
||||
import run.halo.app.extension.exception.ExtensionException;
|
||||
import run.halo.app.extension.exception.SchemeNotFoundException;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
|
@ -37,7 +36,7 @@ class DefaultSchemeManagerTest {
|
|||
class WithoutGvkExtension extends AbstractExtension {
|
||||
}
|
||||
|
||||
assertThrows(ExtensionException.class,
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> schemeManager.register(WithoutGvkExtension.class));
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
|||
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import run.halo.app.extension.exception.ExtensionException;
|
||||
|
||||
class SchemeTest {
|
||||
|
||||
|
@ -40,9 +39,9 @@ class SchemeTest {
|
|||
class NoGvkExtension extends AbstractExtension {
|
||||
}
|
||||
|
||||
assertThrows(ExtensionException.class,
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> Scheme.getGvkFromType(NoGvkExtension.class));
|
||||
assertThrows(ExtensionException.class,
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> Scheme.buildFromType(NoGvkExtension.class));
|
||||
}
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ import static org.mockito.ArgumentMatchers.same;
|
|||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static run.halo.app.extension.GroupVersionKind.fromExtension;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
@ -92,7 +93,8 @@ class ExtensionDeleteHandlerTest {
|
|||
.pathVariable("name", "my-fake")
|
||||
.build();
|
||||
when(client.get(FakeExtension.class, "my-fake")).thenReturn(
|
||||
Mono.error(new ExtensionNotFoundException()));
|
||||
Mono.error(
|
||||
new ExtensionNotFoundException(fromExtension(FakeExtension.class), "my-fake")));
|
||||
|
||||
var scheme = Scheme.buildFromType(FakeExtension.class);
|
||||
var deleteHandler = new ExtensionDeleteHandler(scheme, client);
|
||||
|
|
|
@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
|||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static run.halo.app.extension.GroupVersionKind.fromExtension;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
@ -64,8 +65,8 @@ class ExtensionGetHandlerTest {
|
|||
var serverRequest = MockServerRequest.builder()
|
||||
.pathVariable("name", "my-fake")
|
||||
.build();
|
||||
when(client.get(eq(FakeExtension.class), eq("my-fake"))).thenReturn(
|
||||
Mono.error(new ExtensionNotFoundException()));
|
||||
when(client.get(eq(FakeExtension.class), eq("my-fake"))).thenReturn(Mono.error(
|
||||
new ExtensionNotFoundException(fromExtension(FakeExtension.class), "my-fake")));
|
||||
|
||||
Mono<ServerResponse> responseMono = getHandler.handle(serverRequest);
|
||||
StepVerifier.create(responseMono)
|
||||
|
|
|
@ -1,145 +0,0 @@
|
|||
package run.halo.app.infra.exception.handlers;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import run.halo.app.infra.exception.AccessDeniedException;
|
||||
import run.halo.app.infra.exception.NotFoundException;
|
||||
import run.halo.app.infra.exception.ThemeUninstallException;
|
||||
|
||||
/**
|
||||
* Tests for {@link GlobalErrorWebExceptionHandler}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureWebTestClient
|
||||
class GlobalErrorWebExceptionHandlerTest {
|
||||
@Autowired
|
||||
private WebTestClient client;
|
||||
|
||||
@Test
|
||||
void renderErrorResponseWhenNotFoundError() {
|
||||
client.get()
|
||||
.uri(uriBuilder -> uriBuilder.path("/hello/errors")
|
||||
.queryParam("type", "notFound")
|
||||
.build())
|
||||
.exchange()
|
||||
.expectStatus().isNotFound()
|
||||
.expectBody()
|
||||
.jsonPath("$.title").isEqualTo("Not Found")
|
||||
.jsonPath("$.detail").isEqualTo("Not Found")
|
||||
.jsonPath("$.instance").isEqualTo("/hello/errors")
|
||||
.jsonPath("$.status").isEqualTo(404);
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderErrorResponseWhenBadRequestError() {
|
||||
client.get()
|
||||
.uri(uriBuilder -> uriBuilder.path("/hello/errors")
|
||||
.queryParam("type", "badRequest1")
|
||||
.build())
|
||||
.exchange()
|
||||
.expectStatus().isBadRequest()
|
||||
.expectBody()
|
||||
.jsonPath("$.title").isEqualTo("Bad Request")
|
||||
.jsonPath("$.detail").isEqualTo("Bad Request")
|
||||
.jsonPath("$.instance").isEqualTo("/hello/errors")
|
||||
.jsonPath("$.status").isEqualTo(400);
|
||||
|
||||
client.get()
|
||||
.uri(uriBuilder -> uriBuilder.path("/hello/errors")
|
||||
.queryParam("type", "badRequest2")
|
||||
.build())
|
||||
.exchange()
|
||||
.expectStatus().isBadRequest()
|
||||
.expectBody()
|
||||
.jsonPath("$.title").isEqualTo("Bad Request")
|
||||
.jsonPath("$.detail").isEqualTo("Bad Request for state")
|
||||
.jsonPath("$.instance").isEqualTo("/hello/errors")
|
||||
.jsonPath("$.status").isEqualTo(400);
|
||||
|
||||
client.get()
|
||||
.uri(uriBuilder -> uriBuilder.path("/hello/errors")
|
||||
.queryParam("type", "badRequest3")
|
||||
.build())
|
||||
.exchange()
|
||||
.expectStatus().isBadRequest()
|
||||
.expectBody()
|
||||
.jsonPath("$.title").isEqualTo("Bad Request")
|
||||
.jsonPath("$.detail").isEqualTo("theme install error")
|
||||
.jsonPath("$.instance").isEqualTo("/hello/errors")
|
||||
.jsonPath("$.status").isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderErrorResponseWhenAccessDeniedError() {
|
||||
client.get()
|
||||
.uri(uriBuilder -> uriBuilder.path("/hello/errors")
|
||||
.queryParam("type", "accessDenied")
|
||||
.build())
|
||||
.exchange()
|
||||
.expectStatus().isForbidden()
|
||||
.expectBody()
|
||||
.jsonPath("$.title").isEqualTo("Forbidden")
|
||||
.jsonPath("$.detail").isEqualTo("Access Denied")
|
||||
.jsonPath("$.instance").isEqualTo("/hello/errors")
|
||||
.jsonPath("$.status").isEqualTo(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderErrorResponseWhenOptimisticLockingFailureError() {
|
||||
client.get()
|
||||
.uri(uriBuilder -> uriBuilder.path("/hello/errors")
|
||||
.queryParam("type", "conflict")
|
||||
.build())
|
||||
.exchange()
|
||||
.expectStatus().isEqualTo(HttpStatus.CONFLICT)
|
||||
.expectBody()
|
||||
.jsonPath("$.title").isEqualTo("Conflict")
|
||||
.jsonPath("$.detail").isEqualTo("Version conflict")
|
||||
.jsonPath("$.instance").isEqualTo("/hello/errors")
|
||||
.jsonPath("$.status").isEqualTo(409);
|
||||
}
|
||||
|
||||
@TestConfiguration
|
||||
static class TestConfig {
|
||||
@Bean
|
||||
public RouterFunction<ServerResponse> routerFunction() {
|
||||
return RouterFunctions.route()
|
||||
.GET("/hello/errors", request -> {
|
||||
String type = request.queryParam("type").orElse("other");
|
||||
if (type.equals("notFound")) {
|
||||
throw new NotFoundException("Not Found");
|
||||
}
|
||||
if (type.equals("badRequest1")) {
|
||||
throw new IllegalArgumentException("Bad Request");
|
||||
}
|
||||
if (type.equals("badRequest2")) {
|
||||
throw new IllegalStateException("Bad Request for state");
|
||||
}
|
||||
if (type.equals("badRequest3")) {
|
||||
throw new ThemeUninstallException("theme install error");
|
||||
}
|
||||
if (type.equals("conflict")) {
|
||||
throw new OptimisticLockingFailureException("Version conflict");
|
||||
}
|
||||
if (type.equals("accessDenied")) {
|
||||
throw new AccessDeniedException("Access Denied");
|
||||
}
|
||||
throw new RuntimeException("Unknown Error");
|
||||
})
|
||||
.build();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,172 @@
|
|||
package run.halo.app.infra.exception.handlers;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureWebTestClient
|
||||
class I18nExceptionTest {
|
||||
|
||||
@Autowired
|
||||
WebTestClient webClient;
|
||||
|
||||
|
||||
@Test
|
||||
void shouldBeOkForGreetingEndpoint() {
|
||||
webClient.get().uri("/response-entity/greet")
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody(String.class).isEqualTo("Hello Halo");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldGetErrorIfErrorResponseThrow() {
|
||||
webClient.get().uri("/response-entity/error-response")
|
||||
.exchange()
|
||||
.expectStatus().isBadRequest()
|
||||
.expectBody(ProblemDetail.class)
|
||||
.value(problemDetail -> {
|
||||
assertEquals("Error Response", problemDetail.getTitle());
|
||||
assertEquals("Message argument is {0}.", problemDetail.getDetail());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void shouldGetErrorIfErrorResponseThrowWithMessageCode() {
|
||||
webClient.get().uri("/response-entity/error-response/with-message-code")
|
||||
.exchange()
|
||||
.expectStatus().isBadRequest()
|
||||
.expectBody(ProblemDetail.class)
|
||||
.value(problemDetail -> {
|
||||
assertEquals("Error Response", problemDetail.getTitle());
|
||||
assertEquals("Something went wrong, argument is fake-arg.",
|
||||
problemDetail.getDetail());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldGetErrorIfErrorResponseThrowWithMessageCodeAndLocaleIsChinese() {
|
||||
webClient.get().uri("/response-entity/error-response/with-message-code")
|
||||
.header(HttpHeaders.ACCEPT_LANGUAGE, "zh-CN,zh")
|
||||
.exchange()
|
||||
.expectStatus().isBadRequest()
|
||||
.expectBody(ProblemDetail.class)
|
||||
.value(problemDetail -> {
|
||||
assertEquals("发生错误", problemDetail.getTitle());
|
||||
assertEquals("发生了一些错误,参数:fake-arg。",
|
||||
problemDetail.getDetail());
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldGetErrorIfThrowingResponseStatusException() {
|
||||
webClient.get().uri("/response-entity/with-response-status-error")
|
||||
.exchange()
|
||||
.expectStatus().isEqualTo(HttpStatus.GONE)
|
||||
.expectBody(ProblemDetail.class)
|
||||
.value(problemDetail -> {
|
||||
assertEquals("Gone", problemDetail.getTitle());
|
||||
assertEquals("Something went wrong",
|
||||
problemDetail.getDetail());
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldGetErrorIfThrowingGeneralException() {
|
||||
webClient.get().uri("/response-entity/general-error")
|
||||
.exchange()
|
||||
.expectStatus().isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.expectBody(ProblemDetail.class)
|
||||
.value(problemDetail -> {
|
||||
assertEquals("Internal Server Error", problemDetail.getTitle());
|
||||
assertEquals("Something went wrong",
|
||||
problemDetail.getDetail());
|
||||
});
|
||||
}
|
||||
|
||||
@TestConfiguration
|
||||
static class TestConfig {
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/response-entity")
|
||||
static class ResponseEntityController {
|
||||
|
||||
@GetMapping("/greet")
|
||||
ResponseEntity<String> greet() {
|
||||
return ResponseEntity.ok("Hello Halo");
|
||||
}
|
||||
|
||||
@GetMapping("/error-response")
|
||||
ResponseEntity<String> throwErrorResponseException() {
|
||||
throw new ErrorResponseException();
|
||||
}
|
||||
|
||||
@GetMapping("/error-response/with-message-args")
|
||||
ResponseEntity<String> throwErrorResponseExceptionWithMessageArgs() {
|
||||
throw new ErrorResponseException("Something went wrong.",
|
||||
null, new Object[] {"fake-arg"});
|
||||
}
|
||||
|
||||
@GetMapping("/error-response/with-message-code")
|
||||
ResponseEntity<String> throwErrorResponseExceptionWithMessageCode() {
|
||||
throw new ErrorResponseException("Something went wrong.",
|
||||
"error.somethingWentWrong", new Object[] {"fake-arg"});
|
||||
}
|
||||
|
||||
@GetMapping("/with-response-status-error")
|
||||
ResponseEntity<String> throwWithResponseStatusException() {
|
||||
throw new WithResponseStatusException();
|
||||
}
|
||||
|
||||
@GetMapping("/general-error")
|
||||
ResponseEntity<String> throwGeneralException() {
|
||||
throw new GeneralException("Something went wrong");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
static class ErrorResponseException extends ResponseStatusException {
|
||||
|
||||
public ErrorResponseException() {
|
||||
this("Something went wrong.");
|
||||
}
|
||||
|
||||
public ErrorResponseException(String reason) {
|
||||
this(reason, null, null);
|
||||
}
|
||||
|
||||
public ErrorResponseException(String reason, String detailCode, Object[] detailArgs) {
|
||||
super(HttpStatus.BAD_REQUEST, reason, null, detailCode, detailArgs);
|
||||
}
|
||||
}
|
||||
|
||||
@ResponseStatus(value = HttpStatus.GONE, reason = "Something went wrong")
|
||||
static class WithResponseStatusException extends RuntimeException {
|
||||
|
||||
}
|
||||
|
||||
static class GeneralException extends RuntimeException {
|
||||
|
||||
public GeneralException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -52,8 +52,10 @@ class PluginStartedListenerTest {
|
|||
Set<String> unstructuredFilePathFromJar =
|
||||
PluginStartedListener.PluginExtensionLoaderUtils.lookupFromJar(targetJarPath);
|
||||
assertThat(unstructuredFilePathFromJar).hasSize(3);
|
||||
assertThat(unstructuredFilePathFromJar).containsAll(Set.of("extensions/roles.yaml",
|
||||
"extensions/reverseProxy.yaml", "extensions/test.yml"));
|
||||
assertThat(unstructuredFilePathFromJar).containsAll(Set.of(
|
||||
Path.of("extensions/roles.yaml").toString(),
|
||||
Path.of("extensions/reverseProxy.yaml").toString(),
|
||||
Path.of("extensions/test.yml").toString()));
|
||||
} finally {
|
||||
FileSystemUtils.deleteRecursively(tempDirectory);
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.eq;
|
|||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static run.halo.app.extension.GroupVersionKind.fromExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
@ -163,7 +164,8 @@ class DefaultUserDetailServiceTest {
|
|||
@Test
|
||||
void shouldNotFindUserDetailsByNonExistingUsername() {
|
||||
when(userService.getUser("non-existing-user")).thenReturn(
|
||||
Mono.error(() -> new ExtensionNotFoundException("The user was not found")));
|
||||
Mono.error(() -> new ExtensionNotFoundException(
|
||||
fromExtension(run.halo.app.core.extension.User.class), "non-existing-user")));
|
||||
|
||||
var userDetailsMono = userDetailService.findByUsername("non-existing-user");
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ import static org.springframework.web.reactive.function.server.RequestPredicates
|
|||
import static org.springframework.web.reactive.function.server.RequestPredicates.PUT;
|
||||
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
|
||||
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
|
||||
import static run.halo.app.extension.GroupVersionKind.fromExtension;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
@ -92,7 +93,8 @@ class AuthorizationTest {
|
|||
|
||||
when(roleService.getMonoRole("post.read")).thenReturn(Mono.just(role));
|
||||
when(roleService.getMonoRole("authenticated")).thenReturn(
|
||||
Mono.error(ExtensionNotFoundException::new));
|
||||
Mono.error(
|
||||
() -> new ExtensionNotFoundException(fromExtension(Role.class), "authenticated")));
|
||||
|
||||
var token = LoginUtils.login(webClient, "user", "password").block();
|
||||
webClient.get().uri("/apis/fake.halo.run/v1/posts")
|
||||
|
|
|
@ -11,6 +11,8 @@ spring:
|
|||
init:
|
||||
mode: always
|
||||
platform: h2
|
||||
messages:
|
||||
basename: config.i18n.messages
|
||||
|
||||
halo:
|
||||
work-dir: ${user.home}/halo-next-test
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
problemDetail.title.run.halo.app.infra.exception.handlers.I18nExceptionTest$ErrorResponseException=Error Response
|
||||
problemDetail.run.halo.app.infra.exception.handlers.I18nExceptionTest$ErrorResponseException=Message argument is {0}.
|
||||
error.somethingWentWrong=Something went wrong, argument is {0}.
|
|
@ -0,0 +1,3 @@
|
|||
problemDetail.title.run.halo.app.infra.exception.handlers.I18nExceptionTest$ErrorResponseException=发生错误
|
||||
problemDetail.run.halo.app.infra.exception.handlers.I18nExceptionTest$ErrorResponseException=参数:{0}。
|
||||
error.somethingWentWrong=发生了一些错误,参数:{0}。
|
Loading…
Reference in New Issue