Initial commit.
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
// swift-tools-version: 6.3
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "Utility",
|
||||
platforms: [
|
||||
.macOS(.v15),
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "Utility",
|
||||
targets: [
|
||||
"Utility"
|
||||
]
|
||||
)
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "Utility",
|
||||
path: "Sources",
|
||||
),
|
||||
.testTarget(
|
||||
name: "UtilityTests",
|
||||
dependencies: [
|
||||
.byName(name: "Utility")
|
||||
],
|
||||
path: "Tests"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
# Utility
|
||||
The general-purpose helpers the **Loud** services share: small, single-purpose methods with no dependencies beyond Foundation and no ties to any web framework.
|
||||
|
||||
## Overview
|
||||
| Role | Types |
|
||||
| --- | --- |
|
||||
| Validation | `NormalizeEmail`, which reduces a submitted email address to its canonical form |
|
||||
|
||||
## Design rules
|
||||
- **Dependency-free.** A helper belongs here only while it needs nothing beyond Foundation; one that grows a framework dependency belongs in the package owning that framework's concerns (e.g. `Infrastructure` for Hummingbird).
|
||||
- **Method structs.** Helpers hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`.
|
||||
|
||||
## Layout
|
||||
Sources are split by visibility, then by kind, one type per file:
|
||||
```
|
||||
Sources/
|
||||
└── Public/
|
||||
└── Methods/ NormalizeEmail
|
||||
Tests/
|
||||
├── Cases/ the test suites, mirroring the Sources/ layout
|
||||
└── Utils/ the suite Tag constants
|
||||
```
|
||||
|
||||
## Testing
|
||||
Every suite carries a tag for the kind of API it exercises — `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and summaries can slice a run by kind. A new suite adopts the tag matching its subject, or adds one when none fits.
|
||||
|
||||
## Requirements
|
||||
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||||
- macOS 15, matching the sibling packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||
- No package dependencies — Foundation only.
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
/// A validator reducing a submitted email address to its canonical form.
|
||||
///
|
||||
/// Trims surrounding whitespace, lowercases the address (so one mailbox cannot register once per spelling), caps it at the 254 bytes an address can
|
||||
/// be, and checks its shape: something before the `@`, and a domain with a dot. Control characters are rejected separately: the shape only excludes
|
||||
/// whitespace, which would let non-whitespace controls (such as `NUL`) into the stored address.
|
||||
public struct NormalizeEmail: Sendable {
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an email normalization method.
|
||||
public init() {}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// Validates and normalizes a submitted email address.
|
||||
/// - Parameter email: the submitted email address, if any.
|
||||
/// - Returns: the normalized address, or `nil` when the submission is missing or invalid.
|
||||
public func callAsFunction(
|
||||
_ email: String?
|
||||
) -> String? {
|
||||
guard
|
||||
let email = email?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased(),
|
||||
email.utf8.count <= Constant.Length.maxEmail,
|
||||
!email.unicodeScalars.contains(where: {
|
||||
$0.properties.generalCategory == .control
|
||||
}),
|
||||
email.wholeMatch(of: /[^\s@]+@[^\s@]+\.[^\s@]+/) != nil
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return email
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private enum Constant {
|
||||
/// A namespace for the length constants.
|
||||
enum Length {
|
||||
/// The longest an email address can be, in bytes, per RFC 5321's path limit.
|
||||
static let maxEmail = 254
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Testing
|
||||
|
||||
@testable import Utility
|
||||
|
||||
@Suite(
|
||||
"NormalizeEmail method",
|
||||
.tags(.method)
|
||||
)
|
||||
struct NormalizeEmailTests {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
private let normalize = NormalizeEmail()
|
||||
|
||||
// MARK: Functional tests
|
||||
|
||||
@Test(arguments: [
|
||||
("fan@loudmail.nl", "fan@loudmail.nl"),
|
||||
("Fan@LoudMail.NL", "fan@loudmail.nl"),
|
||||
(" fan@loudmail.nl\n", "fan@loudmail.nl"),
|
||||
("fan+gigs@loudmail.nl", "fan+gigs@loudmail.nl"),
|
||||
// Exactly 254 bytes: the longest address the validator accepts.
|
||||
(String(repeating: "a", count: 242) + "@loudmail.nl", String(repeating: "a", count: 242) + "@loudmail.nl"),
|
||||
])
|
||||
func `normalizes a valid address`(
|
||||
submitted: String,
|
||||
expected: String
|
||||
) {
|
||||
#expect(normalize(submitted) == expected)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
nil,
|
||||
"",
|
||||
" ",
|
||||
"not-an-email",
|
||||
"missing@dot",
|
||||
"@loudmail.nl",
|
||||
"fan@",
|
||||
"fan@.nl",
|
||||
"spaced out@loudmail.nl",
|
||||
"fan@loud mail.nl",
|
||||
// One byte over the 254-byte limit.
|
||||
String(repeating: "a", count: 243) + "@loudmail.nl",
|
||||
// Few enough characters, but multibyte ones put it over the byte limit.
|
||||
String(repeating: "é", count: 130) + "@loudmail.nl",
|
||||
// Control characters are not whitespace, so only the dedicated check catches them.
|
||||
"fan\u{00}@loudmail.nl",
|
||||
"fan@loudmail.nl\u{7F}",
|
||||
] as [String?])
|
||||
func `rejects a missing or invalid address`(
|
||||
submitted: String?
|
||||
) {
|
||||
#expect(normalize(submitted) == nil)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Testing
|
||||
|
||||
extension Tag {
|
||||
/// Tests exercising a method of the Utility package.
|
||||
@Tag static var method: Tag
|
||||
}
|
||||
Reference in New Issue
Block a user