diff --git a/README.md b/README.md index d69ce64..268c943 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,11 @@ func main() { if err := postcode.Validate("10007"); err != nil { // Treat error. } + + postCodeValidator := NewPostCodeValidator() + if isValid := postCodeValidator.ValidatePostalCode("FR", "1000"); !isValid { + // Do stuff here + } } ``` diff --git a/go.mod b/go.mod index f1b5b97..4da6e24 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,9 @@ module github.com/adrg/postcode go 1.14 + +require ( + github.com/google/go-cmp v0.5.9 // indirect + github.com/pkg/errors v0.9.1 // indirect + gotest.tools v2.2.0+incompatible +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bc7511f --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/postcode_by_country.go b/postcode_by_country.go new file mode 100644 index 0000000..6a841df --- /dev/null +++ b/postcode_by_country.go @@ -0,0 +1,195 @@ +package postcode + +import ( + "errors" + "regexp" + "strings" +) + +type PostCodeValidator struct { + CountryRegexMapping map[string]string +} + +// Regexes taken from https://github.com/melwynfurtado/postcode-validator +func NewPostCodeValidator() *PostCodeValidator { + return &PostCodeValidator{ + CountryRegexMapping: map[string]string{ + "GB": `^(?i)([A-Z]){1}([0-9][0-9]|[0-9]|[A-Z][0-9][A-Z]|[A-Z][0-9][0-9]|[A-Z][0-9]|[0-9][A-Z]){1}([ ])?([0-9][A-z][A-z]){1}$`, + "JE": `^JE\d[\dA-Z]?[ ]?\d[ABD-HJLN-UW-Z]{2}$`, + "GG": `^GY\d[\dA-Z]?[ ]?\d[ABD-HJLN-UW-Z]{2}$`, + "IM": `^IM\d[\dA-Z]?[ ]?\d[ABD-HJLN-UW-Z]{2}$$`, + "US": `^([0-9]{5})(?:-([0-9]{4}))?$`, + "CA": `^([ABCEGHJKLMNPRSTVXY][0-9][ABCEGHJKLMNPRSTVWXYZ])\s*([0-9][ABCEGHJKLMNPRSTVWXYZ][0-9])$`, + "IE": `^([AC-FHKNPRTV-Y][0-9]{2}|D6W)[ -]?[0-9AC-FHKNPRTV-Y]{4}$`, + "DE": `^\d{5}$`, + "JP": `^\d{3}-\d{4}$`, + "FR": `\d{2}[ ]?\d{3}`, + "AU": `^\d{4}$`, + "IT": `^\d{5}$`, + "CH": `^\d{4}$`, + "AT": `^(?!0)\d{4}$`, + "ES": `^(?:0[1-9]|[1-4]\d|5[0-2])\d{3}$`, + "NL": `^\d{4}[ ]?[A-Z]{2}$`, + "BE": `^\d{4}$`, + "DK": `^\d{4}$`, + "SE": `^(SE-)?\d{3}[ ]?\d{2}$`, + "NO": `^\d{4}$`, + "BR": `^\d{5}[\-]?\d{3}$`, + "PT": `^\d{4}([\-]\d{3})?$`, + "FI": `^(FI-|AX-)?\d{5}$`, + "AX": `^22\d{3}$`, + "KR": `^\d{5}$`, + "CN": `^\d{6}$`, + "TW": `^\d{3}(\d{2})?$`, + "SG": `^\d{6}$`, + "DZ": `^\d{5}$`, + "AD": `^AD\d{3}$`, + "AR": `^([A-HJ-NP-Z])?\d{4}([A-Z]{3})?$`, + "AM": `^(37)?\d{4}$`, + "AZ": `^\d{4}$`, + "BH": `^((1[0-2]|[2-9])\d{2})?$`, + "BD": `^\d{4}$`, + "BB": `^(BB\d{5})?$`, + "BY": `^\d{6}$`, + "BM": `^[A-Z]{2}[ ]?[A-Z0-9]{2}$`, + "BA": `^\d{5}$`, + "IO": `^BBND 1ZZ$`, + "BN": `^[A-Z]{2}[ ]?\d{4}$`, + "BG": `^\d{4}$`, + "KH": `^\d{5}$`, + "CV": `^\d{4}$`, + "CL": `^\d{7}$`, + "CR": `^(\d{4,5}|\d{3}-\d{4})$`, + "HR": `^(HR-)?\d{5}$`, + "CY": `^\d{4}$`, + "CZ": `^\d{3}[ ]?\d{2}$`, + "DO": `^\d{5}$`, + "EC": `^([A-Z]\d{4}[A-Z]|(?:[A-Z]{2})?\d{6})?$`, + "EG": `^\d{5}$`, + "EE": `^\d{5}$`, + "FO": `^\d{3}$`, + "GE": `^\d{4}$`, + "GR": `^\d{3}[ ]?\d{2}$`, + "GL": `^39\d{2}$`, + "GT": `^\d{5}$`, + "HT": `^\d{4}$`, + "HN": `^(?:\d{5})?$`, + "HU": `^\d{4}$`, + "IS": `^\d{3}$`, + "IN": `^\d{6}$`, + "ID": `^\d{5}$`, + "IL": `^\d{5,7}$`, + "JO": `^\d{5}$`, + "KZ": `^\d{6}$`, + "KE": `^\d{5}$`, + "KW": `^\d{5}$`, + "LA": `^\d{5}$`, + "LV": `^(LV-)?\d{4}$`, + "LB": `^(\d{4}([ ]?\d{4})?)?$`, + "LI": `^(948[5-9])|(949[0-7])$`, + "LT": `^(LT-)?\d{5}$`, + "LU": `^(L-)?\d{4}$`, + "MK": `^\d{4}$`, + "MY": `^\d{5}$`, + "MV": `^\d{5}$`, + "MT": `^[A-Z]{3}[ ]?\d{2,4}$`, + "MU": `^((\d|[A-Z])\d{4})?$`, + "MX": `^\d{5}$`, + "MD": `^\d{4}$`, + "MC": `^980\d{2}$`, + "MA": `^\d{5}$`, + "NP": `^\d{5}$`, + "NZ": `^\d{4}$`, + "NI": `^((\d{4}-)?\d{3}-\d{3}(-\d{1})?)?$`, + "NG": `^(\d{6})?$`, + "OM": `^(PC )?\d{3}$`, + "PA": `^\d{4}$`, + "PK": `^\d{5}$`, + "PY": `^\d{4}$`, + "PH": `^\d{4}$`, + "PL": `^\d{2}-\d{3}$`, + "PR": `^00[679]\d{2}([ \-]\d{4})?$`, + "RO": `^\d{6}$`, + "RU": `^\d{6}$`, + "SM": `^4789\d$`, + "SA": `^\d{5}$`, + "SN": `^\d{5}$`, + "SK": `^\d{3}[ ]?\d{2}$`, + "SI": `^(SI-)?\d{4}$`, + "ZA": `^\d{4}$`, + "LK": `^\d{5}$`, + "TJ": `^\d{6}$`, + "TH": `^\d{5}$`, + "TN": `^\d{4}$`, + "TR": `^\d{5}$`, + "TM": `^\d{6}$`, + "UA": `^\d{5}$`, + "UY": `^\d{5}$`, + "UZ": `^\d{6}$`, + "VA": `^00120$`, + "VE": `^\d{4}$`, + "ZM": `^\d{5}$`, + "AS": `^96799$`, + "CC": `^6799$`, + "CK": `^\d{4}$`, + "RS": `^\d{5,6}$`, + "ME": `^8\d{4}$`, + "CS": `^\d{5}$`, + "YU": `^\d{5}$`, + "CX": `^6798$`, + "ET": `^\d{4}$`, + "FK": `^FIQQ 1ZZ$`, + "NF": `^2899$`, + "FM": `^(9694[1-4])([ \-]\d{4})?$`, + "GF": `^9[78]3\d{2}$`, + "GN": `^\d{3}$`, + "GP": `^9[78][01]\d{2}$`, + "GS": `^SIQQ 1ZZ$`, + "GU": `^969[123]\d([ \-]\d{4})?$`, + "GW": `^\d{4}$`, + "HM": `^\d{4}$`, + "IQ": `^\d{5}$`, + "KG": `^\d{6}$`, + "LR": `^\d{4}$`, + "LS": `^\d{3}$`, + "MG": `^\d{3}$`, + "MH": `^969[67]\d([ \-]\d{4})?$`, + "MN": `^\d{6}$`, + "MP": `^9695[012]([ \-]\d{4})?$`, + "MQ": `^9[78]2\d{2}$`, + "NC": `^988\d{2}$`, + "NE": `^\d{4}$`, + "VI": `^008(([0-4]\d)|(5[01]))([ \-]\d{4})?$`, + "VN": `^\d{6}$`, + "PF": `^987\d{2}$`, + "PG": `^\d{3}$`, + "PM": `^9[78]5\d{2}$`, + "PN": `^PCRN 1ZZ$`, + "PW": `^96940$`, + "RE": `^9[78]4\d{2}$`, + "SH": `^(ASCN|STHL) 1ZZ$`, + "SJ": `^\d{4}$`, + "SO": `^\d{5}$`, + "SZ": `^[HLMS]\d{3}$`, + "TC": `^TKCA 1ZZ$`, + "WF": `^986\d{2}$`, + "XK": `^\d{5}$`, + "YT": `^976\d{2}$`, + "INTL": `^(?:[A-Z0-9]+([- ]?[A-Z0-9]+)*)?$`, + }, + } +} + +func (v *PostCodeValidator) ValidatePostalCode(countryCode, postalCode string) (bool, error) { + regexForCountry, ok := v.CountryRegexMapping[strings.ToUpper(countryCode)] + if !ok { + return false, errors.New("country code not found") + } + + regex, err := regexp.Compile(regexForCountry) + if err != nil { + return false, errors.New("regex does not compile " + err.Error()) + } + + return regex.MatchString(strings.ToUpper(postalCode)), nil +} diff --git a/postcode_by_country_test.go b/postcode_by_country_test.go new file mode 100644 index 0000000..5fbecde --- /dev/null +++ b/postcode_by_country_test.go @@ -0,0 +1,103 @@ +package postcode + +import ( + "testing" + + "gotest.tools/assert" +) + +func TestValidateByCountry(t *testing.T) { + testCases := []struct { + description string + country string + postCode string + expected bool + errorExpected bool + }{ + { + description: "Paris, France", + country: "FR", + postCode: "75008", + expected: true, + }, + { + description: "Brussels, Belgium", + country: "BE", + postCode: "1000", + expected: true, + }, + { + description: "Utrecht, The Netherlands", + country: "NL", + postCode: "3511 ax", + expected: true, + }, + { + description: "Utrecht, The Netherlands, Alt", + country: "NL", + postCode: "3511AX", + expected: true, + }, + { + description: "Hannover, Germany", + country: "DE", + postCode: "30179", + expected: true, + }, + { + description: "Vilnius, Lithuania", + country: "LT", + postCode: "LT-00200", + expected: true, + }, + { + description: "Empty postal code", + country: "FR", + postCode: "", + expected: false, + }, + { + description: "Short postal code", + country: "FR", + postCode: "A", + expected: false, + }, + { + description: "Non-existant country code", + country: "TY", + postCode: "TY 1234", + expected: false, + errorExpected: true, + }, + { + description: "Non-existant postal code format", + country: "US", + postCode: "11111111111", + expected: false, + }, + { + description: "Postal code for wrong country", + country: "FR", + postCode: "1111", + expected: false, + }, + { + description: "UK Postcode", + country: "GB", + postCode: "KT111AT", + expected: true, + }, + } + + postCodeValidator := NewPostCodeValidator() + + for _, testCase := range testCases { + t.Run(testCase.description, func(tt *testing.T) { + isValid, err := postCodeValidator.ValidatePostalCode(testCase.country, testCase.postCode) + if !testCase.errorExpected { + assert.NilError(tt, err) + } + assert.Equal(tt, isValid, testCase.expected) + }) + } +} diff --git a/postcode_test.go b/postcode_test.go index ff3c164..8a8b441 100644 --- a/postcode_test.go +++ b/postcode_test.go @@ -2,6 +2,8 @@ package postcode import ( "testing" + + "gotest.tools/assert" ) func TestValidate(t *testing.T) { @@ -51,21 +53,25 @@ func TestValidate(t *testing.T) { expected: ErrShort, }, { - description: "Inexistent country code", + description: "Non-existant country code", input: "TY 1234", expected: ErrInvalidCountry, }, { - description: "Inexistent postal code format", + description: "Non-existant postal code format", input: "11111111111", expected: ErrInvalidFormat, }, + { + description: "Valid UK postcode", + input: "KT11 1AT", + expected: nil, + }, } for _, testCase := range testCases { - t.Logf("Validating `%s` (%s)", testCase.input, testCase.description) - if err := Validate(testCase.input); err != testCase.expected { - t.Errorf("expected: %v; got: %v", testCase.expected, err) - } + t.Run(testCase.description, func(tt *testing.T) { + assert.Equal(tt, Validate(testCase.input), testCase.expected) + }) } }