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:
2026-08-01 11:15:27 +00:00
committed by javier
parent efc933d5d0
commit 5f4316b85b
8 changed files with 193 additions and 6 deletions
+5 -3
View File
@@ -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).
+31
View File
@@ -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"
),
]
)
+31
View File
@@ -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
}
+7 -3
View File
@@ -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