Skip to content

Commit 7d393cd

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

File tree

2 files changed

+302
-0
lines changed

2 files changed

+302
-0
lines changed

Sources/RFC_1035/RFC 1035.swift

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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+
10+
/// RFC 1035 compliant domain name
11+
public struct Domain: Hashable, Sendable {
12+
/// The labels that make up the domain name, from least significant to most significant
13+
private let labels: [Label]
14+
15+
/// Initialize with an array of string labels, validating RFC 1035 rules
16+
public init(labels: [String]) throws {
17+
guard !labels.isEmpty else {
18+
throw ValidationError.empty
19+
}
20+
21+
guard labels.count <= Limits.maxLabels else {
22+
throw ValidationError.tooManyLabels
23+
}
24+
25+
// Validate and convert each label
26+
self.labels = try labels.map(Label.init)
27+
28+
// Check total length including dots
29+
let totalLength = self.name.count
30+
guard totalLength <= Limits.maxLength else {
31+
throw ValidationError.tooLong(totalLength)
32+
}
33+
}
34+
35+
/// Initialize from a string representation (e.g. "example.com")
36+
public init(_ string: String) throws {
37+
try self.init(labels: string.split(separator: ".", omittingEmptySubsequences: true).map(String.init))
38+
}
39+
}
40+
41+
extension Domain {
42+
/// A type-safe domain label that enforces RFC 1035 rules
43+
public struct Label: Hashable, Sendable {
44+
private let value: String
45+
46+
/// Initialize a label, validating RFC 1035 rules
47+
internal init(_ string: String) throws {
48+
guard !string.isEmpty, string.count <= Domain.Limits.maxLabelLength else {
49+
throw Domain.ValidationError.invalidLabel(string)
50+
}
51+
52+
guard (try? Domain.labelRegex.wholeMatch(in: string)) != nil else {
53+
throw Domain.ValidationError.invalidLabel(string)
54+
}
55+
56+
self.value = string
57+
}
58+
59+
public var stringValue: String { value }
60+
}
61+
}
62+
63+
extension Domain {
64+
/// The complete domain name as a string
65+
public var name: String {
66+
labels.map(\.stringValue).joined(separator: ".")
67+
}
68+
69+
/// The top-level domain (rightmost label)
70+
public var tld: Domain.Label? {
71+
labels.last
72+
}
73+
74+
/// The second-level domain (second from right)
75+
public var sld: Domain.Label? {
76+
labels.dropLast().last
77+
}
78+
79+
/// Returns true if this is a subdomain of the given domain
80+
public func isSubdomain(of parent: Domain) -> Bool {
81+
guard labels.count > parent.labels.count else { return false }
82+
return labels.suffix(parent.labels.count) == parent.labels
83+
}
84+
85+
/// Creates a subdomain by prepending new labels
86+
public func addingSubdomain(_ components: [String]) throws -> Domain {
87+
try Domain(labels: components + labels.map(\.stringValue))
88+
}
89+
90+
public func addingSubdomain(_ components: String...) throws -> Domain {
91+
try self.addingSubdomain(components)
92+
}
93+
94+
/// Returns the parent domain by removing the leftmost label
95+
public func parent() throws -> Domain? {
96+
guard labels.count > 1 else { return nil }
97+
return try Domain(labels: labels.dropFirst().map(\.stringValue))
98+
}
99+
100+
/// Returns the root domain (tld + sld)
101+
public func root() throws -> Domain? {
102+
guard labels.count >= 2 else { return nil }
103+
return try Domain(labels: labels.suffix(2).map(\.stringValue))
104+
}
105+
}
106+
107+
// MARK: - Constants and Validation
108+
extension Domain {
109+
internal enum Limits {
110+
static let maxLength = 255
111+
static let maxLabels = 127
112+
static let maxLabelLength = 63
113+
}
114+
115+
// RFC 1035 label regex:
116+
// - Must begin with a letter
117+
// - Must end with a letter or digit
118+
// - May have hyphens in interior positions only
119+
nonisolated(unsafe) internal static let labelRegex = /[a-zA-Z](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?/
120+
}
121+
122+
// MARK: - Errors
123+
extension Domain {
124+
public enum ValidationError: Error, LocalizedError, Equatable {
125+
case empty
126+
case tooLong(_ length: Int)
127+
case tooManyLabels
128+
case invalidLabel(_ label: String)
129+
130+
public var errorDescription: String? {
131+
switch self {
132+
case .empty:
133+
return "Domain name cannot be empty"
134+
case .tooLong(let length):
135+
return "Domain name length \(length) exceeds maximum of \(Limits.maxLength)"
136+
case .tooManyLabels:
137+
return "Domain name has too many labels (maximum \(Limits.maxLabels))"
138+
case .invalidLabel(let label):
139+
return "Invalid label '\(label)'. Must start with letter, end with letter/digit, and contain only letters/digits/hyphens"
140+
}
141+
}
142+
}
143+
}
144+
145+
// MARK: - Convenience Initializers
146+
extension Domain {
147+
/// Creates a domain from root level components
148+
public static func root(_ sld: String, _ tld: String) throws -> Domain {
149+
try Domain(labels: [sld, tld])
150+
}
151+
152+
/// Creates a subdomain with components in most-to-least significant order
153+
public static func subdomain(_ components: String...) throws -> Domain {
154+
try Domain(labels: components.reversed())
155+
}
156+
}
157+
158+
// MARK: - Protocol Conformances
159+
extension Domain: CustomStringConvertible {
160+
public var description: String { name }
161+
}
162+
163+
extension Domain: Codable {
164+
public func encode(to encoder: Encoder) throws {
165+
var container = encoder.singleValueContainer()
166+
try container.encode(name)
167+
}
168+
169+
public init(from decoder: Decoder) throws {
170+
let container = try decoder.singleValueContainer()
171+
let string = try container.decode(String.self)
172+
try self.init(string)
173+
}
174+
}
175+
176+
extension Domain: RawRepresentable {
177+
public var rawValue: String { name }
178+
public init?(rawValue: String) { try? self.init(rawValue) }
179+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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+
import Testing
11+
12+
@Suite("RFC 1035 Domain Tests")
13+
struct RFC1035Tests {
14+
@Test("Successfully creates valid domain")
15+
func testValidDomain() throws {
16+
let domain = try Domain("example.com")
17+
#expect(domain.name == "example.com")
18+
}
19+
20+
@Test("Successfully creates subdomain")
21+
func testValidSubdomain() throws {
22+
let domain = try Domain("sub.example.com")
23+
#expect(domain.name == "sub.example.com")
24+
}
25+
26+
@Test("Successfully gets TLD")
27+
func testTLD() throws {
28+
let domain = try Domain("example.com")
29+
#expect(domain.tld?.stringValue == "com")
30+
}
31+
32+
@Test("Successfully gets SLD")
33+
func testSLD() throws {
34+
let domain = try Domain("example.com")
35+
#expect(domain.sld?.stringValue == "example")
36+
}
37+
38+
@Test("Fails with empty domain")
39+
func testEmptyDomain() throws {
40+
#expect(throws: Domain.ValidationError.empty) {
41+
_ = try Domain("")
42+
}
43+
}
44+
45+
@Test("Fails with too many labels")
46+
func testTooManyLabels() throws {
47+
let longDomain = Array(repeating: "a", count: 128).joined(separator: ".")
48+
#expect(throws: Domain.ValidationError.tooManyLabels) {
49+
_ = try Domain(longDomain)
50+
}
51+
}
52+
53+
@Test("Fails with too long domain")
54+
func testTooLongDomain() throws {
55+
let longLabel = String(repeating: "a", count: 63)
56+
let longDomain = Array(repeating: longLabel, count: 5).joined(separator: ".")
57+
#expect(throws: Domain.ValidationError.tooLong(319)) {
58+
_ = try Domain(longDomain)
59+
}
60+
}
61+
62+
@Test("Fails with invalid label starting with hyphen")
63+
func testInvalidLabelStartingWithHyphen() throws {
64+
#expect(throws: Domain.ValidationError.invalidLabel("-example")) {
65+
_ = try Domain("-example.com")
66+
}
67+
}
68+
69+
@Test("Fails with invalid label ending with hyphen")
70+
func testInvalidLabelEndingWithHyphen() throws {
71+
#expect(throws: Domain.ValidationError.invalidLabel("example-")) {
72+
_ = try Domain("example-.com")
73+
}
74+
}
75+
76+
@Test("Successfully detects subdomain relationship")
77+
func testIsSubdomain() throws {
78+
let parent = try Domain("example.com")
79+
let child = try Domain("sub.example.com")
80+
#expect(child.isSubdomain(of: parent))
81+
}
82+
83+
@Test("Successfully adds subdomain")
84+
func testAddSubdomain() throws {
85+
let domain = try Domain("example.com")
86+
let subdomain = try domain.addingSubdomain("sub")
87+
#expect(subdomain.name == "sub.example.com")
88+
}
89+
90+
@Test("Successfully gets parent domain")
91+
func testParentDomain() throws {
92+
let domain = try Domain("sub.example.com")
93+
let parent = try domain.parent()
94+
#expect(parent?.name == "example.com")
95+
}
96+
97+
@Test("Successfully gets root domain")
98+
func testRootDomain() throws {
99+
let domain = try Domain("sub.example.com")
100+
let root = try domain.root()
101+
#expect(root?.name == "example.com")
102+
}
103+
104+
@Test("Successfully creates domain from root components")
105+
func testRootInitializer() throws {
106+
let domain = try Domain.root("example", "com")
107+
#expect(domain.name == "example.com")
108+
}
109+
110+
@Test("Successfully creates domain from subdomain components")
111+
func testSubdomainInitializer() throws {
112+
let domain = try Domain.subdomain("com", "example", "sub")
113+
#expect(domain.name == "sub.example.com")
114+
}
115+
116+
@Test("Successfully encodes and decodes")
117+
func testCodable() throws {
118+
let original = try Domain("example.com")
119+
let encoded = try JSONEncoder().encode(original)
120+
let decoded = try JSONDecoder().decode(Domain.self, from: encoded)
121+
#expect(original == decoded)
122+
}
123+
}

0 commit comments

Comments
 (0)