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` |
|
| Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` |
|
||||||
| Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` |
|
| Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` |
|
||||||
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
|
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
|
||||||
|
| Link previews | `SocialCard`, its `Image`, and the `SocialCardTag` meta tags it derives |
|
||||||
| Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` |
|
| Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` |
|
||||||
| Contexts | `LocalizedRequestContext` |
|
| Contexts | `LocalizedRequestContext` |
|
||||||
| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to |
|
| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to |
|
||||||
|
|
||||||
## Design rules
|
## Design rules
|
||||||
The package holds only what every service can reuse; anything a service owns is injected, never referenced:
|
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.
|
- **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`.
|
- **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
|
│ ├── Methods/ FingerprintAssets
|
||||||
│ ├── Middlewares/ the five HTTP middlewares
|
│ ├── Middlewares/ the five HTTP middlewares
|
||||||
│ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController
|
│ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController
|
||||||
│ └── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse
|
│ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse
|
||||||
|
│ └── Types/ SocialCard, with its image and tag types in SocialCard/
|
||||||
└── Internal/
|
└── Internal/
|
||||||
└── Types/ implementation details (FNV1aHash)
|
└── Types/ implementation details (FNV1aHash)
|
||||||
Tests/
|
Tests/
|
||||||
@@ -43,4 +45,4 @@ Every suite carries a tag naming the kind of API it exercises — `.asset`, `.ex
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
- Swift 6.3 toolchain (`swift-tools-version:6.3`).
|
- 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: [
|
dependencies: [
|
||||||
|
.package(
|
||||||
|
path: "../../Packages/Infrastructure"
|
||||||
|
),
|
||||||
.package(
|
.package(
|
||||||
path: "../../Packages/Localization"
|
path: "../../Packages/Localization"
|
||||||
),
|
),
|
||||||
@@ -27,7 +30,7 @@ let package = Package(
|
|||||||
path: "../../Packages/Persistence"
|
path: "../../Packages/Persistence"
|
||||||
),
|
),
|
||||||
.package(
|
.package(
|
||||||
path: "../../Packages/Infrastructure"
|
path: "../../Packages/Utility"
|
||||||
),
|
),
|
||||||
.package(
|
.package(
|
||||||
url: "https://github.com/elementary-swift/elementary.git",
|
url: "https://github.com/elementary-swift/elementary.git",
|
||||||
@@ -79,9 +82,10 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "WebsiteLibrary",
|
name: "WebsiteLibrary",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
|
.byName(name: "Infrastructure"),
|
||||||
.byName(name: "Localization"),
|
.byName(name: "Localization"),
|
||||||
.byName(name: "Persistence"),
|
.byName(name: "Persistence"),
|
||||||
.byName(name: "Infrastructure"),
|
.byName(name: "Utility"),
|
||||||
.product(
|
.product(
|
||||||
name: "Configuration",
|
name: "Configuration",
|
||||||
package: "swift-configuration"
|
package: "swift-configuration"
|
||||||
@@ -121,8 +125,8 @@ let package = Package(
|
|||||||
.testTarget(
|
.testTarget(
|
||||||
name: "WebsiteLibraryTests",
|
name: "WebsiteLibraryTests",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.byName(name: "Persistence"),
|
|
||||||
.byName(name: "Infrastructure"),
|
.byName(name: "Infrastructure"),
|
||||||
|
.byName(name: "Persistence"),
|
||||||
.byName(name: "WebsiteLibrary"),
|
.byName(name: "WebsiteLibrary"),
|
||||||
.product(
|
.product(
|
||||||
name: "Elementary",
|
name: "Elementary",
|
||||||
|
|||||||
@@ -52,6 +52,13 @@
|
|||||||
"identifier" : "InfrastructureTests",
|
"identifier" : "InfrastructureTests",
|
||||||
"name" : "InfrastructureTests"
|
"name" : "InfrastructureTests"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target" : {
|
||||||
|
"containerPath" : "container:..\/..\/Packages\/Utility",
|
||||||
|
"identifier" : "UtilityTests",
|
||||||
|
"name" : "UtilityTests"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 1
|
"version" : 1
|
||||||
|
|||||||
Reference in New Issue
Block a user