Loading a data structure with unknown types

I want to store the configuration of different local to remote connections (SFTP, FTP, ...) in a data structure. Therefore I have a struct Configuration which holds the two variables from: Connection and to: Connection. Connection is a protocol, which can be implemented by the different types of connections, for example SFTPConnection. So the variables from and to can store any connection object, which implements the Connection protocol.

To save Configuration objects, the struct needs to conform to the Codable protocol. This is difficult for example, when I would store multiple Configuration objects in a .plist file. When loading this file I need to know for each element of which type from and to are, in order to encode the data. Also in other use cases in my application, I will need some way to know, of which type my Connection objects are.

So I guessed I somehow need to store the type of from and to in the Configuration object. My solution looks like this (LocalConnection, SFTPConnection and FTPConnections are examples of structs, that implement Connection):

struct Configuration {
    var from: Connection
    var to: Connection
    private var id = UUID.init().uuidString
    
    var fromType: ConnectionType {
        if from is LocalConnection {
            return .local
        } else if from is SFTPConnection {
            return .sftp
        } else if from is FTPConnection {
            return .ftp
        } else {
            return .local
        }
    }
    var toType: ConnectionType {
        if to is LocalConnection {
            return .local
        } else if to is SFTPConnection {
            return .sftp
        } else if to is FTPConnection {
            return .ftp
        } else {
            return .local
        }
    }
}


protocol Connection: Codable {
    var path: String { get set }
}


enum ConnectionType: Int, Codable {
    case local
    case sftp
    case ftp
}


extension Configuration: Codable {
    
    enum CodingKeys: String, CodingKey {
        case from, to, id, fromType, toType
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        self.id = try container.decode(String.self, forKey: .id)
        let fromType = try container.decode(ConnectionType.self, forKey: .fromType)
        let toType = try container.decode(ConnectionType.self, forKey: .toType)
        
        switch fromType {
        case .local:
            self.from = try container.decode(LocalConnection.self, forKey: .from)
        case .sftp:
            self.from = try container.decode(SFTPConnection.self, forKey: .from)
        case .ftp:
            self.from = try container.decode(FTPConnection, forKey: .from)
        }
        
        switch toType {
        case .local:
            self.to = try container.decode(LocalConnection.self, forKey: .to)
        case .sftp:
            self.to = try container.decode(SFTPConnection.self, forKey: .to)
        case .ftp:
            self.to = try container.decode(FTPConnection, forKey: .to)
        }
    }
    
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        
        try container.encode(self.id, forKey: .id)
        try container.encode(self.fromType, forKey: .fromType)
        try container.encode(self.toType, forKey: .toType)
        
        switch fromType {
        case .local:
            try container.encode((from as! LocalConnection), forKey: .from)
        case .sftp:
            try container.encode((from as! SFTPConnection), forKey: .from)
        case .ftp:
            try container.encode((from as! FTPConnection), forKey: .from)
        }
        
        switch toType {
        case .local:
            try container.encode((to as! LocalConnection), forKey: .to)
        case .sftp:
            try container.encode((to as! SFTPConnection), forKey: .to)
        case .ftp:
            try container.encode((to as! FTPConnection), forKey: .to)
        }
    }
}

This should work, but it isn't an elegant solution. Since there are six different places, where I need to distinguish the concrete type of Connection, it's also not very good to maintain.

Is there a better, cleaner way to do this?

I came up with this:

import Foundation

protocol Connection {
	var path : String { get set }
	var type : ConnectionType { get }
	init(path: String)
}

struct LocalConnection : Connection {
	var path: String
	let type: ConnectionType = ConnectionType.local

	init(path: String) {
		self.path = path
	}
}

struct SFTPConnection : Connection {
	var path: String
	let type: ConnectionType = ConnectionType.sftp

	init(path: String) {
		self.path = path
	}
}

struct FTPConnection : Connection {
	var path: String
	let type: ConnectionType = ConnectionType.ftp

	init(path: String) {
		self.path = path
	}
}

struct TFTPConnection : Connection {
	var path: String
	let type: ConnectionType = ConnectionType.tftp

	init(path: String) {
		self.path = path
	}
}

enum ConnectionType : Int, Codable {
	case local
	case sftp
	case ftp
	case tftp

	func createConnection(path: String) -> Connection {
		switch self {
			case .local: return LocalConnection(path: path)
			case .sftp: return SFTPConnection(path: path)
			case .ftp: return FTPConnection(path: path)
			case .tftp: return TFTPConnection(path: path)
		}
	}
}


struct Configuration {
	var from : Connection
	var to : Connection
	private var id = UUID.init().uuidString

	var fromType : ConnectionType { return from.type }
	var fromPath : String { return from.path }
	var toType : ConnectionType { return to.type }
	var toPath : String { return to.path }
}

extension Configuration : Codable {

	enum CodingKeys: String, CodingKey {
		case id, fromType, fromPath, toType, toPath
	}

	init(from decoder: Decoder) throws {
		let container = try decoder.container(keyedBy: CodingKeys.self)

		self.id = try container.decode(String.self, forKey: .id)
		var type : ConnectionType = try container.decode(ConnectionType.self, forKey: .fromType)
		var path : String = try container.decode(String.self, forKey: .fromPath)
		self.from = type.createConnection(path: path)
		type = try container.decode(ConnectionType.self, forKey: .toType)
		path = try container.decode(String.self, forKey: .toPath)
		self.to = type.createConnection(path: path)
	}

	func encode(to encoder: Encoder) throws {
		var container = encoder.container(keyedBy: CodingKeys.self)

		try container.encode(self.id, forKey: .id)
		try container.encode(self.fromType, forKey: .fromType)
		try container.encode(self.fromPath, forKey: .fromPath)
		try container.encode(self.toType, forKey: .toType)
		try container.encode(self.toPath, forKey: .toPath)
	}
}

I'm still a novice myself, but, this centralized the switch in one place. I think it should be straightforward to add encoding/decoding if there is specific data for each connection type.

Thanks for you effort! This looks basically pretty good.

But what if I the implementations of SFTPConnection, FTPConnection, ... have different variables? For example

struct LocalConnection: Connection, Codable {
    var path: String
}

struct SFTPConnection: Connection, Codable {
    var path: String
    var user: String
    var sshKey: String
}

struct FTPConnection: Connection, Codable {
    var path: String
    var user: String
    var password: String
}

How would I do this in your solution?

Here is what I came up with. I also added specific initializers to the SFTP and FTP connections so you could initialize these connections outside of Coding.

import Foundation

protocol Connection {
	var path : String { get set }
	var type : ConnectionType { get }

	init(path: String)

	func encode(to encoder: Encoder) throws;
	mutating func decode(from decoder: Decoder) throws;
}

struct LocalConnection : Connection {
	let type: ConnectionType = ConnectionType.local

	var path: String

	init(path: String) {
		self.path = path
	}

	func encode(to encoder: Encoder) throws { }
	mutating func decode(from decoder: Decoder) throws { }
}

struct SFTPConnection : Connection {
	let type: ConnectionType = ConnectionType.sftp

	var path: String
	var user: String = String()
	var sshKey: String = String()

	init(path: String) {
		self.path = path
	}

	init(path: String, user: String, sshKey: String) {
		self.init(path: path)
		self.user = user
		self.sshKey = sshKey
	}

	enum CodingKeys: String, CodingKey {
		case user, sshKey
	}

	func encode(to encoder: Encoder) throws {
		var container = encoder.container(keyedBy: CodingKeys.self)
		try container.encode(self.user, forKey: .user)
		try container.encode(self.sshKey, forKey: .sshKey)
	}

	mutating func decode(from decoder: Decoder) throws {
		let container = try decoder.container(keyedBy: CodingKeys.self)
		user = try container.decode(String.self, forKey: .user)
		sshKey = try container.decode(String.self, forKey: .sshKey)
	}
}

struct FTPConnection : Connection {
	let type: ConnectionType = ConnectionType.ftp

	var path: String
	var user: String = String()
	var password : String = String()

	init(path: String) {
		self.path = path
	}

	init(path: String, user: String, password: String) {
		self.init(path: path)
		self.user = user
		self.password = password
	}

	enum CodingKeys: String, CodingKey {
		case user, password
	}

	func encode(to encoder: Encoder) throws {
		var container = encoder.container(keyedBy: CodingKeys.self)
		try container.encode(self.user, forKey: .user)
		try container.encode(self.password, forKey: .password)
	}

	mutating func decode(from decoder: Decoder) throws {
		let container = try decoder.container(keyedBy: CodingKeys.self)
		user = try container.decode(String.self, forKey: .user)
		password = try container.decode(String.self, forKey: .password)
	}
}

struct TFTPConnection : Connection {
	let type: ConnectionType = ConnectionType.tftp

	var path: String

	init(path: String) {
		self.path = path
	}

	func encode(to encoder: Encoder) throws { }
	mutating func decode(from decoder: Decoder) throws { }
}

enum ConnectionType : Int, Codable {
	case local
	case sftp
	case ftp
	case tftp

	func createConnection(path: String) -> Connection {
		switch self {
			case .local: return LocalConnection(path: path)
			case .sftp: return SFTPConnection(path: path)
			case .ftp: return FTPConnection(path: path)
			case .tftp: return TFTPConnection(path: path)
		}
	}
}


struct Configuration {
	var from : Connection
	var to : Connection
	private var id = UUID.init().uuidString

	var fromType : ConnectionType { return from.type }
	var fromPath : String { return from.path }

	var toType : ConnectionType { return to.type }
	var toPath : String { return to.path }
}

extension Configuration : Codable {

	enum CodingKeys: String, CodingKey {
		case id, fromType, fromPath, toType, toPath
	}

	init(from decoder: Decoder) throws {
		let container = try decoder.container(keyedBy: CodingKeys.self)

		self.id = try container.decode(String.self, forKey: .id)

		var type : ConnectionType
		var path : String

		type = try container.decode(ConnectionType.self, forKey: .fromType)
		path = try container.decode(String.self, forKey: .fromPath)
		self.from = type.createConnection(path: path)
		try self.from.decode(from: decoder)

		type = try container.decode(ConnectionType.self, forKey: .toType)
		path = try container.decode(String.self, forKey: .toPath)
		self.to = type.createConnection(path: path)
		try self.to.decode(from: decoder)
	}

	func encode(to encoder: Encoder) throws {
		var container = encoder.container(keyedBy: CodingKeys.self)

		try container.encode(self.id, forKey: .id)

		try container.encode(self.fromType, forKey: .fromType)
		try container.encode(self.fromPath, forKey: .fromPath)
		try from.encode(to: encoder)

		try container.encode(self.toType, forKey: .toType)
		try container.encode(self.toPath, forKey: .toPath)
		try to.encode(to: encoder)
	}
}

Thanks I really appreciate your effort. This seems to be exactly what I was looking for!

There is one simple issue, when two implementations of Connection have two properties with the same name (for example SFTPConnection.user and FTPConnection.user), and instances of these classes are in the same Configuration, then one user is overridden by the other one.

My simple fix for this would be, to just give the CodingKeys for each implementation a prefix. For example:

enum CodingKeys: String, CodingKey {
    case sftpUser, sftpSshKey
}

You would always have to be careful, when adding new implementations of Connection to not forget the prefix. Hence I thought about another idea: Make the encoding method return a dictionary, which can then be encoded from Configuration, so that the properties are kind of grouped and not interfering each other. But this would require all values of the dictionary to be of the same type, which could be impractical in future implementations.

So I will stick to the prefix method for now. Maybe you have another idea?

Thanks again for helping me out!

I finally have a solution, which best fits to my needs, using superDecoder and superEncoder:

import Foundation

protocol Connection: Codable {
    var type: ConnectionType { get }
    var path: String { get set }
}


struct LocalConnection: Connection {
    let type: ConnectionType = ConnectionType.local

    var path: String
}


struct SFTPConnection : Connection {
    let type: ConnectionType = ConnectionType.sftp

    var path: String
    var user: String
    var sshKey: String

    init(path: String, user: String, sshKey: String) {
        self.path = path
        self.user = user
        self.sshKey = sshKey
    }
}


struct FTPConnection: Connection {
    let type: ConnectionType = ConnectionType.ftp

    var path: String
    var user: String
    var password: String
}


struct TFTPConnection: Connection {
    let type: ConnectionType = ConnectionType.tftp

    var path: String
}




enum ConnectionType : Int, Codable {
    case local
    case sftp
    case ftp
    case tftp

    func getType() -> Connection.Type {
        switch self {
        case .local: return LocalConnection.self
        case .sftp: return SFTPConnection.self
        case .ftp: return FTPConnection.self
        case .tftp: return TFTPConnection.self
        }
    }
}




struct Configuration {
    var from : Connection
    var to : Connection
    private var id = UUID.init().uuidString

    var fromType : ConnectionType { return from.type }
    var toType : ConnectionType { return to.type }
    
    init(from: Connection, to: Connection) {
        self.from = from
        self.to = to
    }
}


extension Configuration : Codable {

    enum CodingKeys: String, CodingKey {
        case id, from, to, fromType, toType
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        self.id = try container.decode(String.self, forKey: .id)

        var type : ConnectionType
        
        type = try container.decode(ConnectionType.self, forKey: .fromType)
        let fromDecoder = try container.superDecoder(forKey: .from)
        self.from = try type.getType().init(from: fromDecoder)

        type = try container.decode(ConnectionType.self, forKey: .toType)
        let toDecoder = try container.superDecoder(forKey: .to)
        self.to = try type.getType().init(from: toDecoder)
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        
        try container.encode(self.id, forKey: .id)

        try container.encode(self.fromType, forKey: .fromType)
        let fromContainer = container.superEncoder(forKey: .from)
        try from.encode(to: fromContainer)

        try container.encode(self.toType, forKey: .toType)
        let toContainer = container.superEncoder(forKey: .to)
        try to.encode(to: toContainer)
    }
}