W jaki sposób podchodzicie do walidacji danych wejściowych?
Możemy założyć, że używamy springa i mamy api restowe.
Powiedzmy, że mamy prosty request do rejestracji użytkownika zawierający nick, email, password.
Chcielibyśmy na tym requescie odpalić zestaw jakichś walidacji np:
- nick nie może być nullem i nie może być pusty
- email nie może być nullem, nie może być pusty i musi być poprawnym emailem
- password musi spełniać odpowiednie kryteria np. znaki specjalne, duże i małe litery itd.
- nick nie może być zajęty
- nie można 2x zarejestrować się na ten sam email
Możemy rozpocząć od zdefiniowania DTO:
public class UserRegistrationRequest {
@NotBlank
public final String nick;
@NotBlank
public final String email;
@Email
public final String password;
@JsonCreator
public UserRegistrationRequest(@JsonProperty("nick") String nick,
@JsonProperty("email") String email,
@JsonProperty("password") String password) {
this.nick = nick;
this.email = email;
this.password = password;
}
}
Jak widać dodałem podstawową walidację jednak i tak nie spełnia ona wszystkich
kryteriów walidacji, które wymieniłem na początku więc muszę zwalidować dalej
w serwisie w większych szczegółach. Tu przy okazji czy lubicie tego typu walidację
przez adnotacje? Nie ma ona zbyt dużych możliwości chociaż z tego co wiem można
tworzyć customowe walidatory.
Idąc dalej mamy serwis i walidujemy dalej. I tutaj mamy wiele możliwości jak to walidować.
Możemy po prostu wstrzyknąć jakiś walidator, który nam zwaliduje request przychodzący,
który ma wstrzyknięte jakieś repository/dao (konstruktory pominięte dla czytelności).
public class UserRegistrationService {
private final UserRegistrationRequestValidator requestValidator;
public Either<AppError, UserRegistrationResponse> registerUser(UserRegistrationRequest request) {
return requestValidator.validate(request).flatMap(this::register);
}
private Either<AppError, UserRegistrationResponse> register(UserRegistrationRequest request) {
...
}
}
public class UserRegistrationRequestValidator {
private final UserDao userDao;
public Either<AppError, UserRegistrationRequest> validate(UserRegistrationRequest request) {
return Either.right(request)
.flatMap(this::validateNick)
.flatMap(this::validateEmail)
.flatMap(this::validatePassword)
.flatMap(this::validateNickUniqueness)
.flatMap(this::validateEmailUniqueness);
}
//...
}
Kolejnym sposobem może być wykonanie tej walidacji w jakimś konwerterze albo factory,
który konwertuje / tworzy nam obiekt domenowy User:
public class UserRegistrationRequestConverter {
public Either<AppError, User> convert(UserRegistrationRequest request) {
return Either.right(request)
.flatMap(this::validateNick)
.flatMap(this::validateEmail)
.flatMap(this::validatePassword)
.flatMap(this::validateNickUniqueness)
.flatMap(this::validateEmailUniqueness)
.map(this::createUser);
}
//...
}
Z drugiej strony możemy też wstrzyknąć tamten walidator do tego konwertera
i napisać konwersję / factory walidując z wykorzystaniem tamtego walidatora.
Tym razem posłużę się factory:
public class UserFactory {
private final UserRegistrationRequestValidator requestValidator;
public Either<AppError, User> createUser(UserRegistrationRequest request) {
return requestValidator.validate(request).flatMap(this::create);
}
//...
}
Załóżmy jeszcze, że User jest value objectem więc mapujemy tak naprawdę na coś takiego:
public class User {
private final Nick nick;
private final Email email;
private final Password password;
public User(String nick, String email, String password) {
this.nick = new Nick(nick);
this.email = new Email(email);
this.password = new Password(password);
}
}
class Nick {
private final String nick;
public Nick(String nick) {
if (!isValid(nick) {
throw new IllegalArgumentException("Nick is invalid");
}
this.nick = nick;
}
}
class Email {
private final String email;
public Email(String email) {
if (!isValid(email) {
throw new IllegalArgumentException("Email is invalid");
}
this.email = email;
}
}
class Password {
private final String password;
public Password(String password) {
if (!isValid(password) {
throw new IllegalArgumentException("Password is invalid");
}
this.password = password;
}
}
Czyli mamy jakiś obiekt domenowy User, który też ma jakąś walidację
powtórzoną (bo wcześniej już zwalidowaliśmy te dane używając requestValidatora)
natomiast tutaj już rzucamy wyjątkami żeby było pewne, że obiekty mają spójny
stan i zakładamy, że jeżeli przeszło wcześniejszą walidację tzn., że tutaj już dane muszą
być poprawne a jeżeli nie są to jest to błąd programisty dlatego też rzucamy wyjątkiem (a nie jakiś either).
Pytanie czy ma to sens bo walidacja tych pól jest wykonywana 2x raz
w requestValidator a raz w tych obiektach co nie jest zbyt optymalne?
Kolejna rzecz jaką możemy zrobić to statyczne metody fabrykujące w tych obiektach i Either:
public class User {
public static Either<AppError, User> create(String nick, String email, String password) {
var nickEither = Nick.create(nick);
if (nickEither.isLeft()) {
return Either.left(nickEither.getLeft());
}
var emailEither = Email.create(email);
if (emailEither.isLeft()) {
return Either.left(emailEither.getLeft());
}
var passwordEither = Password.create(password);
if (passwordEither.isLeft()) {
return Either.left(passwordEither.getLeft());
}
return Either.right(
new User(nickEither.get(), emailEither.get(), passwordEither.get())
);
}
private final Nick nick;
private final Email email;
private final Password password;
private User(Nick nick, Email email, Password password) {
this.nick = nick;
this.email = email;
this.password = password;
}
}
class Nick {
private final String nick;
public static Either<AppError, Nick> create(String nick) {
return isValid(nick)
? Either.right(new Nick(nick))
: Either.left(AppError.nickNotValid(nick));
}
private Nick(String nick) {
this.nick = nick;
}
private static boolean isValid(String nick) {
//...
}
}
class Email {
private final String email;
public static Either<String, Email> create(String email) {
return isValid(email)
? Either.right(new Email(email))
: Either.left(AppError.emailNotValid(email));
}
private Email(String email) {
this.email = email;
}
private static boolean isValid(String email) {
//...
}
}
class Password {
private final String password;
public static Either<String, Password> create(String password) {
return isValid(password)
? Either.right(new Password(password))
: Either.left(AppError.passwordNotValid(password));
}
public Password(String password) {
this.password = password;
}
private static boolean isValid(String password) {
//...
}
}
I wtedy factory z walidatorem mogłoby wyglądać tak:
public class UserFactory {
private final UserValidator userValidator;
public Either<AppError, User> createUser(UserRegistrationRequest request) {
return User.create(request.nick, request.email, request.password)
.flatMap(userValidator::validate);
}
//...
}
public class UserValidator {
private final UserDao userDao;
public Either<AppError, User> validate(User user) {
return Either.right(user)
.flatMap(this::validateNickUniqueness)
.flatMap(this::validateEmailUniqueness);
}
//...
}
Jak widać w dalszym ciągu potrzebujemy walidatora
do sprawdzenia unikalności nick/email natomiast sama
walidacja wartości tych pól jest zamknięta w value objectach
i nie powtarza się 2x tak jak wcześniej.
Jestem ciekawy co myślicie o tych podejściach do walidacji
i jakie jest wasze preferowane podejście?
Shalomtl;dr
p_agonjakiś spec od Javy?
- @scibi_92