-
Notifications
You must be signed in to change notification settings - Fork 23
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
162 additions
and
35 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
// Copyright © 2022 SpotHero, Inc. All rights reserved. | ||
|
||
import Foundation | ||
|
||
/// A validated email address. | ||
public struct Email { | ||
/// A `String` representing the email. | ||
public let string: String | ||
|
||
/// The components of an email address. | ||
public let components: EmailComponents | ||
|
||
/// Construct an `Email` from a string if it's valid. Returns `nil` otherwise. | ||
/// | ||
/// For detailed errors use ``EmailComponents.init(email:)`` | ||
/// - Parameter string: The string containing the email. | ||
public init?(string: String) { | ||
guard let components = try? EmailComponents(email: string) else { | ||
return nil | ||
} | ||
|
||
self.components = components | ||
self.string = components.string | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
// Copyright © 2022 SpotHero, Inc. All rights reserved. | ||
|
||
import Foundation | ||
|
||
/// A structure that parses emails into and constructs emails from their constituent parts. | ||
public struct EmailComponents { | ||
/// The portion of an email before the @ | ||
public let username: String | ||
|
||
/// The hostname of the domain | ||
public let hostname: String | ||
|
||
/// The top level domain. | ||
public let tld: String | ||
|
||
/// An email created from the components | ||
public let string: String | ||
|
||
/// Initializes an email using its constituent parts. | ||
/// - Parameters: | ||
/// - username: The portion of an email before the @ | ||
/// - hostname: The hostname of the domain | ||
/// - tld: The top level domain. | ||
public init(username: String, hostname: String, tld: String) { | ||
self.username = username | ||
self.hostname = hostname | ||
self.tld = tld | ||
self.string = "\(username)@\(hostname)\(tld)" | ||
} | ||
|
||
/// Parses an email and exposes its constituent parts. | ||
public init(email: String) throws { | ||
// Ensure there is exactly one @ symbol. | ||
guard email.filter({ $0 == "@" }).count == 1 else { | ||
throw SpotHeroEmailValidator.Error.invalidSyntax | ||
} | ||
|
||
let emailAddressParts = email.split(separator: "@") | ||
|
||
// Extract the username from the email address parts | ||
let username = String(emailAddressParts.first ?? "") | ||
// Extract the full domain (including TLD) from the email address parts | ||
let fullDomain = String(emailAddressParts.last ?? "") | ||
// Split the domain parts for evaluation | ||
let domainParts = fullDomain.split(separator: ".") | ||
|
||
guard domainParts.count >= 2 else { | ||
// There are no periods found in the domain, throw an error | ||
throw SpotHeroEmailValidator.Error.invalidDomain | ||
} | ||
|
||
// TODO: This logic is wrong and doesn't take subdomains into account. We should compare TLDs against the commonTLDs list." | ||
|
||
// Extract the domain from the domain parts | ||
let domain = domainParts.first?.lowercased() ?? "" | ||
|
||
// Extract the TLD from the domain parts, which are all the remaining parts joined with a period again | ||
let tld = domainParts.dropFirst().joined(separator: ".") | ||
|
||
// Complete initialization | ||
self.username = username | ||
self.hostname = domain | ||
self.tld = tld | ||
self.string = email | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
69 changes: 69 additions & 0 deletions
69
Tests/SpotHeroEmailValidatorTests/EmailComponentsTests.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
// Copyright © 2022 SpotHero, Inc. All rights reserved. | ||
|
||
@testable import SpotHeroEmailValidator | ||
import XCTest | ||
|
||
// swiftlint:disable nesting | ||
class EmailComponentsTests: XCTestCase { | ||
struct TestModel { | ||
enum ExpectedResult { | ||
case `throw`(SpotHeroEmailValidator.Error) | ||
case `return`(username: String, hostname: String, tld: String) | ||
} | ||
|
||
/// The email under test. | ||
let email: String | ||
|
||
/// The result expected when ran through ``EmailComponents``. | ||
let expectedResult: ExpectedResult | ||
|
||
init(email emailUnderTest: String, expectedTo result: ExpectedResult) { | ||
self.email = emailUnderTest | ||
self.expectedResult = result | ||
} | ||
} | ||
|
||
func testEmailComponentsInit() { | ||
let tests = [ | ||
// Successful Examples | ||
TestModel(email: "test@email.com", | ||
expectedTo: .return(username: "test", hostname: "email", tld: "com")), | ||
|
||
TestModel(email: "TEST@EMAIL.COM", | ||
expectedTo: .return(username: "TEST", hostname: "email", tld: "COM")), | ||
|
||
TestModel(email: "test+-.test@email.com", | ||
expectedTo: .return(username: "test+-.test", hostname: "email", tld: "com")), | ||
|
||
TestModel(email: #""JohnDoe"@email.com"#, | ||
expectedTo: .return(username: #""JohnDoe""#, hostname: "email", tld: "com")), | ||
|
||
// Failing Examples | ||
TestModel(email: "t@st@email.com", expectedTo: .throw(.invalidSyntax)), | ||
TestModel(email: "test.com", expectedTo: .throw(.invalidSyntax)), | ||
|
||
// Domain Tests | ||
TestModel(email: "test@email", expectedTo: .throw(.invalidDomain)), | ||
] | ||
|
||
for test in tests { | ||
switch test.expectedResult { | ||
case .throw: | ||
XCTAssertThrowsError(try EmailComponents(email: test.email)) { error in | ||
XCTAssertEqual(error.localizedDescription, | ||
error.localizedDescription, | ||
"Test failed for email address: \(test.email)") | ||
} | ||
case let .return(username, hostname, tld): | ||
do { | ||
let actualComponents = try EmailComponents(email: test.email) | ||
XCTAssertEqual(actualComponents.username, username) | ||
XCTAssertEqual(actualComponents.hostname, hostname) | ||
XCTAssertEqual(actualComponents.tld, tld) | ||
} catch { | ||
XCTFail("Test failed for email address: \(test.email). \(error.localizedDescription)") | ||
} | ||
} | ||
} | ||
} | ||
} |