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
John Niang 2022-12-26 22:10:31 +08:00 committed by GitHub
parent 3a1c264afb
commit da55532777
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 618 additions and 720 deletions

View File

@ -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) {

View File

@ -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()));

View File

@ -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()));
});
}

View File

@ -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)

View File

@ -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();
}
}

View File

@ -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);

View File

@ -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());
}

View File

@ -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();

View File

@ -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

View File

@ -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;
}
}

View File

@ -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

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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});
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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});
}
}

View File

@ -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;
}
}

View File

@ -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())
)
);
}
}

View File

@ -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);
}
}

View File

@ -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"));
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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});
}
/**

View File

@ -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

View File

@ -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}"

View File

@ -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}.

View File

@ -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} 已存在,建议更名后重试。

View File

@ -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

View File

@ -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)

View File

@ -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));
}

View File

@ -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));
}

View File

@ -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);

View File

@ -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)

View File

@ -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();
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}

View File

@ -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");

View File

@ -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")

View File

@ -11,6 +11,8 @@ spring:
init:
mode: always
platform: h2
messages:
basename: config.i18n.messages
halo:
work-dir: ${user.home}/halo-next-test

View File

@ -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}.

View File

@ -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}。