From 35146c29e944a16aa01983dbc6e111d8b0310b20 Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Tue, 14 Feb 2023 13:43:34 +0100 Subject: [PATCH] custom constraint --- .../main/java/example/micronaut/Contact.java | 25 ++ .../java/example/micronaut/CountryCode.java | 357 ++++++++++++++++++ .../micronaut/CustomValidationFactory.java | 15 + .../micronaut/CustomValidationMessages.java | 24 ++ .../src/main/java/example/micronaut/E164.java | 56 +++ .../java/example/micronaut/E164Utils.java | 38 ++ .../java/example/micronaut/ContactTest.java | 44 +++ .../example/micronaut/CountryCodeTest.java | 74 ++++ .../java/example/micronaut/E164UtilsTest.java | 35 ++ .../metadata.json | 15 + ...icronaut-custom-validation-annotation.adoc | 81 ++++ 11 files changed, 764 insertions(+) create mode 100644 guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/Contact.java create mode 100644 guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/CountryCode.java create mode 100644 guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/CustomValidationFactory.java create mode 100644 guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/CustomValidationMessages.java create mode 100644 guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/E164.java create mode 100644 guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/E164Utils.java create mode 100644 guides/micronaut-custom-validation-annotation/java/src/test/java/example/micronaut/ContactTest.java create mode 100644 guides/micronaut-custom-validation-annotation/java/src/test/java/example/micronaut/CountryCodeTest.java create mode 100644 guides/micronaut-custom-validation-annotation/java/src/test/java/example/micronaut/E164UtilsTest.java create mode 100644 guides/micronaut-custom-validation-annotation/metadata.json create mode 100644 guides/micronaut-custom-validation-annotation/micronaut-custom-validation-annotation.adoc diff --git a/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/Contact.java b/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/Contact.java new file mode 100644 index 0000000000..11e72ef0a9 --- /dev/null +++ b/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/Contact.java @@ -0,0 +1,25 @@ +package example.micronaut; + +import io.micronaut.core.annotation.Introspected; +import io.micronaut.core.annotation.NonNull; + +import javax.validation.constraints.NotBlank; + +@Introspected +public class Contact { + + @E164 + @NotBlank + @NonNull + private final String phone; + + public Contact(@NonNull String phone) { + this.phone = phone; + } + + + @NonNull + public String getPhone() { + return phone; + } +} diff --git a/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/CountryCode.java b/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/CountryCode.java new file mode 100644 index 0000000000..cd7632989c --- /dev/null +++ b/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/CountryCode.java @@ -0,0 +1,357 @@ +package example.micronaut; + +import io.micronaut.core.annotation.Nullable; +import jakarta.annotation.Nonnull; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Every country code in the world. + * @see LIST OF ITU-T RECOMMENDATION E.164 ASSIGNED COUNTRY CODES + */ +public enum CountryCode { + AFGHANISTAN("93", "Afghanistan"), + ALBANIA("355", "Albania (Republic of)"), + ALGERIA("213", "Algeria (People's Democratic Republic of)"), + AMERICAN_SAMOA("1", "American Samoa"), + ANDORRA("376", "Andorra (Principality of)"), + ANGOLA("244", "Angola (Republic of)"), + ANGUILLA("1", "Anguilla"), + ANTIGUA_AND_BARBUDA("1", "Antigua and Barbuda"), + ARGENTINA("54", "Argentine Republic"), + ARMENIA("374", "Armenia (Republic of)"), + ARUBA("297", "Aruba"), + AUSTRALIA("61", "Australia"), + AUSTRALIAN_EXTERNAL_TERRITORIES("672", "Australian External Territories"), + AUSTRIA("43", "Austria"), + AZERBAIJAN("994", "Azerbaijan (Republic of)"), + BAHAMAS("1", "Bahamas (Commonwealth of the)"), + BAHRAIN("973", "Bahrain (Kingdom of)"), + BANGLADESH("880", "Bangladesh (People's Republic of)"), + BARBADOS("1", "Barbados"), + BELARUS("375", "Belarus (Republic of)"), + BELGIUM("32", "Belgium"), + BELIZE("501", "Belize"), + BENIN("229", "Benin (Republic of)"), + BERMUDA("1", "Bermuda"), + BHUTAN("975", "Bhutan (Kingdom of)"), + BOLIVIA("591", "Bolivia (Plurinational State of)"), + BONAIRE_SINT_EUSTATIUS_AND_SABA("599", "Bonaire, Sint Eustatius and Saba"), + BOSNIA_AND_HERZEGOVINA("387", "Bosnia and Herzegovina"), + BOTSWANA("267", "Botswana (Republic of)"), + BRAZIL("55", "Brazil (Federative Republic of)"), + BRITISH_VIRGIN_ISLANDS("1", "British Version Islands"), + BRUNEI("673", "Brunei Darussalam"), + BULGARIA("359", "Bulgaria (Republic of)"), + BURKINA_FASO("226", "Burkina Faso"), + BURUNDI("257", "Burundi (Republic of)"), + CABO_VERDE("238", "Cabo Verde (Republic of)"), + CAMBODIA("855", "Cambodia (Kingdom of"), + CAMEROON("237", "Cameroon (Republic of)"), + CANADA("1", "Canada"), + CAYMAN_ISLANDS("1", "Cayman Islands"), + CENTRAL_AFRICAN_REPUBLIC("236", "Central African Republic"), + CHAD("235", "Chad (Republic of)"), + CHILE("56", "Chile"), + CHINA("86", "China (People's Republic of)"), + COLOMBIA("57", "Colombia (Republic of)"), + COMOROS("269", "Comoros (Union of the)"), + CONGO("242", "Congo (Republic of the)"), + COOK_ISLANDS("682", "Cook Islands"), + COSTA_RICA("506", "Costa Rica"), + CROATIA("385", "Croatia (Republic of)"), + CUBA("53", "Cuba"), + CURACAO("599", "Curacao"), + CYPRUS("357", "Cyprus (Republic of)"), + CZECH_REPUBLIC("420", "Czech Republic"), + DEMOCRATIC_REPUBLIC_OF_THE_CONGO("243", "Democratic Republic of the Congo"), + DENMARK("45", "Denmark"), + DISASTER_RELIEF("888", "Telecommunications for Disaster Relief (TDR)"), + DJIBOUTI("253", "Djibouti (Republic of)"), + DOMINICA("1", "Dominica (Commonwealth of)"), + DOMINICAN_REPUBLIC("1", "Dominican Republic"), + EAST_TIMOR("670", "Timor-Leste (Democratic Republic of)"), + ECUADOR("593", "Ecuador"), + EGYPT("20", "Egypt (Arab Republic of)"), + EL_SALVADOR("503", "El Salvador (Republic of)"), + EQUATORIAL_GUINEA("240", "Equatorial Guinea (Republic of)"), + ERITREA("291", "Eritrea"), + ESTONIA("372", "Estonia (Republic of)"), + ETHIOPIA("251", "Ethiopia (Federal Democratic Republic of)"), + FALKLAND_ISLANDS("500", "Falkland Islands (Malvinas)"), + FAROE_ISLANDS("298", "Faroe Islands"), + FIJI("679", "Fiji (Republic of"), + FINLAND("358", "Finland"), + FRANCE("33", "France"), + FRENCH_GUIANA("590", "French Guiana (French Department of)"), + FRENCH_POLYNESIA("689", "French Polynesia"), + GABON("241", "Gabonese Republic"), + GAMBIA("220", "Gambia (Republic of)"), + GEORGIA("995", "Georgia"), + GERMANY("49", "Germany (Federal Republic of)"), + GHANA("233", "Ghana"), + GIBRALTAR("350", "Gibraltar"), + GMSS("881", "Global Missile Satellite System (GMSS), shared code"), + GREECE("30", "Greece"), + GREENLAND("299", "Greenland (Denmark)"), + GRENADA("1", "Grenada"), + GROUP_SHARED("388", "Group of countries, shared code"), + GUADELOUPE("590", "Guadeloupe (French Department of"), + GUAM("1", "Guam"), + GUATEMALA("502", "Guatemala (Republic of)"), + GUINEA("224", "Guinea (Republic of)"), + GUINEA_BISSAU("245", "Guinnea-Bassau (Republic of)"), + GUYANA("592", "Guyana"), + HAITI("509", "Haiti (Republic of)"), + HONDURAS("504", "Honduras (Republic of)"), + HONG_KONG("852", "Hong Kong, China"), + HUNGARY("36", "Hungary (Republic of)"), + ICELAND("354", "Iceland"), + INDIA("91", "India (Republic of)"), + INDONESIA("62", "Indonesia"), + INMARSAT("870", "Inmarsat SNAC"), + INTERNATIONAL_FREEPHONE("800", "International Freephone Service"), + INTERNATIONAL_NETWORKS("882", "International Networks, shared code"), //There is a second entry for 883 with the same name. + INTERNATIONAL_PREMIUM("979", "International Premium Rate Service (IPRS)"), + INTERNATIONAL_SHARED("808", "International Shared Cost Service (ISCS)"), + INTERNATIONAL_TRIAL("991", "Trial of a proposed new international telecommunication public correspondence service, shared code"), + IRAN("98", "Iran (Islamic Republic of)"), + IRAQ("964", "Iraq (Republic of)"), + IRELAND("353", "Ireland"), + ISRAEL("972", "Israel (State of)"), + ITALY("39", "Italy"), + IVORY_COAST("225", "Cote d'Ivoire (Republic of)"), + JAMAICA("1", "Jamaica"), + JAPAN("81", "Japan"), + JORDAN("962", "Jordan (Hashemite Kingdom of)"), + KAZAKHSTAN("7", "Kazakhstan (Republic of)"), + KENYA("254", "Kenya (Republic of)"), + KIRIBATI("686", "Kiribati (Republic of)"), + KOSOVO("383", "Kosovo"), + KUWAIT("965", "Kuwait (State of)"), + KYRGYZSTAN("996", "Kyrgyz Republic"), + LAOS("856", "Lao People's Democratic Republic"), + LATVIA("371", "Latvia (Republic of)"), + LEBANON("961", "Lebanon"), + LESOTHO("266", "Lesotho (Kingdom of)"), + LIBERIA("231", "Liberia (Republic of)"), + LIBYA("218", "Libya"), + LIECHTENSTEIN("423", "Liechtenstein (Principality of)"), + LITHUANIA("370", "Lithuania (Republic of)"), + LUXEMBOURG("352", "Luxembourg"), + MACAO("853", "Macao, China"), + MACEDONIA("389", "The Former Yugoslav Republic of Macedonia"), + MADAGASCAR("261", "Madagascar (Republic of)"), + MALAWI("265", "Malawi"), + MALAYSIA("60", "Malaysia"), + MALDIVES("960", "Maldives (Republic of)"), + MALI("223", "Mali (Republic of)"), + MALTA("356", "Malta"), + MARSHALL_ISLANDS("692", "Marshall Islands (Republic of)"), + MARTINIQUE("596", "Martinique (French Department of"), + MAURITANIA("222", "Mauritania (Islamic Republic of)"), + MAURITIUS("230", "Mauritius (Republic of)"), + MEXICO("52", "Mexico"), + MICRONESIA("691", "Micronesia (Federated States of)"), + MOLDOVA("373", "Moldova (Republic of)"), + MONACO("377", "Monaco (Principality of)"), + MONGOLIA("976", "Mongolia"), + MONTENEGRO("382", "Montenegro (Republic of)"), + MONTSERRAT("1", "Montserrat"), + MOROCCO("212", "Morocco (Kingdom of)"), + MOZAMBIQUE("258", "Mozambique (Republic of)"), + MYANMAR("95", "Myanmar (The Republic of the Union of)"), + NAMIBIA("264", "Namibia (Republic of)"), + NAURU("674", "Nauru (Republic of)"), + NEPAL("977", "Nepal (Federal Democratic Republic of)"), + NETHERLANDS("31", "Netherlands (Kingdom of the)"), + NEW_CALEDONIA("687", "New Caledonia (Territoire francais d'outre-mer)"), + NEW_ZEALAND("64", "New Zealand"), + NICARAGUA("505", "Nicaragua"), + NIGER("227", "Niger (Republic of)"), + NIGERIA("234", "Nigeria (Federal Republic of)"), + NIUE("683", "Niue"), + NORTH_KOREA("850", "Democratic People's Republic of Korea\n"), + NORTHERN_MARIANA_ISLANDS("1", "Northern Mariana Islands (Commonwealth of the)"), + NORWAY("47", "Norway"), + OMAN("968", "Oman (Sultanate of)"), + PAKISTAN("92", "Pakistan (Islamic Republic of)"), + PALAU("680", "Palau (Republic of)"), + PANAMA("507", "Panama (Republic of)"), + PAPUA_NEW_GUINEA("675", "Papua New Guinea"), + PARAGUAY("595", "PARAGUAY (Republic of)"), + PERU("51", "Peru"), + PHILIPPINES("63", "Philippines (Republic of the)"), + POLAND("48", "Poland (Republic of)"), + PORTUGAL("351", "Portugal"), + PUERTO_RICO("1", "Puerto Rico"), + QATAR("974", "Qatar (State of)"), + REUNION("262", "French Departments and Territories in the Indian Ocean"), + ROMANIA("40", "Romania"), + RUSSIA("7", "Russian Federation"), + RWANDA("250", "Rwanda (Republic of)"), + SAINT_HELENA("290", "Saint Helena, Ascension and the Tristan da Cunha"), //Also, 247 has an identical entry + SAINT_KITTS_AND_NEVIS("1", "Saint Kitts and Nevis"), + SAINT_LUCIA("1", "Saint Lucia"), + SAINT_PIERRE_AND_MIQUELON("508", "Saint Pierre and Miquelon (Collectivite territoriale de la Republique francaise)"), + SAINT_VINCENT_AND_THE_GRENADINES("1", "Saint Vincent and the Grenadines"), + SAMOA("685", "Samoa (Independent State of"), + SAN_MARINO("378", "San Marino (Republic of)"), + SAO_TOME_AND_PRINCIPE("239", "Sao Tome and Principe (Democratic Republic of)"), + SAUDI_ARABIA("966", "Saudi Arabia (Kingdom of)"), + SENEGAL("221", "Senegal (Republic of)"), + SERBIA("381", "Serbia (Republic of)"), + SEYCHELLES("248", "Seychelles (Republic of)"), + SIERRA_LEONE("232", "Sierra Leone"), + SINGAPORE("65", "Singapore (Republic of)"), + SINT_MAARTEN("1", "Sint Maarten (Dutch part)"), + SLOVAKIA("421", "Slovak Republic"), + SLOVENIA("386", "Slovenia (Republic of)"), + SOLOMON_ISLANDS("677", "Solomon Islands"), + SOMALIA("252", "Somalia (Federal Republic of)"), + SOUTH_AFRICA("27", "South Africa (Republic of)"), + SOUTH_KOREA("82", "Korea (Republic of)"), + SOUTH_SUDAN("211", "South Sudan (Republic of)"), + SPAIN("34", "Spain"), + SRI_LANKA("94", "Sri Lanka (Democratic Socialist Republic of)"), + SUDAN("249", "Sudan (Republic of)"), + SURINAME("597", "Suriname (Republic of"), + SWAZILAND("268", "Swaziland (Kingdom of)"), + SWEDEN("46", "Sweden"), + SWITZERLAND("41", "Switzerland (Confederation of)"), + SYRIA("963", "Syrian Arab Republic"), + TAIWAN("886", "Taiwan, China"), //Republic of China? + TAJIKISTAN("992", "Tajikstan (Republic of)"), + TANZANIA("255", "Tanzania (United Republic of)"), + THAILAND("66", "Thailand"), + TOGO("228", "Togolese Republic"), + TOKELAU("690", "Tokelau"), + TONGA("676", "Tonga (Kingdom of)"), + TRINIDAD_AND_TOBAGO("1", "Trinidad and Tobago"), + TUNISIA("216", "Tunisia"), + TURKEY("90", "Turkey"), + TURKMENISTAN("993", "Turkemenistan"), + TURKS_AND_CAICOS_ISLANDS("1", "Turks and Caicos Islands"), + TUVALU("688", "Tuvalu"), + UGANDA("256", "Uganda (Republic of)"), + UKRAINE("380", "Ukraine"), + UNITED_ARAB_EMIRATES("971", "United Arab Emirates"), + UNITED_KINGDOM("44", "United Kingdom of Great Britain and Northern Ireland"), + UNITED_STATES("1", "United States of America"), + UPT("878", "Universal Personal Telecommunication Service (UPT)"), + URUGUAY("598", "Uruguay (Eastern Republic of)"), + UZBEKISTAN("998", "Uzbekistan (Republic of)"), + VANUATU("678", "Vanuatu (Republic of)"), + VATICAN("379", "Vatican City State"), + VENEZUELA("58", "Venezuala (Bolivarian Republic of)"), + VIETNAM("84", "Viet nam (Socialist Republic of)"), + WALLIS_AND_FUTUNA("681", "Wallis and Futuna (Territoire francais d'outre-mer)"), + YEMEN("967", "Yemen (Republic of)"), + ZAMBIA("260", "Zambia (Republic of)"), + ZIMBABWE("263", "Zimbabwe (Republic of)"); + + private String code; + private String countryName; + + /** + * Constructor for countries whose name matches their enum. + * @param code country code + */ + CountryCode(String code) { + this.code = code; + } + + /** + * Constructor for countries whose name does not match the enum. + * @param code country code + * @param countryName full country name + */ + CountryCode(String code, String countryName) { + this.code = code; + this.countryName = countryName; + } + + public String getCode() { + return this.code; + } + + /** + * Country name. + * @return country name + */ + public String getCountryName() { + return this.countryName; + } + + private static final List CODES = new ArrayList<>(); + private static final String PLUS_SIGN = "+"; + + private static final Map> COUNTRYCODESBYCODE = new HashMap<>(); + + static { + Set uniquecodes = new HashSet<>(); + for (CountryCode value : EnumSet.allOf(CountryCode.class)) { + List countryCodes = COUNTRYCODESBYCODE.containsKey(value.toString()) ? + COUNTRYCODESBYCODE.get(value.toString()) : + new ArrayList<>(); + countryCodes.add(value); + COUNTRYCODESBYCODE.put(value.toString(), countryCodes); + uniquecodes.add(value.toString()); + } + CODES.addAll(uniquecodes); + CODES.sort((Comparator) (s1, s2) -> Integer.compare(s2.length(), s1.length())); + + } + + /** + * + * @param code Country code + * @return a List of {@link CountryCode} for a found code or an empty list + */ + @NotNull + @Nonnull + public static List countryCodesByCode(@Nonnull @NotBlank String code) { + if (COUNTRYCODESBYCODE.containsKey(code)) { + return COUNTRYCODESBYCODE.get(code); + } + return new ArrayList<>(); + } + + /** + * + * @return Country codes ordered from codes of longer length to less length. + */ + public static List getCodes() { + return CODES; + } + + /** + * + * @param number Phone number + * @return the Country code found in the phone number or {@code null} if not found. + */ + @Nullable + public static String parseCountryCode(@Nonnull @NotBlank String number) { + String phone = number.startsWith(PLUS_SIGN) ? number.substring(1) : number; + for (String code : getCodes()) { + if (phone.startsWith(code)) { + return code; + } + } + return null; + } + + @Override + public String toString() { + return this.code; + } +} diff --git a/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/CustomValidationFactory.java b/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/CustomValidationFactory.java new file mode 100644 index 0000000000..6840d7eb94 --- /dev/null +++ b/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/CustomValidationFactory.java @@ -0,0 +1,15 @@ +package example.micronaut; +import io.micronaut.context.annotation.Factory; +import io.micronaut.validation.validator.constraints.ConstraintValidator; +import jakarta.inject.Singleton; + +@Factory // <1> +class CustomValidationFactory { + /** + * @return A {@link ConstraintValidator} implementation of a {@link E164} constraint for type {@link String}. + */ + @Singleton // <2> + ConstraintValidator e164Validator() { + return (value, annotationMetadata, context) -> E164Utils.isValid(value); + } +} \ No newline at end of file diff --git a/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/CustomValidationMessages.java b/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/CustomValidationMessages.java new file mode 100644 index 0000000000..52fd498ae3 --- /dev/null +++ b/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/CustomValidationMessages.java @@ -0,0 +1,24 @@ +package example.micronaut; + +import io.micronaut.context.StaticMessageSource; +import jakarta.inject.Singleton; + +/** + * Adds validation messages. + */ +@Singleton +public class CustomValidationMessages extends StaticMessageSource { + public static final String E164_MESSAGE = "must be a phone in E.164 format"; + /** + * The message suffix to use. + */ + private static final String MESSAGE_SUFFIX = ".message"; + + /** + * Default constructor to initialize messages. + * via {@link #addMessage(String, String)} + */ + public CustomValidationMessages() { + addMessage(E164.class.getName() + MESSAGE_SUFFIX, E164_MESSAGE); + } +} diff --git a/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/E164.java b/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/E164.java new file mode 100644 index 0000000000..6c82e4afb2 --- /dev/null +++ b/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/E164.java @@ -0,0 +1,56 @@ +package example.micronaut; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * The annotated element must be a E.164 phone number. + * + * @see ITU E.164 recommendation + * @see E.614 + */ +@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.PARAMETER, ElementType.TYPE_USE}) +@Retention(RetentionPolicy.RUNTIME) +@Repeatable(E164.List.class) +@Documented +@Constraint(validatedBy = {}) +public @interface E164 { + + String MESSAGE = "example.micronaut.E164.message"; + + /** + * @return message The error message + */ + String message() default "{" + MESSAGE + "}"; + + /** + * @return Groups to control the order in which constraints are evaluated, + * or to perform validation of the partial state of a JavaBean. + */ + Class[] groups() default {}; + + /** + * @return Payloads used by validation clients to associate some metadata information with a given constraint declaration + */ + Class[] payload() default {}; + + /** + * List annotation. + */ + @Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.PARAMETER, ElementType.TYPE_USE}) + @Retention(RetentionPolicy.RUNTIME) + @Documented + @interface List { + + /** + * @return An array of E164. + */ + E164[] value(); + } +} diff --git a/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/E164Utils.java b/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/E164Utils.java new file mode 100644 index 0000000000..07f8fea41d --- /dev/null +++ b/guides/micronaut-custom-validation-annotation/java/src/main/java/example/micronaut/E164Utils.java @@ -0,0 +1,38 @@ +package example.micronaut; + +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.util.StringUtils; + +/** + * Utility methods to ease {@link E164} validation. + */ +public final class E164Utils { + private static final int MAX_NUMBER_OF_DIGITS = 15; + private static final String PLUS_SIGN = "+"; + + private E164Utils() { + } + + /** + * @param value phone number + * @return Whether a phone is E.164 formatted + */ + public static boolean isValid(@Nullable String value) { + if (value == null || value.isEmpty()) { + return false; + } + + String phone = value.startsWith(PLUS_SIGN) ? value.substring(1) : value; + if (phone.length() > MAX_NUMBER_OF_DIGITS) { + return false; + } + if (phone.isEmpty()) { + return false; + } + if (!StringUtils.isDigits(phone) || phone.charAt(0) == '0') { + return false; + } + + return CountryCode.parseCountryCode(phone) != null; + } +} diff --git a/guides/micronaut-custom-validation-annotation/java/src/test/java/example/micronaut/ContactTest.java b/guides/micronaut-custom-validation-annotation/java/src/test/java/example/micronaut/ContactTest.java new file mode 100644 index 0000000000..368d19dfd6 --- /dev/null +++ b/guides/micronaut-custom-validation-annotation/java/src/test/java/example/micronaut/ContactTest.java @@ -0,0 +1,44 @@ +package example.micronaut; + +import io.micronaut.context.MessageSource; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import javax.validation.ConstraintViolation; +import javax.validation.Validator; + +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@MicronautTest(startApplication = false) // <3> +class ContactTest { + @Inject // <2> + MessageSource messageSource; + + @Inject // <3> + Validator validator; + + @Test + void contactValidation() { + assertTrue(validator.validate(new Contact("+14155552671")).isEmpty()); + Set> violationSet = validator.validate(new Contact("+1-4155552671")); + assertFalse(violationSet.isEmpty()); + String template = "{example.micronaut.E164.message}"; + assertTrue(violationSet.stream().anyMatch(violation -> violation.getMessageTemplate().equals(template))); + assertTrue(violationSet.stream().anyMatch(violation -> violation.getInvalidValue().equals("+1-4155552671"))); + + violationSet.stream().filter(violation -> violation.getMessage().equals(template)) + .findFirst() + .ifPresent(violation -> { + Optional message = messageSource.getMessage(violation.getMessage(), Locale.ENGLISH); + assertTrue(message.isPresent()); + assertEquals("must be a phone in E.164 format", message.get()); + }); + } +} diff --git a/guides/micronaut-custom-validation-annotation/java/src/test/java/example/micronaut/CountryCodeTest.java b/guides/micronaut-custom-validation-annotation/java/src/test/java/example/micronaut/CountryCodeTest.java new file mode 100644 index 0000000000..7ec0bb8766 --- /dev/null +++ b/guides/micronaut-custom-validation-annotation/java/src/test/java/example/micronaut/CountryCodeTest.java @@ -0,0 +1,74 @@ +package example.micronaut; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CountryCodeTest { + @Test + void preferredNameGetsUsed() { + String name = CountryCode.YEMEN.getCountryName(); + assertEquals(name, "Yemen (Republic of)"); + } + + @Test + void defaultNameIsCapitalizedCorrectly() { + String name = CountryCode.SPAIN.getCountryName(); + assertEquals(name, "Spain"); + } + + @Test + void toStringReturnsCorrectValue() { + String code = CountryCode.AMERICAN_SAMOA.toString(); + assertEquals(code, "1"); + } + + @Test + void countryCodeGetCodesReturnEveryCodeWithLongestCodesFirst() { + assertTrue(CountryCode.getCodes().get(0).length() > 1); + } + + @Test + void countryCodeParseCountryCodeParseCodes() { + + assertNull(CountryCode.parseCountryCode("999999")); + + assertEquals("34",CountryCode.parseCountryCode("34630443322")); + assertEquals("268",CountryCode.parseCountryCode("2684046441")); + assertEquals("1",CountryCode.parseCountryCode("+14155552671")); + } + + @Test + void countryCodeCountryCodesByCodeReturnAListOfCountryCodeWithTheSameCountryCode() { + assertTrue(CountryCode.countryCodesByCode("999999").isEmpty()); + assertEquals(CountryCode.countryCodesByCode("1"), Arrays.asList( + CountryCode.AMERICAN_SAMOA, + CountryCode.ANGUILLA, + CountryCode.ANTIGUA_AND_BARBUDA, + CountryCode.BAHAMAS, + CountryCode.BARBADOS, + CountryCode.BERMUDA, + CountryCode.BRITISH_VIRGIN_ISLANDS, + CountryCode.CANADA, + CountryCode.CAYMAN_ISLANDS, + CountryCode.DOMINICA, + CountryCode.DOMINICAN_REPUBLIC, + CountryCode.GRENADA, + CountryCode.GUAM, + CountryCode.JAMAICA, + CountryCode.MONTSERRAT, + CountryCode.NORTHERN_MARIANA_ISLANDS, + CountryCode.PUERTO_RICO, + CountryCode.SAINT_KITTS_AND_NEVIS, + CountryCode.SAINT_LUCIA, + CountryCode.SAINT_VINCENT_AND_THE_GRENADINES, + CountryCode.SINT_MAARTEN, + CountryCode.TRINIDAD_AND_TOBAGO, + CountryCode.TURKS_AND_CAICOS_ISLANDS, + CountryCode.UNITED_STATES)); + } +} diff --git a/guides/micronaut-custom-validation-annotation/java/src/test/java/example/micronaut/E164UtilsTest.java b/guides/micronaut-custom-validation-annotation/java/src/test/java/example/micronaut/E164UtilsTest.java new file mode 100644 index 0000000000..fb70af79e7 --- /dev/null +++ b/guides/micronaut-custom-validation-annotation/java/src/test/java/example/micronaut/E164UtilsTest.java @@ -0,0 +1,35 @@ +package example.micronaut; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class E164UtilsTest { + + @ParameterizedTest + @ValueSource(strings = { + "+04630443322", + "+1415555267102345", + "+1-4155552671", + "" + }) + void invalidPhones(String phone) { + assertFalse(E164Utils.isValid(phone)); + } + + @ParameterizedTest + @ValueSource(strings = { + "+14155552671", + "+442071838750", + "+55115525632", + "14155552671", + "442071838750", + "55115525632", + "55115525632", + }) + void validPhones(String phone) { + assertTrue(E164Utils.isValid(phone)); + } +} diff --git a/guides/micronaut-custom-validation-annotation/metadata.json b/guides/micronaut-custom-validation-annotation/metadata.json new file mode 100644 index 0000000000..1069532062 --- /dev/null +++ b/guides/micronaut-custom-validation-annotation/metadata.json @@ -0,0 +1,15 @@ +{ + "title": "Custom constraint annotation for validation", + "intro": "How to create a custom constraint annotation for validation in your Micronaut application", + "authors": ["Sergio del Amo"], + "tags": ["validation"], + "categories": ["Beyond the Basics"], + "publicationDate": "2023-02-13", + "apps": [ + { + "name": "default", + "features": ["junit-params"] + } + ], + "languages": ["java"] +} diff --git a/guides/micronaut-custom-validation-annotation/micronaut-custom-validation-annotation.adoc b/guides/micronaut-custom-validation-annotation/micronaut-custom-validation-annotation.adoc new file mode 100644 index 0000000000..7d5b267a38 --- /dev/null +++ b/guides/micronaut-custom-validation-annotation/micronaut-custom-validation-annotation.adoc @@ -0,0 +1,81 @@ +common:header.adoc[] + +common:requirements.adoc[] + +common:completesolution.adoc[] + +common:create-app-features.adoc[] + +== Writing the application + +In this guide, you will code an annotation - `@E164` - to validate phone numbers. + +https://www.twilio.com/docs/glossary/what-e164[E-164] + +____ +E.164 is the international telephone numbering plan that ensures each device on the PSTN has globally unique number. + +This number allows phone calls and text messages can be correctly routed to individual phones in different countries. E.164 numbers are formatted [+] [country code] [subscriber number including area code] and can have a maximum of fifteen digits. +____ + +=== Country Code + +Create an enum for the country code. The following enum maps the https://www.itu.int/dms_pub/itu-t/opb/sp/T-SP-E.164D-11-2011-PDF-E.pdf[ ITU_T recommendation for E.164 assigned country codes]. + +source:CountryCode[] + +And a test for this enum: + +test:CountryCodeTest[] + +== Phone E164 + +Create a `Utils` class to validate a phone number. + +source:E164Utils[] + +and a test for valid and invalid phones: + +test:E164UtilsTest[] + +== Custom Validation Annotation + +Create a custom annotation: + +source:E164[] + +== Validation Factory + +Create a factory that creates a `ConstraintValidator` for the annotation defined in the previous step. + +source:CustomValidationFactory[] + +callout:factory[1] +callout:singleton[2] + +== Validation Messages + +Create a default message for the E164 constraint: + +source:CustomValidationMessages[] + +== Testing Validation + +Create a `Contact` object which uses the custom annotation. + +source:Contact[] + +Create a test verifies the custom annotation participates in the validation of the object. + +test:ContactTest[] +callout:micronaut-test-start-application-false[1] +callout:injection[number=2,arg0=MessageSource] +callout:injection[number=3,arg0=Validator] + +common:next.adoc[] + +`ContactTest` uses `MessageSource`. Learn about the https://guides.micronaut.io/latest/micronaut-patterns-composite.html[composite pattern] which `MessageSource` uses. + +Learn about https://docs.micronaut.io/latest/guide/#beanValidation[Bean Validation] and how to https://guides.micronaut.io/latest/localized-message-source.html[localize your application]. + +common:helpWithMicronaut.adoc[]