refactor: Extract endpoint normalization to reusable EndpointValidator
This commit is contained in:
parent
ab88f2f83f
commit
75200e472c
98
readeck/Domain/Utils/EndpointValidator.swift
Normal file
98
readeck/Domain/Utils/EndpointValidator.swift
Normal file
@ -0,0 +1,98 @@
|
||||
//
|
||||
// EndpointValidator.swift
|
||||
// readeck
|
||||
//
|
||||
// Created by Ilyas Hallak on 05.12.25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Validates and normalizes server endpoint URLs for consistent API usage
|
||||
struct EndpointValidator {
|
||||
|
||||
/// Normalizes an endpoint URL by:
|
||||
/// - Trimming whitespace
|
||||
/// - Ensuring proper scheme (http/https, defaults to https if missing)
|
||||
/// - Preserving custom ports
|
||||
/// - Removing trailing slashes from path
|
||||
/// - Removing query parameters and fragments
|
||||
///
|
||||
/// - Parameter endpoint: Raw endpoint string from user input
|
||||
/// - Returns: Normalized endpoint URL string
|
||||
///
|
||||
/// Examples:
|
||||
/// - "example.com" → "https://example.com"
|
||||
/// - "http://100.80.0.1:8080" → "http://100.80.0.1:8080"
|
||||
/// - "https://server:3000/path/" → "https://server:3000/path"
|
||||
/// - "192.168.1.100:9090?query=test" → "https://192.168.1.100:9090"
|
||||
static func normalize(_ endpoint: String) -> String {
|
||||
var normalized = endpoint.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Handle empty input
|
||||
guard !normalized.isEmpty else {
|
||||
return normalized
|
||||
}
|
||||
|
||||
// Remove query parameters first
|
||||
if let queryIndex = normalized.firstIndex(of: "?") {
|
||||
normalized = String(normalized[..<queryIndex])
|
||||
}
|
||||
|
||||
// Try to parse as URLComponents
|
||||
var urlComponents: URLComponents?
|
||||
|
||||
// First attempt: parse as-is
|
||||
urlComponents = URLComponents(string: normalized)
|
||||
|
||||
// If parsing failed, no scheme, or no host (means URLComponents misinterpreted it),
|
||||
// try adding https:// prefix
|
||||
if urlComponents == nil ||
|
||||
urlComponents?.scheme == nil ||
|
||||
urlComponents?.host == nil {
|
||||
urlComponents = URLComponents(string: "https://" + normalized)
|
||||
}
|
||||
|
||||
// If still no valid components, return original
|
||||
guard let components = urlComponents else {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return buildNormalizedURL(from: components)
|
||||
}
|
||||
|
||||
/// Validates if an endpoint string can be normalized to a valid URL
|
||||
/// - Parameter endpoint: Endpoint string to validate
|
||||
/// - Returns: true if the endpoint can be normalized to a valid URL, false otherwise
|
||||
static func isValid(_ endpoint: String) -> Bool {
|
||||
let normalized = normalize(endpoint)
|
||||
guard let url = URL(string: normalized) else {
|
||||
return false
|
||||
}
|
||||
// Check that we have at minimum a scheme and host
|
||||
return url.scheme != nil && url.host != nil
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private static func buildNormalizedURL(from components: URLComponents) -> String {
|
||||
var urlComponents = components
|
||||
|
||||
// Ensure scheme is http or https, default to https
|
||||
if urlComponents.scheme == nil {
|
||||
urlComponents.scheme = "https"
|
||||
} else if urlComponents.scheme != "http" && urlComponents.scheme != "https" {
|
||||
urlComponents.scheme = "https"
|
||||
}
|
||||
|
||||
// Remove trailing slash from path if present
|
||||
if urlComponents.path.hasSuffix("/") {
|
||||
urlComponents.path = String(urlComponents.path.dropLast())
|
||||
}
|
||||
|
||||
// Remove query parameters and fragments
|
||||
urlComponents.query = nil
|
||||
urlComponents.fragment = nil
|
||||
|
||||
return urlComponents.string ?? components.string ?? ""
|
||||
}
|
||||
}
|
||||
@ -63,7 +63,7 @@ class SettingsServerViewModel {
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
// Normalize endpoint before saving
|
||||
let normalizedEndpoint = normalizeEndpoint(endpoint)
|
||||
let normalizedEndpoint = EndpointValidator.normalize(endpoint)
|
||||
|
||||
let user = try await loginUseCase.execute(endpoint: normalizedEndpoint, username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password)
|
||||
try await saveServerSettingsUseCase.execute(endpoint: normalizedEndpoint, username: username, password: password, token: user.token)
|
||||
@ -80,51 +80,6 @@ class SettingsServerViewModel {
|
||||
isLoggedIn = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Endpoint Normalization
|
||||
|
||||
private func normalizeEndpoint(_ endpoint: String) -> String {
|
||||
var normalized = endpoint.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Remove query parameters
|
||||
if let queryIndex = normalized.firstIndex(of: "?") {
|
||||
normalized = String(normalized[..<queryIndex])
|
||||
}
|
||||
|
||||
// Parse URL components
|
||||
guard var urlComponents = URLComponents(string: normalized) else {
|
||||
// If parsing fails, try adding https:// and parse again
|
||||
normalized = "https://" + normalized
|
||||
guard var urlComponents = URLComponents(string: normalized) else {
|
||||
return normalized
|
||||
}
|
||||
return buildNormalizedURL(from: urlComponents)
|
||||
}
|
||||
|
||||
return buildNormalizedURL(from: urlComponents)
|
||||
}
|
||||
|
||||
private func buildNormalizedURL(from components: URLComponents) -> String {
|
||||
var urlComponents = components
|
||||
|
||||
// Ensure scheme is http or https, default to https
|
||||
if urlComponents.scheme == nil {
|
||||
urlComponents.scheme = "https"
|
||||
} else if urlComponents.scheme != "http" && urlComponents.scheme != "https" {
|
||||
urlComponents.scheme = "https"
|
||||
}
|
||||
|
||||
// Remove trailing slash from path if present
|
||||
if urlComponents.path.hasSuffix("/") {
|
||||
urlComponents.path = String(urlComponents.path.dropLast())
|
||||
}
|
||||
|
||||
// Remove query parameters (already done above, but double check)
|
||||
urlComponents.query = nil
|
||||
urlComponents.fragment = nil
|
||||
|
||||
return urlComponents.string ?? components.string ?? ""
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func logout() async {
|
||||
|
||||
373
readeckTests/Domain/EndpointValidatorTests.swift
Normal file
373
readeckTests/Domain/EndpointValidatorTests.swift
Normal file
@ -0,0 +1,373 @@
|
||||
//
|
||||
// EndpointValidatorTests.swift
|
||||
// readeckTests
|
||||
//
|
||||
// Created by Ilyas Hallak on 05.12.25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import readeck
|
||||
|
||||
final class EndpointValidatorTests: XCTestCase {
|
||||
|
||||
// MARK: - Standard HTTPS URLs
|
||||
|
||||
func testNormalize_FullHTTPSURL() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("https://example.com"),
|
||||
"https://example.com"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_DomainWithoutScheme_AddsHTTPS() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("example.com"),
|
||||
"https://example.com"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_DomainWithSubdomain() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("api.example.com"),
|
||||
"https://api.example.com"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_DomainWithWWW() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("www.example.com"),
|
||||
"https://www.example.com"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - HTTP URLs (Critical for Tailscale)
|
||||
|
||||
func testNormalize_ExplicitHTTP_PreservesHTTP() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("http://example.com"),
|
||||
"http://example.com"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_HTTPWithTailscaleIP() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("http://100.80.0.1"),
|
||||
"http://100.80.0.1"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_HTTPWithTailscaleIPAndPort() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("http://100.80.0.1:8080"),
|
||||
"http://100.80.0.1:8080"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Custom Ports (Critical!)
|
||||
|
||||
func testNormalize_HTTPSWithCustomPort() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("https://example.com:8443"),
|
||||
"https://example.com:8443"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_HTTPWithCustomPort() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("http://example.com:8080"),
|
||||
"http://example.com:8080"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_DomainWithPortNoScheme_AddsHTTPS() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("example.com:3000"),
|
||||
"https://example.com:3000"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_PortOnly8080() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("localhost:8080"),
|
||||
"https://localhost:8080"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_PortOnly9090() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("server:9090"),
|
||||
"https://server:9090"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Tailscale IP Addresses
|
||||
|
||||
func testNormalize_TailscaleIPNoScheme_AddsHTTPS() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("100.80.0.1"),
|
||||
"https://100.80.0.1"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_TailscaleIPWithPortNoScheme() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("100.80.0.1:8080"),
|
||||
"https://100.80.0.1:8080"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_TailscaleIPWithHTTPAndPort() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("http://100.95.200.50:3000"),
|
||||
"http://100.95.200.50:3000"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_TailscaleIPWithHTTPSAndPort() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("https://100.120.10.5:8443"),
|
||||
"https://100.120.10.5:8443"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Private IP Addresses
|
||||
|
||||
func testNormalize_PrivateIPv4NoScheme() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("192.168.1.100"),
|
||||
"https://192.168.1.100"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_PrivateIPv4WithPort() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("192.168.1.100:9090"),
|
||||
"https://192.168.1.100:9090"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_PrivateIPv4WithHTTPAndPort() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("http://192.168.1.100:8080"),
|
||||
"http://192.168.1.100:8080"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_LocalhostWithHTTP() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("http://localhost:8080"),
|
||||
"http://localhost:8080"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Trailing Slashes
|
||||
|
||||
func testNormalize_RemovesTrailingSlash() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("https://example.com/"),
|
||||
"https://example.com"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_RemovesTrailingSlashFromPath() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("https://example.com/api/"),
|
||||
"https://example.com/api"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_RemovesTrailingSlashWithPort() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("http://100.80.0.1:8080/"),
|
||||
"http://100.80.0.1:8080"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Query Parameters and Fragments
|
||||
|
||||
func testNormalize_RemovesQueryParameters() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("https://example.com?query=test"),
|
||||
"https://example.com"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_RemovesFragment() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("https://example.com#section"),
|
||||
"https://example.com"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_RemovesQueryAndFragment() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("https://example.com?query=test#section"),
|
||||
"https://example.com"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_RemovesQueryWithPort() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("http://192.168.1.100:9090?debug=true"),
|
||||
"http://192.168.1.100:9090"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_ComplexQueryParameters() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("https://example.com/path?param1=value1¶m2=value2"),
|
||||
"https://example.com/path"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Paths
|
||||
|
||||
func testNormalize_PreservesPath() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("https://example.com/readeck"),
|
||||
"https://example.com/readeck"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_PreservesNestedPath() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("https://example.com/api/v1"),
|
||||
"https://example.com/api/v1"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_PathWithPortNoScheme() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("example.com:8080/readeck"),
|
||||
"https://example.com:8080/readeck"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_HTTPWithPathAndPort() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("http://100.80.0.1:3000/api"),
|
||||
"http://100.80.0.1:3000/api"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Whitespace Handling
|
||||
|
||||
func testNormalize_TrimsLeadingWhitespace() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize(" https://example.com"),
|
||||
"https://example.com"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_TrimsTrailingWhitespace() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("https://example.com "),
|
||||
"https://example.com"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_TrimsBothWhitespace() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize(" https://example.com "),
|
||||
"https://example.com"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_TrimsWhitespaceFromComplexURL() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize(" http://100.80.0.1:8080/api "),
|
||||
"http://100.80.0.1:8080/api"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Edge Cases
|
||||
|
||||
func testNormalize_EmptyString() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize(""),
|
||||
""
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_OnlyWhitespace() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize(" "),
|
||||
""
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_StandardPort80_Preserved() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("http://example.com:80"),
|
||||
"http://example.com:80"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_StandardPort443_Preserved() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("https://example.com:443"),
|
||||
"https://example.com:443"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Complex Real-World Scenarios
|
||||
|
||||
func testNormalize_TailscaleWithPathQueryAndTrailingSlash() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("http://100.80.0.1:8080/readeck/?setup=true"),
|
||||
"http://100.80.0.1:8080/readeck"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_UserInputWithEverything() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize(" http://192.168.1.50:9090/api/v1/?debug=true#main "),
|
||||
"http://192.168.1.50:9090/api/v1"
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalize_InvalidScheme_ConvertsToHTTPS() {
|
||||
XCTAssertEqual(
|
||||
EndpointValidator.normalize("ftp://example.com"),
|
||||
"https://example.com"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - isValid Tests
|
||||
|
||||
func testIsValid_ValidHTTPSURL() {
|
||||
XCTAssertTrue(EndpointValidator.isValid("https://example.com"))
|
||||
}
|
||||
|
||||
func testIsValid_ValidHTTPURL() {
|
||||
XCTAssertTrue(EndpointValidator.isValid("http://example.com"))
|
||||
}
|
||||
|
||||
func testIsValid_ValidDomainWithoutScheme() {
|
||||
XCTAssertTrue(EndpointValidator.isValid("example.com"))
|
||||
}
|
||||
|
||||
func testIsValid_ValidTailscaleIP() {
|
||||
XCTAssertTrue(EndpointValidator.isValid("100.80.0.1:8080"))
|
||||
}
|
||||
|
||||
func testIsValid_ValidIPWithPort() {
|
||||
XCTAssertTrue(EndpointValidator.isValid("192.168.1.100:9090"))
|
||||
}
|
||||
|
||||
func testIsValid_EmptyString() {
|
||||
XCTAssertFalse(EndpointValidator.isValid(""))
|
||||
}
|
||||
|
||||
func testIsValid_OnlyWhitespace() {
|
||||
XCTAssertFalse(EndpointValidator.isValid(" "))
|
||||
}
|
||||
|
||||
func testIsValid_ValidWithPath() {
|
||||
XCTAssertTrue(EndpointValidator.isValid("https://example.com/api"))
|
||||
}
|
||||
|
||||
func testIsValid_ValidHTTPWithPortAndPath() {
|
||||
XCTAssertTrue(EndpointValidator.isValid("http://100.80.0.1:3000/readeck"))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user