Background
I was working on a coding challenge as part of an interview process. I had to create a REST API where the user can report sensor data and query derived metrics based on several filter criteria.
Ingest
In the sensor data ingest handler I could simply annotate the expected request body entity with validation rules.
@Data
@Builder
public class SensorIngestRequest {
final static String DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss";
@NotNull(message = "must be provided in the following format: " + DATE_FORMAT)
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = DATE_FORMAT, timezone = "GMT")
Date observedAt;
@NotNull(message = "must be provided and it should be a hyphened UUID v4")
UUID deviceId;
@NotNull(message = "must be provided")
Integer measuredValue;
@NotEmpty(message = "must be provided and should not be empty")
String measureUnit;
}
then I could use @Valid
to enforce validation
@PostMapping("/{sensor-name}")
ResponseEntity<?> ingestSensorData(
@PathVariable(name="sensor-name") String sensorName,
@Valid @RequestBody SensorIngestRequest ingestRequest) {
//Compute
var serviceModel = ingestService.ingest(sensorName, ingestRequest);
//Generate Response << not important from the question perspective
//Calculate surrounding days for observed at
var calendar = Calendar.getInstance();
calendar.setTime(serviceModel.getObservedAt());
calendar.set(Calendar.HOUR_OF_DAY, 0);
var from = calendar.getTime();
calendar.add(Calendar.DATE, 1);
var till = calendar.getTime();
//Generate links << not important from the question perspective
var linkSingle = createLinkForGetMetric(sensorName, from, till, Optional.of(ingestRequest.getDeviceId()), LinkRelation.of(RELATION_NAME_FOR_SINGLE_DEVICE_METRIC));
var linkAll = createLinkForGetMetric(sensorName, from, till, Optional.empty(), LinkRelation.of(RELATION_NAME_FOR_ALL_DEVICES_METRIC));
var responseModel = EntityModel.of(serviceModel, linkSingle, linkAll);
return ResponseEntity
.created(responseModel.getRequiredLink(RELATION_NAME_FOR_SINGLE_DEVICE_METRIC).toUri())
.body(responseModel);
}
If the validation fails then it throws a MethodArgumentNotValidException
against which I can define a ControllerAdvice
@ControllerAdvice
public class ValidationExceptionAdvice {
final static String ERROR_FORMAT = "'%s' parameter %s";
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Problem> handleMANVE(MethodArgumentNotValidException ex) {
var errors = ex.getBindingResult().getAllErrors().stream()
.map(error -> String.format(ERROR_FORMAT,((FieldError) error).getField(), error.getDefaultMessage()))
.sorted() //Make errors' ordering consistent
.collect(Collectors.toList());
return getResponseEntity(errors);
}
ResponseEntity<Problem> getResponseEntity(List<String> errors) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(Problem.create()
.withTitle("Invalid data has been provided, please correct it")
.withDetail(errors.toString()));
}
}
Query
In the query/search handler I anticipate two dates (a from
and a to
). These could be used to narrow the date interval against which the metric calculation should be performed. A sample request looks like this
GET /api/v1/sensors/windSpeed/metrics/avg?from=2023-05-22&till=2023-05-23
@GetMapping("/{sensor-name}/metrics/{metric-type}")
ResponseEntity<?> getSensorMetricForADateRangeForADevice(
@PathVariable(name="sensor-name") String sensorName,
@PathVariable(name="metric-type") String metricType,
@RequestParam("from") @Valid @DateTimeFormat(pattern="yyyy-MM-dd") Date from,
@RequestParam("till") @Valid @DateTimeFormat(pattern="yyyy-MM-dd") Date till,
@RequestParam(name="device-id",required = false) Optional<UUID> deviceId) {
//Extra input data validation
if (!"avg".equalsIgnoreCase(metricType)) {
throw createCVE("metric-type", 1, metricType, String.class, "currently supports only 'avg'");
}
if (from.compareTo(till) > 0) {
throw createCVE("till", 3, till, Date.class,"should be greater than from");
}
//Generate Response
//... << omitted for the sake of brevity
return ResponseEntity.ok().body(EntityModel.of(serviceModel, linkSingle, linkAll));
}
As you can see I had to compare from
and to
to make sure that from
is smaller than to
. I have found pretty hard to throw a hand-crafted MethodArgumentNotValidException
. So, I've decided to use ConstraintViolationException
instead. Well, that is also not an easy peasy task.
<T> ConstraintViolationException createCVE(
String parameterName,
int parameterIndex,
T parameter,
Class<T> parameterType,
String violationMessage) {
//NOTE: here the method name does not matter
final var propertyPath = PathImpl.createPathFromString("");
propertyPath.addParameterNode(parameterName, parameterIndex);
var constraintViolation = ConstraintViolationImpl.forParameterValidation(
violationMessage,
Collections.emptyMap(),
Collections.emptyMap(),
violationMessage,
parameterType,
parameter,
null,
parameter,
propertyPath,
null,
null,
null);
return new ConstraintViolationException(Set.of(constraintViolation));
}
Quite frankly I don't like approach either. I had to write another ExceptionHandler
as well to create a proper Problem
response.
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Problem> handleCVE(ConstraintViolationException ex) {
var errors = ex.getConstraintViolations().stream()
.map(error -> String.format(ERROR_FORMAT, error.getPropertyPath(), error.getMessage()))
.collect(Collectors.toList());
return getResponseEntity(errors);
}
Question
- Is there any better way to perform custom cross parameter check for GET request?
Date
that has been deprecated cause a lot of problems in favour of theLocalDate
class , is this required from the interview? Because you are using streams soLocalDate
is equally available. \$\endgroup\$BindingResult
class like here, its behaviour is still the same in the rest controller or mvc controller. \$\endgroup\$BindingResult
help to perform cross parameter check (from
points to an earlier date thantill
)? According to my understanding this collects the validation errors. Or shall I use theaddError
with aFieldError
if my custom validation fails? \$\endgroup\$BindingResult
because you can intercept@NotNull
and@DateTimeFormat
validation errors and then create anew ResponseEntity(errormessage, HttpStatus.BAD_REQUEST)
if BindingResult is not empty.In this case you should create a ResponseEntity for comparing at runtime the two dates with the error after checked BindingResults is empty. Alternatively you can create a new custom annotation for comparing dates like here but it seems me more complicated. \$\endgroup\$