Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 84 additions & 9 deletions src/main/java/goodroad/api/ApiErrors.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
package goodroad.api;

import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import org.springframework.web.servlet.resource.NoResourceFoundException;

import java.time.Instant;

@Slf4j
Expand All @@ -23,8 +36,8 @@ public static class ApiException extends RuntimeException {
private final HttpStatus status;
private final String code;

public ApiException(HttpStatus status, String code, String msg) {
super(msg);
public ApiException(HttpStatus status, String code, String message) {
super(message);
this.status = status;
this.code = code;
}
Expand All @@ -42,16 +55,78 @@ public String code() {
public static class GlobalHandler {

@ExceptionHandler(ApiException.class)
public ResponseEntity<ApiError> handleApiException(ApiException e) {
log.error("API exception: code={}, msg={}", e.code(), e.getMessage(), e);
return ResponseEntity.status(e.status()).body(ApiError.of(e.code(), e.getMessage()));
public ResponseEntity<ApiError> handleApiException(ApiException exception) {
if (exception.status().is5xxServerError()) {
log.error("API exception: code={}, msg={}", exception.code(), exception.getMessage(), exception);
}
return ResponseEntity.status(exception.status())
.body(ApiError.of(exception.code(), exception.getMessage()));
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException exception) {
return badRequest("REQUEST_VALIDATION_FAILED", "Request fields are invalid");
}

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiError> handleUnreadableBody(HttpMessageNotReadableException exception) {
return badRequest("REQUEST_BODY_INVALID", "Request body is missing or contains invalid JSON");
}

@ExceptionHandler({
MethodArgumentTypeMismatchException.class,
MissingServletRequestParameterException.class,
ConstraintViolationException.class,
HandlerMethodValidationException.class
})
public ResponseEntity<ApiError> handleInvalidParameters(Exception exception) {
return badRequest("REQUEST_VALIDATION_FAILED", "Request parameters are invalid");
}

@ExceptionHandler(MissingServletRequestPartException.class)
public ResponseEntity<ApiError> handleMissingPart(MissingServletRequestPartException exception) {
return badRequest("REQUEST_PART_MISSING", "Required request part is missing");
}

@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiError> handleUploadTooLarge(MaxUploadSizeExceededException exception) {
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(ApiError.of("FILE_TOO_LARGE", "Uploaded file is too large"));
}

@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<ApiError> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException exception) {
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
.body(ApiError.of("CONTENT_TYPE_UNSUPPORTED", "Content type is not supported"));
}

@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ApiError> handleConflict(DataIntegrityViolationException exception) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiError.of("DATA_CONFLICT", "Operation conflicts with current data"));
}

@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiError> handleAccessDenied(AccessDeniedException exception) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiError.of("ACCESS_DENIED", "Access denied"));
}

@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ApiError> handleNotFound(NoResourceFoundException exception) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiError.of("ENDPOINT_NOT_FOUND", "Endpoint not found"));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleServerError(Exception e) {
log.error("Unexpected exception", e);
public ResponseEntity<ApiError> handleServerError(Exception exception) {
log.error("Unexpected exception", exception);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiError.of("SERVER_INTERNAL_ERROR", "Server internal error"));
}

private ResponseEntity<ApiError> badRequest(String code, String message) {
return ResponseEntity.badRequest().body(ApiError.of(code, message));
}
}
}
}
11 changes: 10 additions & 1 deletion src/main/java/goodroad/users/repository/UserRepo.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
import jakarta.persistence.LockModeType;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
Expand All @@ -10,6 +11,14 @@ public interface UserRepo extends JpaRepository<UserEntity, Long> {

Optional<UserEntity> findByPhoneHash(String phoneHash);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select user from UserEntity user where user.phoneHash = :phoneHash")
Optional<UserEntity> findByPhoneHashForUpdate(@Param("phoneHash") String phoneHash);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select user from UserEntity user where user.id = :id")
Optional<UserEntity> findByIdForUpdate(@Param("id") Long id);

List<UserEntity> findByRoleIn(List<String> roles);

@Modifying
Expand All @@ -20,4 +29,4 @@ public interface UserRepo extends JpaRepository<UserEntity, Long> {
and user.lastActiveAt < :cutoff
""")
int deleteInactiveBefore(@Param("cutoff") Instant cutoff);
}
}
63 changes: 63 additions & 0 deletions src/main/java/goodroad/validation/GeoUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package goodroad.validation;

import goodroad.api.ApiErrors.ApiException;
import org.springframework.http.HttpStatus;

public final class GeoUtils {
private static final double EARTH_RADIUS_KM = 6371.0;

private GeoUtils() {
}

public static Coordinates requireCoordinates(Double latitude, Double longitude, String code) {
if (latitude == null || longitude == null
|| !Double.isFinite(latitude) || !Double.isFinite(longitude)
|| latitude < -90 || latitude > 90
|| longitude < -180 || longitude > 180) {
throw new ApiException(HttpStatus.BAD_REQUEST, code, "Координаты должны содержать допустимые широту и долготу");
}
return new Coordinates(latitude, longitude);
}

public static Coordinates parseLatLon(String value, String fieldName) {
if (value == null) {
throw invalidPoint(fieldName);
}
String[] parts = value.split(",", -1);
if (parts.length != 2) {
throw invalidPoint(fieldName);
}
try {
return requireCoordinates(
Double.parseDouble(parts[0].trim()),
Double.parseDouble(parts[1].trim()),
"ROUTE_POINT_INVALID"
);
} catch (NumberFormatException e) {
throw invalidPoint(fieldName);
}
}

public static double distanceKm(double firstLat, double firstLon, double secondLat, double secondLon) {
double latitudeDelta = Math.toRadians(secondLat - firstLat);
double longitudeDelta = Math.toRadians(secondLon - firstLon);
double a = Math.sin(latitudeDelta / 2) * Math.sin(latitudeDelta / 2)
+ Math.cos(Math.toRadians(firstLat)) * Math.cos(Math.toRadians(secondLat))
* Math.sin(longitudeDelta / 2) * Math.sin(longitudeDelta / 2);
return EARTH_RADIUS_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

private static ApiException invalidPoint(String fieldName) {
return new ApiException(
HttpStatus.BAD_REQUEST,
"ROUTE_POINT_INVALID",
"Поле " + fieldName + " должно иметь формат latitude,longitude"
);
}

public record Coordinates(double latitude, double longitude) {
public String asLatLon() {
return latitude + "," + longitude;
}
}
}
2 changes: 1 addition & 1 deletion src/main/java/goodroad/validation/InputRules.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ private InputRules() {

public static String requireCyrillicText(String value, String code, String fieldName) {
String normalized = trimToNull(value);
if (normalized == null || !CYRILLIC_TEXT.matcher(normalized).matches()) {
if (normalized == null || normalized.length() > 80 || !CYRILLIC_TEXT.matcher(normalized).matches()) {
throw new ApiException(HttpStatus.BAD_REQUEST, code, fieldName + " is invalid");
}
return normalized;
Expand Down