I've written a ISBN-validating annotation. It is to be used in conjunction with Hibernate Validator.
I'm pretty satisfied with this code as it works as expected, but I've had to make a design choice that I totally dislike. This is why I post this question.
The method checkISBN
returns an Optional
regarding the error returned. An empty Optional
then means it's okay. Originally, I sincerely thought about using a custom exceptions, but exceptions shouldn't be used as code structure (Effective Java, 2nd edition, item 59). But the Optional
used as a negative check totally bugs me. Plus, I feel like the method names are totally wrong, since I'm using Optional
in an inverted way and the methods then don't say what they return.
Another point is that I usually favor static methods, but most of my check*
methods aren't because of method references named ISBNvalidator::check*
makes it feels like these methods aren't part of this class. Maybe that's just me.
In a lesser measure, please provide some advice on how to properly name my variables that start with ISBN
. The classes will stay like that, but I just can't properly decide what to do regarding the variable names: use ISBNAnnotation
or isbnAnnotation
, for instance. Also, there's some inconsistency in what I decided: String isbn
is named like that only because I didn't want to name a variable exactly the same as the annotation ISBN
.
Other remarks are naturally welcome, but the previous points are more important in my eyes.
ISBN.java
(The annotation created)
package myapp.book;
import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import javax.validation.Constraint;
@Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE})
@Retention(RUNTIME)
@Constraint(validatedBy = ISBNValidator.class)
@Documented
public @interface ISBN {
Format format() default Format.ANY;
public enum Format {
ISBN_10,
ISBN_13,
ANY;
}
}
ISBNValidator.java
package myapp.book;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.CharMatcher;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
public class ISBNValidator implements ConstraintValidator<ISBN, String> {
private static final Predicate<String> ISBN_10_PATTERN = Pattern.compile("^[0-9]{9}[0-9X]$").asPredicate();
private static final Predicate<String> ISBN_13_PATTERN = Pattern.compile("^[0-9]{13}$").asPredicate();
private static final CharMatcher CLEANUP_MATCHER = CharMatcher.is('-');
@VisibleForTesting
static final String FORMAT_ERROR = "{books.ISBN.format}";
@VisibleForTesting
static final String CHECKSUM_ERROR = "{books.ISBN.checksum}";
private ISBN.Format format;
@Override
public void initialize(ISBN ISBNAnnotation) {
this.format = ISBNAnnotation.format();
}
@Override
public boolean isValid(String isbn, ConstraintValidatorContext context) {
if (isbn == null) {
// That's not @ISBN's role, but @NotNull's
return true;
}
isbn = CLEANUP_MATCHER.removeFrom(isbn);
final Optional<String> error;
switch (format) {
case ISBN_10:
error = checkISBN(isbn, ISBN_10_PATTERN, this::checksumISBN10);
break;
case ISBN_13:
error = checkISBN(isbn, ISBN_13_PATTERN, this::checksumISBN13);
break;
case ANY:
if (ISBN_10_PATTERN.test(isbn)) {
error = checkISBN(isbn, x -> true, this::checksumISBN10);
} else if (ISBN_13_PATTERN.test(isbn)) {
error = checkISBN(isbn, x -> true, this::checksumISBN13);
} else {
error = Optional.of(FORMAT_ERROR);
}
break;
default:
throw new AssertionError("unreachable code");
}
error.ifPresent(e -> {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(e).addConstraintViolation();
});
return !error.isPresent();
}
private Optional<String> checkISBN(String isbn, Predicate<String> formatChecker, Predicate<String> sumChecker) {
if (!formatChecker.test(isbn)) {
return Optional.of(FORMAT_ERROR);
}
if (!sumChecker.test(isbn)) {
return Optional.of(CHECKSUM_ERROR);
}
return Optional.empty();
}
private boolean checksumISBN10(String isbn) {
int sum = 0;
for (int i = 0; i < 9; i++) {
sum += digit(isbn.charAt(i)) * (i + 1);
}
int computedChecksum = sum % 11;
char cs = isbn.charAt(9);
int checksum = cs == 'X' ? 10 : digit(cs);
return checksum == computedChecksum;
}
private boolean checksumISBN13(String isbn) {
int sum = 0;
for (int i = 0; i < 12; i += 2) {
sum += digit(isbn.charAt(i));
sum += digit(isbn.charAt(i + 1)) * 3;
}
int computedChecksum = (10 - sum % 10) % 10;
int checksum = digit(isbn.charAt(12));
return checksum == computedChecksum;
}
private static int digit(char c) {
return Character.digit(c, 10);
}
}