Skip to content

Commit

Permalink
Add Email and EmailComponents (#51)
Browse files Browse the repository at this point in the history
  • Loading branch information
MartinJrP committed Nov 14, 2022
1 parent 1da83b4 commit d0fe24f
Show file tree
Hide file tree
Showing 4 changed files with 162 additions and 35 deletions.
25 changes: 25 additions & 0 deletions Sources/SpotHeroEmailValidator/Email.swift
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
}
}
66 changes: 66 additions & 0 deletions Sources/SpotHeroEmailValidator/EmailComponents.swift
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
}
}
37 changes: 2 additions & 35 deletions Sources/SpotHeroEmailValidator/SpotHeroEmailValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import Foundation

// TODO: Remove NSObject when entirely converted into Swift
public class SpotHeroEmailValidator: NSObject {
private typealias EmailParts = (username: String, hostname: String, tld: String)

public static let shared = SpotHeroEmailValidator()

private let commonTLDs: [String]
Expand Down Expand Up @@ -44,7 +42,7 @@ public class SpotHeroEmailValidator: NSObject {
try self.validateSyntax(of: emailAddress)

// Split the email address into its component parts
let emailParts = try self.splitEmailAddress(emailAddress)
let emailParts = try EmailComponents(email: emailAddress)

var suggestedTLD = emailParts.tld

Expand Down Expand Up @@ -72,7 +70,7 @@ public class SpotHeroEmailValidator: NSObject {
@discardableResult
public func validateSyntax(of emailAddress: String) throws -> Bool {
// Split the email address into parts
let emailParts = try self.splitEmailAddress(emailAddress)
let emailParts = try EmailComponents(email: emailAddress)

// Ensure the username is valid by itself
guard emailParts.username.isValidEmailUsername() else {
Expand Down Expand Up @@ -116,37 +114,6 @@ public class SpotHeroEmailValidator: NSObject {

return closestString
}

private func splitEmailAddress(_ emailAddress: String) throws -> EmailParts {
// Ensure there is exactly one @ symbol.
guard emailAddress.filter({ $0 == "@" }).count == 1 else {
throw Error.invalidSyntax
}

let emailAddressParts = emailAddress.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 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: ".")

return (username, domain, tld)
}
}

// MARK: - Extensions
Expand Down
69 changes: 69 additions & 0 deletions Tests/SpotHeroEmailValidatorTests/EmailComponentsTests.swift
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)")
}
}
}
}
}

0 comments on commit d0fe24f

Please sign in to comment.