-
-
Notifications
You must be signed in to change notification settings - Fork 595
HV-2246 Adding @IBAN Constraint #2096
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| /* | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| * Copyright Red Hat Inc. and Hibernate Authors | ||
| */ | ||
| package org.hibernate.validator.cfg.defs; | ||
|
|
||
| import org.hibernate.validator.cfg.ConstraintDef; | ||
| import org.hibernate.validator.constraints.IBAN; | ||
|
|
||
| /** | ||
| * An {@link IBAN} constraint definition. | ||
| * | ||
| * @author Andrea Boriero | ||
| * @since 9.2 | ||
| */ | ||
| public class IBANDef extends ConstraintDef<IBANDef, IBAN> { | ||
|
|
||
| public IBANDef() { | ||
| super( IBAN.class ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| /* | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| * Copyright Red Hat Inc. and Hibernate Authors | ||
| */ | ||
| package org.hibernate.validator.constraints; | ||
|
|
||
| import static java.lang.annotation.ElementType.ANNOTATION_TYPE; | ||
| import static java.lang.annotation.ElementType.CONSTRUCTOR; | ||
| 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.ElementType.TYPE_USE; | ||
| import static java.lang.annotation.RetentionPolicy.RUNTIME; | ||
|
|
||
| import java.lang.annotation.Documented; | ||
| import java.lang.annotation.Repeatable; | ||
| import java.lang.annotation.Retention; | ||
| import java.lang.annotation.Target; | ||
|
|
||
| import jakarta.validation.Constraint; | ||
| import jakarta.validation.Payload; | ||
|
|
||
| import org.hibernate.validator.constraints.IBAN.List; | ||
|
|
||
| /** | ||
| * Checks that the annotated character sequence is a valid | ||
| * <a href="https://en.wikipedia.org/wiki/International_Bank_Account_Number">IBAN</a> | ||
| * (International Bank Account Number). | ||
| * <p> | ||
| * The country-specific length and the ISO 7064 MOD 97-10 check digits are both verified. | ||
| * <p> | ||
| * The supported type is {@code CharSequence}. {@code null} is considered valid. | ||
| * <p> | ||
| * During validation spaces are ignored. This is useful when validating IBANs that use spaces | ||
| * to separate groups of characters (ex. {@code GB82 WEST 1234 5698 7654 32}). | ||
| * | ||
| * @author Andrea Boriero | ||
| * @since 9.2 | ||
| */ | ||
| @Documented | ||
| @Constraint(validatedBy = { }) | ||
| @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE }) | ||
| @Retention(RUNTIME) | ||
| @Repeatable(List.class) | ||
| public @interface IBAN { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. and same here about |
||
|
|
||
| String message() default "{org.hibernate.validator.constraints.IBAN.message}"; | ||
|
|
||
| Class<?>[] groups() default { }; | ||
|
|
||
| Class<? extends Payload>[] payload() default { }; | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's also have an attribute to disallow lowercase characters by default, but let the user flip the flag to allow them. |
||
| /** | ||
| * Defines several {@code @IBAN} annotations on the same element. | ||
| */ | ||
| @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE }) | ||
| @Retention(RUNTIME) | ||
| @Documented | ||
| public @interface List { | ||
|
|
||
| IBAN[] value(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| /* | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| * Copyright Red Hat Inc. and Hibernate Authors | ||
| */ | ||
| package org.hibernate.validator.internal.constraintvalidators.hv; | ||
|
|
||
| import java.util.Collections; | ||
| import java.util.HashMap; | ||
| import java.util.Locale; | ||
| import java.util.Map; | ||
| import java.util.regex.Pattern; | ||
|
|
||
| import jakarta.validation.ConstraintValidator; | ||
| import jakarta.validation.ConstraintValidatorContext; | ||
|
|
||
| import org.hibernate.validator.constraints.IBAN; | ||
|
|
||
| /** | ||
| * Checks that a given character sequence (e.g. string) is a valid IBAN (International Bank Account Number). | ||
| * <p> | ||
| * Validation is performed by checking the country-specific length and the ISO 7064 MOD 97-10 check digits. | ||
| * | ||
| * @author Andrea Boriero | ||
| */ | ||
| public class IBANValidator implements ConstraintValidator<IBAN, CharSequence> { | ||
|
|
||
| private static final int MODULUS = 97; | ||
|
|
||
| /** | ||
| * The general IBAN structure: two letters (country code), two check digits and up to 30 alphanumeric | ||
| * characters (BBAN). The country-specific length is verified separately. | ||
| */ | ||
| private static final Pattern IBAN_STRUCTURE = Pattern.compile( "[A-Za-z]{2}[0-9]{2}[A-Za-z0-9]+" ); | ||
|
|
||
| /** | ||
| * The expected total IBAN length per country, as defined by the SWIFT IBAN registry. | ||
| */ | ||
| private static final Map<String, Integer> IBAN_COUNTRY_LENGTHS = buildCountryLengths(); | ||
|
|
||
| @Override | ||
| public boolean isValid(CharSequence value, ConstraintValidatorContext context) { | ||
| if ( value == null ) { | ||
| return true; | ||
| } | ||
|
|
||
| // Spaces are used to group characters when printing an IBAN, they are not part of the actual number. | ||
| final String iban = removeSpaces( value ); | ||
|
|
||
| if ( !IBAN_STRUCTURE.matcher( iban ).matches() ) { | ||
| return false; | ||
| } | ||
|
|
||
| // Reject unknown country codes and any IBAN whose length does not match the fixed length defined for its country. | ||
| final Integer expectedLength = IBAN_COUNTRY_LENGTHS.get( iban.substring( 0, 2 ).toUpperCase( Locale.ROOT ) ); | ||
| if ( expectedLength == null || expectedLength != iban.length() ) { | ||
| return false; | ||
| } | ||
|
|
||
| return hasValidCheckDigits( iban ); | ||
| } | ||
|
|
||
| private static String removeSpaces(CharSequence value) { | ||
| return value.toString().replace( " ", "" ); | ||
| } | ||
|
|
||
| /** | ||
| * Validates the ISO 7064 MOD 97-10 check digits: the first four characters are moved to the end, | ||
| * each letter is replaced by two digits ('A' = 10, ..., 'Z' = 35) and the resulting number must | ||
| * yield a remainder of 1 when divided by 97. The remainder is computed piece by piece to avoid | ||
| * building a potentially very large integer. | ||
| */ | ||
| private static boolean hasValidCheckDigits(String iban) { | ||
| int length = iban.length(); | ||
| int mod = 0; | ||
| for ( int i = 0; i < length; i++ ) { | ||
| // Start with the BBAN (chars after the first four), then wrap around to the country code and check digits. | ||
| char c = iban.charAt( ( i + 4 ) % length ); | ||
| if ( c >= '0' && c <= '9' ) { | ||
| mod = ( mod * 10 + ( c - '0' ) ) % MODULUS; | ||
| } | ||
| else { | ||
| mod = ( mod * 100 + ( Character.toUpperCase( c ) - 'A' + 10 ) ) % MODULUS; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if lowercase would even be valid ? |
||
| } | ||
| } | ||
| return mod == 1; | ||
| } | ||
|
|
||
| private static Map<String, Integer> buildCountryLengths() { | ||
| Map<String, Integer> lengths = new HashMap<>(); | ||
| lengths.put( "AD", 24 ); | ||
| lengths.put( "AE", 23 ); | ||
| lengths.put( "AL", 28 ); | ||
| lengths.put( "AT", 20 ); | ||
| lengths.put( "AZ", 28 ); | ||
| lengths.put( "BA", 20 ); | ||
| lengths.put( "BE", 16 ); | ||
| lengths.put( "BG", 22 ); | ||
| lengths.put( "BH", 22 ); | ||
| lengths.put( "BR", 29 ); | ||
| lengths.put( "BY", 28 ); | ||
| lengths.put( "CH", 21 ); | ||
| lengths.put( "CR", 22 ); | ||
| lengths.put( "CY", 28 ); | ||
| lengths.put( "CZ", 24 ); | ||
| lengths.put( "DE", 22 ); | ||
| lengths.put( "DK", 18 ); | ||
| lengths.put( "DO", 28 ); | ||
| lengths.put( "EE", 20 ); | ||
| lengths.put( "EG", 29 ); | ||
| lengths.put( "ES", 24 ); | ||
| lengths.put( "FI", 18 ); | ||
| lengths.put( "FO", 18 ); | ||
| lengths.put( "FR", 27 ); | ||
| lengths.put( "GB", 22 ); | ||
| lengths.put( "GE", 22 ); | ||
| lengths.put( "GI", 23 ); | ||
| lengths.put( "GL", 18 ); | ||
| lengths.put( "GR", 27 ); | ||
| lengths.put( "GT", 28 ); | ||
| lengths.put( "HR", 21 ); | ||
| lengths.put( "HU", 28 ); | ||
| lengths.put( "IE", 22 ); | ||
| lengths.put( "IL", 23 ); | ||
| lengths.put( "IQ", 23 ); | ||
| lengths.put( "IS", 26 ); | ||
| lengths.put( "IT", 27 ); | ||
| lengths.put( "JO", 30 ); | ||
| lengths.put( "KW", 30 ); | ||
| lengths.put( "KZ", 20 ); | ||
| lengths.put( "LB", 28 ); | ||
| lengths.put( "LC", 32 ); | ||
| lengths.put( "LI", 21 ); | ||
| lengths.put( "LT", 20 ); | ||
| lengths.put( "LU", 20 ); | ||
| lengths.put( "LV", 21 ); | ||
| lengths.put( "LY", 25 ); | ||
| lengths.put( "MC", 27 ); | ||
| lengths.put( "MD", 24 ); | ||
| lengths.put( "ME", 22 ); | ||
| lengths.put( "MK", 19 ); | ||
| lengths.put( "MR", 27 ); | ||
| lengths.put( "MT", 31 ); | ||
| lengths.put( "MU", 30 ); | ||
| lengths.put( "NL", 18 ); | ||
| lengths.put( "NO", 15 ); | ||
| lengths.put( "PK", 24 ); | ||
| lengths.put( "PL", 28 ); | ||
| lengths.put( "PS", 29 ); | ||
| lengths.put( "PT", 25 ); | ||
| lengths.put( "QA", 29 ); | ||
| lengths.put( "RO", 24 ); | ||
| lengths.put( "RS", 22 ); | ||
| lengths.put( "SA", 24 ); | ||
| lengths.put( "SC", 31 ); | ||
| lengths.put( "SD", 18 ); | ||
| lengths.put( "SE", 24 ); | ||
| lengths.put( "SI", 19 ); | ||
| lengths.put( "SK", 24 ); | ||
| lengths.put( "SM", 27 ); | ||
| lengths.put( "ST", 25 ); | ||
| lengths.put( "SV", 28 ); | ||
| lengths.put( "TL", 23 ); | ||
| lengths.put( "TN", 24 ); | ||
| lengths.put( "TR", 26 ); | ||
| lengths.put( "UA", 29 ); | ||
| lengths.put( "VA", 22 ); | ||
| lengths.put( "VG", 24 ); | ||
| lengths.put( "XK", 20 ); | ||
| return Collections.unmodifiableMap( lengths ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| /* | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| * Copyright Red Hat Inc. and Hibernate Authors | ||
| */ | ||
| package org.hibernate.validator.test.constraints.annotations.hv; | ||
|
|
||
| import static org.hibernate.validator.testutil.ConstraintViolationAssert.assertNoViolations; | ||
| import static org.hibernate.validator.testutil.ConstraintViolationAssert.assertThat; | ||
| import static org.hibernate.validator.testutil.ConstraintViolationAssert.violationOf; | ||
|
|
||
| import java.util.Set; | ||
|
|
||
| import jakarta.validation.ConstraintViolation; | ||
|
|
||
| import org.hibernate.validator.constraints.IBAN; | ||
| import org.hibernate.validator.test.constraints.annotations.AbstractConstrainedTest; | ||
|
|
||
| import org.testng.annotations.Test; | ||
|
|
||
| /** | ||
| * Test to make sure that elements annotated with {@link IBAN} are validated. | ||
| * | ||
| * @author Andrea Boriero | ||
| */ | ||
| public class IBANConstrainedTest extends AbstractConstrainedTest { | ||
|
|
||
| @Test | ||
| public void testIBAN() { | ||
| Foo foo = new Foo( "GB82WEST12345698765432" ); | ||
| Set<ConstraintViolation<Foo>> violations = validator.validate( foo ); | ||
| assertNoViolations( violations ); | ||
| } | ||
|
|
||
| @Test | ||
| public void testIBANInvalid() { | ||
| Foo foo = new Foo( "GB94WEST12345698765432" ); | ||
| Set<ConstraintViolation<Foo>> violations = validator.validate( foo ); | ||
| assertThat( violations ).containsOnlyViolations( | ||
| violationOf( IBAN.class ).withMessage( "invalid International Bank Account Number (IBAN)" ) | ||
| ); | ||
| } | ||
|
|
||
| private static class Foo { | ||
|
|
||
| @IBAN | ||
| private final String number; | ||
|
|
||
| public Foo(String number) { | ||
| this.number = number; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We usually add
@Incubatingto new constraints (so that we have more room to adjust one if needed)