Skip to content

Commit e0f6576

Browse files
coenttbclaude
andcommitted
Add RFC 1123 implementation and tests
🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 18ef6a8 commit e0f6576

File tree

2 files changed

+348
-0
lines changed

2 files changed

+348
-0
lines changed

Sources/RFC_1123/RFC 1123.swift

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
//
2+
// File.swift
3+
// swift-web
4+
//
5+
// Created by Coen ten Thije Boonkkamp on 28/12/2024.
6+
//
7+
8+
import Foundation
9+
import RFC_1035
10+
11+
/// RFC 1123 compliant host name
12+
public struct Domain: Hashable, Sendable {
13+
/// The labels that make up the host name, from least significant to most significant
14+
private let labels: [Label]
15+
16+
/// Initialize with an array of string labels, validating RFC 1123 rules
17+
public init(labels: [String]) throws {
18+
guard !labels.isEmpty else {
19+
throw ValidationError.empty
20+
}
21+
22+
guard labels.count <= Limits.maxLabels else {
23+
throw ValidationError.tooManyLabels
24+
}
25+
26+
// Validate TLD according to stricter RFC 1123 rules
27+
guard let tld = labels.last else {
28+
throw ValidationError.empty
29+
}
30+
31+
// Convert and validate labels
32+
var validatedLabels = try labels.dropLast().map { label in
33+
try Label(label, validateAs: .label)
34+
}
35+
36+
// Add TLD with stricter validation
37+
validatedLabels.append(try Label(tld, validateAs: .tld))
38+
39+
self.labels = validatedLabels
40+
41+
// Check total length including dots
42+
let totalLength = self.name.count
43+
guard totalLength <= Limits.maxLength else {
44+
throw ValidationError.tooLong(totalLength)
45+
}
46+
}
47+
48+
/// Initialize from a string representation (e.g. "host.example.com")
49+
public init(_ string: String) throws {
50+
try self.init(labels: string.split(separator: ".", omittingEmptySubsequences: true).map(String.init))
51+
}
52+
}
53+
54+
// MARK: - Label Type
55+
extension Domain {
56+
/// A type-safe host label that enforces RFC 1123 rules
57+
public struct Label: Hashable, Sendable {
58+
enum ValidationType {
59+
case label // Regular label rules
60+
case tld // Stricter TLD rules
61+
}
62+
63+
private let value: String
64+
65+
/// Initialize a label, validating RFC 1123 rules
66+
internal init(_ string: String, validateAs type: ValidationType) throws {
67+
guard !string.isEmpty, string.count <= Domain.Limits.maxLabelLength else {
68+
throw type == .tld ? Domain.ValidationError.invalidTLD(string) : Domain.ValidationError.invalidLabel(string)
69+
}
70+
71+
let regex = type == .tld ? Domain.tldRegex : Domain.labelRegex
72+
guard (try? regex.wholeMatch(in: string)) != nil else {
73+
throw type == .tld ? Domain.ValidationError.invalidTLD(string) : Domain.ValidationError.invalidLabel(string)
74+
}
75+
76+
self.value = string
77+
}
78+
79+
public var stringValue: String { value }
80+
}
81+
}
82+
83+
// MARK: - Constants and Validation
84+
extension Domain {
85+
internal enum Limits {
86+
static let maxLength = 255
87+
static let maxLabels = 127
88+
static let maxLabelLength = 63
89+
}
90+
91+
/// RFC 1123 label regex:
92+
/// - Can begin with letter or digit
93+
/// - Can end with letter or digit
94+
/// - May have hyphens in interior positions only
95+
nonisolated(unsafe) internal static let labelRegex = /[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?/
96+
97+
/// RFC 1123 TLD regex:
98+
/// - Must begin with a letter
99+
/// - Must end with a letter
100+
/// - May have hyphens in interior positions only
101+
nonisolated(unsafe) internal static let tldRegex = /[a-zA-Z](?:[a-zA-Z0-9\-]*[a-zA-Z])?/
102+
}
103+
104+
// MARK: - Properties and Methods
105+
extension Domain {
106+
/// The complete host name as a string
107+
public var name: String {
108+
labels.map(\.stringValue).joined(separator: ".")
109+
}
110+
111+
/// The top-level domain (rightmost label)
112+
public var tld: Label? {
113+
labels.last
114+
}
115+
116+
/// The second-level domain (second from right)
117+
public var sld: Label? {
118+
labels.dropLast().last
119+
}
120+
121+
/// Returns true if this is a subdomain of the given host
122+
public func isSubdomain(of parent: Domain) -> Bool {
123+
guard labels.count > parent.labels.count else { return false }
124+
return labels.suffix(parent.labels.count) == parent.labels
125+
}
126+
127+
/// Creates a subdomain by prepending new labels
128+
public func addingSubdomain(_ components: [String]) throws -> Domain {
129+
try Domain(labels: components + labels.map(\.stringValue))
130+
}
131+
132+
public func addingSubdomain(_ components: String...) throws -> Domain {
133+
try self.addingSubdomain(components)
134+
}
135+
136+
/// Returns the parent domain by removing the leftmost label
137+
public func parent() throws -> Domain? {
138+
guard labels.count > 1 else { return nil }
139+
return try Domain(labels: labels.dropFirst().map(\.stringValue))
140+
}
141+
142+
/// Returns the root domain (tld + sld)
143+
public func root() throws -> Domain? {
144+
guard labels.count >= 2 else { return nil }
145+
return try Domain(labels: labels.suffix(2).map(\.stringValue))
146+
}
147+
}
148+
149+
// MARK: - Errors
150+
extension Domain {
151+
public enum ValidationError: Error, LocalizedError, Equatable {
152+
case empty
153+
case tooLong(_ length: Int)
154+
case tooManyLabels
155+
case invalidLabel(_ label: String)
156+
case invalidTLD(_ tld: String)
157+
158+
public var errorDescription: String? {
159+
switch self {
160+
case .empty:
161+
return "Host name cannot be empty"
162+
case .tooLong(let length):
163+
return "Host name length \(length) exceeds maximum of \(Limits.maxLength)"
164+
case .tooManyLabels:
165+
return "Host name has too many labels (maximum \(Limits.maxLabels))"
166+
case .invalidLabel(let label):
167+
return "Invalid label '\(label)'. Must start and end with letter/digit, and contain only letters/digits/hyphens"
168+
case .invalidTLD(let tld):
169+
return "Invalid TLD '\(tld)'. Must start and end with letter, and contain only letters/digits/hyphens"
170+
}
171+
}
172+
}
173+
}
174+
175+
// MARK: - Convenience Initializers
176+
extension Domain {
177+
/// Creates a host from root level components
178+
public static func root(_ sld: String, _ tld: String) throws -> Domain {
179+
try Domain(labels: [sld, tld])
180+
}
181+
182+
/// Creates a subdomain with components in most-to-least significant order
183+
public static func subdomain(_ components: String...) throws -> Domain {
184+
try Domain(labels: components.reversed())
185+
}
186+
}
187+
188+
// MARK: - Protocol Conformances
189+
extension Domain: CustomStringConvertible {
190+
public var description: String { name }
191+
}
192+
193+
extension Domain: Codable {
194+
public func encode(to encoder: Encoder) throws {
195+
var container = encoder.singleValueContainer()
196+
try container.encode(name)
197+
}
198+
199+
public init(from decoder: Decoder) throws {
200+
let container = try decoder.singleValueContainer()
201+
let string = try container.decode(String.self)
202+
try self.init(string)
203+
}
204+
}
205+
206+
extension Domain: RawRepresentable {
207+
public var rawValue: String { name }
208+
public init?(rawValue: String) { try? self.init(rawValue) }
209+
}
210+
211+
extension RFC_1123.Domain {
212+
public init(_ domain: RFC_1035.Domain) throws {
213+
self = try RFC_1123.Domain(domain.name)
214+
}
215+
216+
public func toRFC1035() throws -> RFC_1035.Domain {
217+
try RFC_1035.Domain(self.name)
218+
}
219+
}
220+
221+
extension RFC_1035.Domain {
222+
public init(_ domain: RFC_1123.Domain) throws {
223+
try self.init(domain.name)
224+
}
225+
226+
public func toRFC1123() throws -> RFC_1123.Domain {
227+
try RFC_1123.Domain(self.name)
228+
}
229+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
//
2+
// File.swift
3+
// swift-web
4+
//
5+
// Created by Coen ten Thije Boonkkamp on 28/12/2024.
6+
//
7+
8+
import Foundation
9+
import RFC_1123
10+
import Testing
11+
12+
@Suite("RFC 1123 Host Tests")
13+
struct RFC1123Tests {
14+
@Test("Successfully creates valid host")
15+
func testValidHost() throws {
16+
let host = try Domain("host.example.com")
17+
#expect(host.name == "host.example.com")
18+
}
19+
20+
@Test("Successfully creates host with numeric labels")
21+
func testNumericLabels() throws {
22+
let host = try Domain("123.example.com")
23+
#expect(host.name == "123.example.com")
24+
}
25+
26+
@Test("Successfully creates host with mixed alphanumeric labels")
27+
func testMixedLabels() throws {
28+
let host = try Domain("host123.example456.com")
29+
#expect(host.name == "host123.example456.com")
30+
}
31+
32+
@Test("Fails with empty host")
33+
func testEmptyHost() throws {
34+
#expect(throws: Domain.ValidationError.empty) {
35+
_ = try Domain("")
36+
}
37+
}
38+
39+
@Test("Fails with invalid TLD starting with number")
40+
func testInvalidTLDStartingWithNumber() throws {
41+
#expect(throws: Domain.ValidationError.invalidTLD("123com")) {
42+
_ = try Domain("example.123com")
43+
}
44+
}
45+
46+
@Test("Fails with invalid TLD ending with number")
47+
func testInvalidTLDEndingWithNumber() throws {
48+
#expect(throws: Domain.ValidationError.invalidTLD("com123")) {
49+
_ = try Domain("example.com123")
50+
}
51+
}
52+
53+
@Test("Fails with invalid label containing special characters")
54+
func testInvalidLabelSpecialChars() throws {
55+
#expect(throws: Domain.ValidationError.invalidLabel("host@name")) {
56+
_ = try Domain("[email protected]")
57+
}
58+
}
59+
60+
@Test("Successfully gets TLD")
61+
func testTLD() throws {
62+
let host = try Domain("example.com")
63+
#expect(host.tld?.stringValue == "com")
64+
}
65+
66+
@Test("Successfully gets SLD")
67+
func testSLD() throws {
68+
let host = try Domain("example.com")
69+
#expect(host.sld?.stringValue == "example")
70+
}
71+
72+
@Test("Successfully detects subdomain relationship")
73+
func testIsSubdomain() throws {
74+
let parent = try Domain("example.com")
75+
let child = try Domain("host.example.com")
76+
#expect(child.isSubdomain(of: parent))
77+
}
78+
79+
@Test("Successfully adds subdomain")
80+
func testAddSubdomain() throws {
81+
let host = try Domain("example.com")
82+
let subdomain = try host.addingSubdomain("host")
83+
#expect(subdomain.name == "host.example.com")
84+
}
85+
86+
@Test("Successfully gets parent domain")
87+
func testParentDomain() throws {
88+
let host = try Domain("host.example.com")
89+
let parent = try host.parent()
90+
#expect(parent?.name == "example.com")
91+
}
92+
93+
@Test("Successfully gets root domain")
94+
func testRootDomain() throws {
95+
let host = try Domain("host.example.com")
96+
let root = try host.root()
97+
#expect(root?.name == "example.com")
98+
}
99+
100+
@Test("Successfully creates host from root components")
101+
func testRootInitializer() throws {
102+
let host = try Domain.root("example", "com")
103+
#expect(host.name == "example.com")
104+
}
105+
106+
@Test("Successfully creates host from subdomain components")
107+
func testSubdomainInitializer() throws {
108+
let host = try Domain.subdomain("com", "example", "host")
109+
#expect(host.name == "host.example.com")
110+
}
111+
112+
@Test("Successfully encodes and decodes")
113+
func testCodable() throws {
114+
let original = try Domain("example.com")
115+
let encoded = try JSONEncoder().encode(original)
116+
let decoded = try JSONDecoder().decode(Domain.self, from: encoded)
117+
#expect(original == decoded)
118+
}
119+
}

0 commit comments

Comments
 (0)