Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
DerivedData/
*.xcuserstate
.DS_Store
.tmp
3 changes: 0 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,6 @@ let package = Package(
"DGWProto",
"DGWStore",
.product(name: "GRPCCore", package: "grpc-swift-2"),
],
resources: [
.process("Resources/PublicEndpoints.json"),
]
),
.testTarget(
Expand Down
135 changes: 97 additions & 38 deletions README.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Sources/DGWControlPlane/ControlPlaneTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,8 @@ public enum DataGatewayClientError: Error, Sendable, Equatable {
case invalidConfiguration(String)
case alreadyInitialized(configURL: URL)
case notInitialized(configURL: URL)
case endpointsAlreadyInitialized(endpointsURL: URL)
case endpointsNotInitialized(endpointsURL: URL)
case invalidLocalFile(String)
case zeroByteFile
case ossFailed(httpStatus: Int?, ossCode: String?, message: String)
Expand Down
236 changes: 236 additions & 0 deletions Sources/DataGatewayClient/ArchebasePublicEndpoints.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import DGWControlPlane
import Foundation

/// Runtime store for Archebase public service endpoints.
public enum ArchebasePublicEndpoints {
package struct Resolved: Sendable, Equatable {
package var auth: URL
package var gateway: URL
package var deviceInit: URL
package var authTLS: TLSMode
package var gatewayTLS: TLSMode
package var deviceInitTLS: TLSMode
}

public static let endpointsFileName = "archebase-endpoints.json"

public static func defaultEndpointsURL() throws -> URL {
guard let applicationSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first else {
throw DataGatewayClientError.invalidConfiguration("application support directory is unavailable")
}

return applicationSupport
.appendingPathComponent("Archebase", isDirectory: true)
.appendingPathComponent(Self.endpointsFileName, isDirectory: false)
.standardizedFileURL
}

package static func decodeEndpoints(_ data: Data) throws -> Resolved {
do {
let payload = try JSONDecoder().decode(EndpointsPayload.self, from: data)
let auth = try payload.auth.resolvedURL(fieldName: "auth")
let gateway = try payload.gateway.resolvedURL(fieldName: "gateway")
let deviceInit = try payload.deviceInit.resolvedURL(fieldName: "deviceInit")
return Resolved(
auth: auth.url,
gateway: gateway.url,
deviceInit: deviceInit.url,
authTLS: auth.tls,
gatewayTLS: gateway.tls,
deviceInitTLS: deviceInit.tls
)
} catch let error as DataGatewayClientError {
throw error
} catch {
throw DataGatewayClientError.invalidConfiguration(
"failed to decode archebase endpoints: \(error.localizedDescription)"
)
}
}

package static func load(endpointsURL: URL) throws -> Resolved {
let resolvedURL = endpointsURL.standardizedFileURL
guard FileManager.default.fileExists(atPath: resolvedURL.path()) else {
throw DataGatewayClientError.endpointsNotInitialized(endpointsURL: resolvedURL)
}

do {
let data = try Data(contentsOf: resolvedURL)
return try Self.decodeEndpoints(data)
} catch let error as DataGatewayClientError {
throw error
} catch {
throw DataGatewayClientError.invalidConfiguration(
"failed to load archebase endpoints: \(error.localizedDescription)"
)
}
}

public static func initialize(endpointsJSON: String, endpointsURL: URL) throws {
guard !endpointsJSON.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw DataGatewayClientError.invalidConfiguration("archebase endpoints json must not be empty")
}

let data = Data(endpointsJSON.utf8)
let expected = try Self.decodeEndpoints(data)
let resolvedURL = endpointsURL.standardizedFileURL
let fileManager = FileManager.default

if fileManager.fileExists(atPath: resolvedURL.path()) {
let existing = try Self.load(endpointsURL: resolvedURL)
guard existing == expected else {
throw DataGatewayClientError.endpointsAlreadyInitialized(endpointsURL: resolvedURL)
}
return
}

try Self.atomicWrite(data, expected: expected, to: resolvedURL, fileManager: fileManager)
}

private static func atomicWrite(
_ data: Data,
expected: Resolved,
to endpointsURL: URL,
fileManager: FileManager
) throws {
let parent = endpointsURL.deletingLastPathComponent()
let tempURL = parent.appendingPathComponent(".\(endpointsURL.lastPathComponent).\(UUID().uuidString).tmp")

do {
try fileManager.createDirectory(at: parent, withIntermediateDirectories: true)
try Self.writeProtected(data, to: tempURL)
do {
try fileManager.moveItem(at: tempURL, to: endpointsURL)
} catch {
if fileManager.fileExists(atPath: endpointsURL.path()) {
let existing = try Self.load(endpointsURL: endpointsURL)
guard existing == expected else {
throw DataGatewayClientError.endpointsAlreadyInitialized(endpointsURL: endpointsURL)
}
return
}
throw error
}

let loaded = try Self.load(endpointsURL: endpointsURL)
guard loaded == expected else {
throw DataGatewayClientError.persistenceFailed("archebase endpoints verification failed after write")
}
} catch let error as DataGatewayClientError {
try? fileManager.removeItem(at: tempURL)
throw error
} catch {
try? fileManager.removeItem(at: tempURL)
throw DataGatewayClientError.persistenceFailed(
"failed to write archebase endpoints: \(error.localizedDescription)"
)
}
}

private static func writeProtected(_ data: Data, to url: URL) throws {
#if os(iOS)
try data.write(to: url, options: [.completeFileProtectionUnlessOpen])
#else
try data.write(to: url, options: [])
#endif
}
}

private struct EndpointsPayload: Decodable {
var auth: EndpointPayload
var gateway: EndpointPayload
var deviceInit: EndpointPayload
}

private struct EndpointPayload: Decodable {
private static let allowedFields: Set<String> = ["scheme", "host", "port"]

var scheme: String?
var host: String?
var port: Int?
var unsupportedFields: [String]
var hasLegacySchemaField: Bool

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: DynamicCodingKey.self)
let keys = container.allKeys.map(\.stringValue)
self.unsupportedFields = keys.filter { !Self.allowedFields.contains($0) }.sorted()
self.hasLegacySchemaField = keys.contains("schema")
self.scheme = try container.decodeIfPresent(String.self, forKey: DynamicCodingKey("scheme"))
self.host = try container.decodeIfPresent(String.self, forKey: DynamicCodingKey("host"))
self.port = try container.decodeIfPresent(Int.self, forKey: DynamicCodingKey("port"))
}

func resolvedURL(fieldName: String) throws -> (url: URL, tls: TLSMode) {
if self.hasLegacySchemaField {
throw DataGatewayClientError.invalidConfiguration("\(fieldName).schema is not supported; use scheme")
}

if let unsupportedField = self.unsupportedFields.first {
throw DataGatewayClientError.invalidConfiguration(
"\(fieldName) contains unsupported field '\(unsupportedField)'"
)
}

guard let scheme else {
throw DataGatewayClientError.invalidConfiguration("\(fieldName).scheme is required")
}
let normalizedScheme = scheme.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let tls: TLSMode
switch normalizedScheme {
case "http":
tls = .plaintext
case "https":
tls = .tls
default:
throw DataGatewayClientError.invalidConfiguration("\(fieldName).scheme must be http or https")
}

guard let host else {
throw DataGatewayClientError.invalidConfiguration("\(fieldName).host is required")
}
let normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedHost.isEmpty else {
throw DataGatewayClientError.invalidConfiguration("\(fieldName).host must not be empty")
}

guard let port else {
throw DataGatewayClientError.invalidConfiguration("\(fieldName).port is required")
}
guard (1 ... 65535).contains(port) else {
throw DataGatewayClientError.invalidConfiguration("\(fieldName).port must be between 1 and 65535")
}

var components = URLComponents()
components.scheme = normalizedScheme
components.host = normalizedHost
components.port = port
guard let url = components.url else {
throw DataGatewayClientError.invalidConfiguration("\(fieldName) endpoint is not a valid URL")
}

return (url, tls)
}
}

private struct DynamicCodingKey: CodingKey {
var stringValue: String
var intValue: Int?

init(_ stringValue: String) {
self.stringValue = stringValue
self.intValue = nil
}

init?(stringValue: String) {
self.init(stringValue)
}

init?(intValue: Int) {
self.stringValue = "\(intValue)"
self.intValue = intValue
}
}
Loading
Loading