Added the Utility package (#29)
This PR contains the work done to create the new **Utility** package within the project, and also included in it the `NormalizeEmail` method, as it's not something that belongs to the **Infrastructure** package. Reviewed-on: rock-n-code/loud-amsterdam#29 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
@@ -8,13 +8,14 @@ The package provides, grouped by role:
|
||||
| Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` |
|
||||
| Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` |
|
||||
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
|
||||
| Link previews | `SocialCard`, its `Image`, and the `SocialCardTag` meta tags it derives |
|
||||
| Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` |
|
||||
| Contexts | `LocalizedRequestContext` |
|
||||
| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to |
|
||||
|
||||
## Design rules
|
||||
The package holds only what every service can reuse; anything a service owns is injected, never referenced:
|
||||
- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors.
|
||||
- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors — as are the `summary`, `canonicalURL`, and `socialCard` values its other head tags render, each omitted unless the page provides it. A `SocialCard` takes its URLs fully formed and absolute; composing them from an origin and a versioned asset path stays with the page providing the card.
|
||||
- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow.
|
||||
- **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`.
|
||||
|
||||
@@ -29,7 +30,8 @@ Sources/
|
||||
│ ├── Methods/ FingerprintAssets
|
||||
│ ├── Middlewares/ the five HTTP middlewares
|
||||
│ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController
|
||||
│ └── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse
|
||||
│ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse
|
||||
│ └── Types/ SocialCard, with its image and tag types in SocialCard/
|
||||
└── Internal/
|
||||
└── Types/ implementation details (FNV1aHash)
|
||||
Tests/
|
||||
@@ -43,4 +45,4 @@ Every suite carries a tag naming the kind of API it exercises — `.asset`, `.ex
|
||||
|
||||
## Requirements
|
||||
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||||
- macOS 15, matching the sibling `Localization` and `Persistence` packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||
- macOS 15, matching the sibling `Localization`, `Persistence`, and `Utility` packages (the services deploy to Linux containers; the packages carry no UI platforms).
|
||||
|
||||
@@ -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,31 @@
|
||||
# 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
|
||||
The package provides, grouped by role:
|
||||
| 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 naming the kind of API it exercises — `.method`, declared in `Tests/Utils/Extensions/Tag+Constants.swift` — so test plans and result summaries can slice the run by kind. A new suite must adopt the tag matching its subject (or add a tag there if none fits).
|
||||
|
||||
## Requirements
|
||||
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
||||
- macOS 15, matching the sibling `Infrastructure`, `Localization`, and `Persistence` 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
|
||||
}
|
||||
@@ -20,6 +20,9 @@ let package = Package(
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(
|
||||
path: "../../Packages/Infrastructure"
|
||||
),
|
||||
.package(
|
||||
path: "../../Packages/Localization"
|
||||
),
|
||||
@@ -27,7 +30,7 @@ let package = Package(
|
||||
path: "../../Packages/Persistence"
|
||||
),
|
||||
.package(
|
||||
path: "../../Packages/Infrastructure"
|
||||
path: "../../Packages/Utility"
|
||||
),
|
||||
.package(
|
||||
url: "https://github.com/elementary-swift/elementary.git",
|
||||
@@ -79,9 +82,10 @@ let package = Package(
|
||||
.target(
|
||||
name: "WebsiteLibrary",
|
||||
dependencies: [
|
||||
.byName(name: "Infrastructure"),
|
||||
.byName(name: "Localization"),
|
||||
.byName(name: "Persistence"),
|
||||
.byName(name: "Infrastructure"),
|
||||
.byName(name: "Utility"),
|
||||
.product(
|
||||
name: "Configuration",
|
||||
package: "swift-configuration"
|
||||
@@ -121,8 +125,8 @@ let package = Package(
|
||||
.testTarget(
|
||||
name: "WebsiteLibraryTests",
|
||||
dependencies: [
|
||||
.byName(name: "Persistence"),
|
||||
.byName(name: "Infrastructure"),
|
||||
.byName(name: "Persistence"),
|
||||
.byName(name: "WebsiteLibrary"),
|
||||
.product(
|
||||
name: "Elementary",
|
||||
|
||||
@@ -52,6 +52,13 @@
|
||||
"identifier" : "InfrastructureTests",
|
||||
"name" : "InfrastructureTests"
|
||||
}
|
||||
},
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:..\/..\/Packages\/Utility",
|
||||
"identifier" : "UtilityTests",
|
||||
"name" : "UtilityTests"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 1
|
||||
|
||||
Reference in New Issue
Block a user