Skip to content

Commit

Permalink
Improve error reporting system
Browse files Browse the repository at this point in the history
Now with more detailed errors, including the error endpoints. There's a clipboard icon to copy a detailed bug report to the clipboard.
  • Loading branch information
zapek committed Nov 19, 2024
1 parent 1e8a15d commit e74bff6
Show file tree
Hide file tree
Showing 12 changed files with 155 additions and 286 deletions.
63 changes: 30 additions & 33 deletions app/src/main/java/io/xeres/app/api/DefaultHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,18 @@
import io.swagger.v3.oas.annotations.info.License;
import io.xeres.app.api.exception.UnprocessableEntityException;
import io.xeres.common.AppName;
import io.xeres.common.rest.Error;
import io.xeres.common.rest.ErrorResponseEntity;
import jakarta.persistence.EntityNotFoundException;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import org.springframework.web.server.ResponseStatusException;

import java.io.PrintWriter;
import java.io.StringWriter;
import java.net.UnknownHostException;
import java.util.NoSuchElementException;

Expand All @@ -49,7 +47,7 @@
version = "0.1",
description = "This is the REST API available for UI clients.",
license = @License(name = "GPL v3", url = "https://www.gnu.org/licenses/gpl-3.0.en.html"),
contact = @Contact(url = "https://zapek.com", name = "David Gerber", email = "info@zapek.com")
contact = @Contact(url = "https://zapek.com", name = "David Gerber", email = "dg@zapek.com")
)
)
public class DefaultHandler
Expand All @@ -60,43 +58,38 @@ public class DefaultHandler
NoSuchElementException.class,
EntityNotFoundException.class,
UnknownHostException.class})
public ResponseEntity<Error> handleNotFoundException(Exception e)
public ErrorResponse handleNotFoundException(Exception e)
{
log.error("Exception: {}, {}", e.getClass().getCanonicalName(), e.getMessage());
var builder = new ErrorResponseEntity.Builder(HttpStatus.NOT_FOUND)
.setError(e.getMessage())
.setStackTrace(getStackTrace(e));

return builder.build();
logError(e, false);
return ErrorResponse.builder(e, HttpStatus.NOT_FOUND, e.getMessage())
.property("trace", ExceptionUtils.getStackTrace(e))
.build();
}

@ExceptionHandler(UnprocessableEntityException.class)
public ResponseEntity<Error> handleUnprocessableEntityException(UnprocessableEntityException e)
public ErrorResponse handleUnprocessableEntityException(UnprocessableEntityException e)
{
log.error("Exception: {}, {}", e.getClass().getCanonicalName(), e.getMessage());
return new ErrorResponseEntity.Builder(HttpStatus.UNPROCESSABLE_ENTITY)
.setError(e.getMessage())
.setStackTrace(getStackTrace(e))
logError(e, false);
return ErrorResponse.builder(e, HttpStatus.UNPROCESSABLE_ENTITY, e.getMessage())
.property("trace", ExceptionUtils.getStackTrace(e))
.build();
}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Error> handleIllegalArgumentException(IllegalArgumentException e)
public ErrorResponse handleIllegalArgumentException(IllegalArgumentException e)
{
log.error("Exception: {}, {}", e.getClass().getCanonicalName(), e.getMessage());
return new ErrorResponseEntity.Builder(HttpStatus.BAD_REQUEST)
.setError(e.getMessage())
.setStackTrace(getStackTrace(e))
logError(e, false);
return ErrorResponse.builder(e, HttpStatus.BAD_REQUEST, e.getMessage())
.property("trace", ExceptionUtils.getStackTrace(e))
.build();
}

@ExceptionHandler(RuntimeException.class)
public ResponseEntity<Error> handleRuntimeException(RuntimeException e)
@ExceptionHandler(Exception.class)
public ErrorResponse handleException(Exception e)
{
log.error("RuntimeException: {}, {}", e.getClass().getCanonicalName(), e.getMessage(), e);
return new ErrorResponseEntity.Builder(HttpStatus.INTERNAL_SERVER_ERROR)
.setError(e.getMessage())
.setStackTrace(getStackTrace(e))
logError(e, true);
return ErrorResponse.builder(e, HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage())
.property("trace", ExceptionUtils.getStackTrace(e))
.build();
}

Expand All @@ -119,11 +112,15 @@ public void handleAsyncRequestNotUsableException(AsyncRequestNotUsableException
// We ignore those because they happen when scrolling images (we abort useless loads when scrolling quickly).
}

private String getStackTrace(Exception e)
private void logError(Exception e, boolean withStackTrace)
{
var sw = new StringWriter();
var pw = new PrintWriter(sw);
e.printStackTrace(pw);
return sw.toString();
if (withStackTrace)
{
log.error("{}: {}", e.getClass().getSimpleName(), e.getMessage(), e);
}
else
{
log.error("{}: {}", e.getClass().getSimpleName(), e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
import io.xeres.app.xrs.service.identity.IdentityRsService;
import io.xeres.app.xrs.service.status.StatusRsService;
import io.xeres.common.location.Availability;
import io.xeres.common.rest.Error;
import io.xeres.common.rest.config.*;
import jakarta.validation.Valid;
import jakarta.xml.bind.JAXBException;
Expand All @@ -47,6 +46,7 @@
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
Expand Down Expand Up @@ -94,8 +94,8 @@ public ConfigController(ProfileService profileService, LocationService locationS
@Operation(summary = "Create own profile")
@ApiResponse(responseCode = "200", description = "Profile already exists")
@ApiResponse(responseCode = "201", description = "Profile created successfully", headers = @Header(name = "Location", description = "The location of the created profile", schema = @Schema(type = "string")))
@ApiResponse(responseCode = "422", description = "Profile entity cannot be processed", content = @Content(schema = @Schema(implementation = Error.class)))
@ApiResponse(responseCode = "500", description = "Serious error", content = @Content(schema = @Schema(implementation = Error.class)))
@ApiResponse(responseCode = "422", description = "Profile entity cannot be processed", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@ApiResponse(responseCode = "500", description = "Serious error", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<Void> createOwnProfile(@Valid @RequestBody OwnProfileRequest ownProfileRequest)
{
var name = ownProfileRequest.name();
Expand All @@ -117,7 +117,7 @@ public ResponseEntity<Void> createOwnProfile(@Valid @RequestBody OwnProfileReque
@Operation(summary = "Create own location")
@ApiResponse(responseCode = "200", description = "Location already exists")
@ApiResponse(responseCode = "201", description = "Location created successfully", headers = @Header(name = "Location", description = "The location of the created location", schema = @Schema(type = "string")))
@ApiResponse(responseCode = "500", description = "Serious error", content = @Content(schema = @Schema(implementation = Error.class)))
@ApiResponse(responseCode = "500", description = "Serious error", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<Void> createOwnLocation(@Valid @RequestBody OwnLocationRequest ownLocationRequest)
{
var name = ownLocationRequest.name();
Expand Down Expand Up @@ -154,7 +154,7 @@ public ResponseEntity<Void> changeAvailability(@RequestBody Availability availab
@Operation(summary = "Create own identity")
@ApiResponse(responseCode = "200", description = "Identity already exists")
@ApiResponse(responseCode = "201", description = "Identity created successfully")
@ApiResponse(responseCode = "500", description = "Serious error", content = @Content(schema = @Schema(implementation = Error.class)))
@ApiResponse(responseCode = "500", description = "Serious error", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<Void> createOwnIdentity(@Valid @RequestBody OwnIdentityRequest ownIdentityRequest)
{
var name = ownIdentityRequest.name();
Expand Down Expand Up @@ -196,7 +196,7 @@ public ResponseEntity<Void> updateExternalIpAddress(@Valid @RequestBody IpAddres
@GetMapping("/external-ip")
@Operation(summary = "Get the external IP address and port.", description = "Note that an external IP address is not strictly required if for example the host is on a public IP already.")
@ApiResponse(responseCode = "200", description = "Request successful")
@ApiResponse(responseCode = "404", description = "No location or no external IP address", content = @Content(schema = @Schema(implementation = Error.class)))
@ApiResponse(responseCode = "404", description = "No location or no external IP address", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public IpAddressResponse getExternalIpAddress()
{
var connection = locationService.findOwnLocation().orElseThrow()
Expand All @@ -211,7 +211,7 @@ public IpAddressResponse getExternalIpAddress()
@GetMapping("/internal-ip")
@Operation(summary = "Get the internal IP address and port.")
@ApiResponse(responseCode = "200", description = "Request successful")
@ApiResponse(responseCode = "404", description = "No location or no internal IP address", content = @Content(schema = @Schema(implementation = Error.class)))
@ApiResponse(responseCode = "404", description = "No location or no internal IP address", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public IpAddressResponse getInternalIpAddress()
{
return new IpAddressResponse(Optional.ofNullable(networkService.getLocalIpAddress()).orElseThrow(), networkService.getPort());
Expand All @@ -220,7 +220,7 @@ public IpAddressResponse getInternalIpAddress()
@GetMapping("/hostname")
@Operation(summary = "Get the machine's hostname.")
@ApiResponse(responseCode = "200", description = "Request successful")
@ApiResponse(responseCode = "404", description = "No hostname (host configuration problem)", content = @Content(schema = @Schema(implementation = Error.class)))
@ApiResponse(responseCode = "404", description = "No hostname (host configuration problem)", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public HostnameResponse getHostname() throws UnknownHostException
{
return new HostnameResponse(locationService.getHostname());
Expand All @@ -229,7 +229,7 @@ public HostnameResponse getHostname() throws UnknownHostException
@GetMapping("/username")
@Operation(summary = "Get the OS session's username.")
@ApiResponse(responseCode = "200", description = "Request successful")
@ApiResponse(responseCode = "404", description = "No username (no user session)", content = @Content(schema = @Schema(implementation = Error.class)))
@ApiResponse(responseCode = "404", description = "No username (no user session)", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public UsernameResponse getUsername()
{
return new UsernameResponse(locationService.getUsername());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.xeres.app.service.GeoIpService;
import io.xeres.common.rest.Error;
import io.xeres.common.rest.geoip.CountryResponse;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.http.MediaType;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -54,7 +54,7 @@ public GeoIpController(GeoIpService geoIpService)
@GetMapping("/{ip}")
@Operation(summary = "Get the ISO country code of the IP address.")
@ApiResponse(responseCode = "200", description = "Request successful")
@ApiResponse(responseCode = "404", description = "No country found for IP address", content = @Content(schema = @Schema(implementation = Error.class)))
@ApiResponse(responseCode = "404", description = "No country found for IP address", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public CountryResponse getIsoCountry(@PathVariable String ip)
{
var country = geoIpService.getCountry(ip);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@
import io.xeres.common.dto.identity.IdentityDTO;
import io.xeres.common.id.GxsId;
import io.xeres.common.identity.Type;
import io.xeres.common.rest.Error;
import io.xeres.common.util.ImageDetectionUtils;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
Expand Down Expand Up @@ -74,7 +74,7 @@ public IdentityController(IdentityService identityService, IdentityRsService ide
@GetMapping("/{id}")
@Operation(summary = "Return an identity")
@ApiResponse(responseCode = "200", description = "Identity found")
@ApiResponse(responseCode = "404", description = "Identity not found", content = @Content(schema = @Schema(implementation = Error.class)))
@ApiResponse(responseCode = "404", description = "Identity not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public IdentityDTO findIdentityById(@PathVariable long id)
{
return toDTO(identityService.findById(id).orElseThrow());
Expand All @@ -84,7 +84,7 @@ public IdentityDTO findIdentityById(@PathVariable long id)
@Operation(summary = "Return an identity's avatar image")
@ApiResponse(responseCode = "200", description = "Identity's avatar image found")
@ApiResponse(responseCode = "204", description = "Identity's avatar image is empty")
@ApiResponse(responseCode = "404", description = "Identity not found", content = @Content(schema = @Schema(implementation = Error.class)))
@ApiResponse(responseCode = "404", description = "Identity not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<InputStreamResource> downloadIdentityImage(@PathVariable long id)
{
var identity = identityService.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // Bypass the global controller advice because it only knows about application/json mimetype
Expand Down Expand Up @@ -134,9 +134,9 @@ public ResponseEntity<InputStreamResource> downloadImageByGxsId(@RequestParam(va
@PostMapping(value = "/{id}/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "Change an identity's avatar image")
@ApiResponse(responseCode = "201", description = "Identity's avatar image created")
@ApiResponse(responseCode = "404", description = "Identity not found", content = @Content(schema = @Schema(implementation = Error.class)))
@ApiResponse(responseCode = "415", description = "Image's media type unsupported", content = @Content(schema = @Schema(implementation = Error.class)))
@ApiResponse(responseCode = "422", description = "Image unprocessable", content = @Content(schema = @Schema(implementation = Error.class)))
@ApiResponse(responseCode = "404", description = "Identity not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@ApiResponse(responseCode = "415", description = "Image's media type unsupported", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
@ApiResponse(responseCode = "422", description = "Image unprocessable", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<Void> uploadIdentityImage(@PathVariable long id, @RequestBody MultipartFile file) throws IOException
{
var identity = identityRsService.saveOwnIdentityImage(id, file);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@
import io.xeres.app.service.LocationService;
import io.xeres.app.service.QrCodeService;
import io.xeres.common.dto.location.LocationDTO;
import io.xeres.common.rest.Error;
import io.xeres.common.rest.location.RSIdResponse;
import io.xeres.common.rsid.Type;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

Expand All @@ -60,7 +60,7 @@ public LocationController(LocationService locationService, QrCodeService qrCodeS
@GetMapping("/{id}")
@Operation(summary = "Return a location")
@ApiResponse(responseCode = "200", description = "Location found")
@ApiResponse(responseCode = "404", description = "Location not found", content = @Content(schema = @Schema(implementation = Error.class)))
@ApiResponse(responseCode = "404", description = "Location not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public LocationDTO findLocationById(@PathVariable long id)
{
return toDTO(locationService.findLocationById(id).orElseThrow());
Expand All @@ -69,7 +69,7 @@ public LocationDTO findLocationById(@PathVariable long id)
@GetMapping("/{id}/rs-id")
@Operation(summary = "Return a location's RSId")
@ApiResponse(responseCode = "200", description = "Location found")
@ApiResponse(responseCode = "404", description = "Profile not found", content = @Content(schema = @Schema(implementation = Error.class)))
@ApiResponse(responseCode = "404", description = "Profile not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public RSIdResponse getRSIdOfLocation(@PathVariable long id, @RequestParam(value = "type", required = false) Type type)
{
var location = locationService.findLocationById(id).orElseThrow();
Expand All @@ -80,7 +80,7 @@ public RSIdResponse getRSIdOfLocation(@PathVariable long id, @RequestParam(value
@GetMapping(value = "/{id}/rs-id/qr-code", produces = MediaType.IMAGE_PNG_VALUE)
@Operation(summary = "Return a location's RSId as a QR code")
@ApiResponse(responseCode = "200", description = "Location found")
@ApiResponse(responseCode = "404", description = "Profile not found", content = @Content(schema = @Schema(implementation = Error.class)))
@ApiResponse(responseCode = "404", description = "Profile not found", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<BufferedImage> getRSIdOfLocationAsQrCode(@PathVariable long id)
{
var location = locationService.findLocationById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); // Bypass the global controller advice because it only knows about application/json mimetype
Expand Down
Loading

0 comments on commit e74bff6

Please sign in to comment.