Code Review / How would you have designed this? Config file for non-UI application

Hello,

I am currently writing an application in Swift which has no GUI. It is designed to run on a Raspberry Pi and control some real-world hardware via an electrical relay. It will be installed in a handlful of locations.

The application has certain global properties which I would like to be able to configure on a location-by-location basis and from time-to-time adjust without recompiling the application. e.g. Thresholds for logging levels.

I would be interested in learning what the best, and most 'Swifty' way to accomplish this would be. I have come-up with a design, but would like to know if there's a better way. (I'm sure that there is...)

My design:

Initially I had a very naĂŻve system where I hard-coded the properties / variables inline as I wrote the code. I think that this is known as 'magic numbers'. This is obviously bad.

I then refined this to place all these properties into a separate file (a struct, named 'AppConfiguration'), full of property declarations. That way I could replace the magic numbers with a global property named 'config' let config = AppConfiguration() and where I needed to refer to one of the properties using config.somePropertyName

This was better, but the obvious issue here was that the properties were all hard-coded at compile-time and immutable. I was just moving multiple problems to one single location.

Then I thought that it would be good to make the confg struct Codable. That way I could read and write it to the filesystem as JSON, swap-out config files and create a default config file.

That sounded fine, but I quickly realised that some of the properties I wanted to store in the config struct were of Types that I didn't control and did not conform to Codable (e.g. Logging Levels from the Puppy package.)

My Fix

At this point I got into problem-solving/hacking mode and decided that almost all of the types in my Config struct were codable, but the ones that weren't were Enums. I realised that I could ignore the enum values when (en/)coding (via codingKeys) and have stringly-typed (a Swift heresy) proxy properties which would then set the correct enum values via the RawValue: initialiser.

Code:

Below is the code for my AppConfig Struct. What do you think of it and how could it be implemented better?

Thanks.

//  AppConfiguration.swift

import Foundation
import Puppy
import SwiftyGPIO

//	Note: Logging is not available yet at this point of the app lifecycle…

public struct AppConfiguration: Codable {
	
	//	Ignore the enums that aren't codable
	enum CodingKeys: CodingKey {
		case logFolderPath
		case configFolderPath
		case beaconExpiryDuration
		
		case consoleLoggingLevelAsInt
		case piHardwareVersionAsString
		case relayControlSignalGPIOPinAsString
	}
	
	//	Stringly typed alternatives to the enum properties
	//    MARK: - Stringly typed enum properties -
	var consoleLoggingLevelAsInt: UInt8 = 4						//	Info
	var piHardwareVersionAsString = "RaspberryPi3"
	var relayControlSignalGPIOPinAsString = "P18"
	
	//    MARK: - Logging -
	//	From Puppy Package
	var consoleLoggingLevel: LogLevel = .info				//	Info level and more severe
	var logFolderPath: String = "./Logs"					//	in the same path as the executable.		Should really be /var/log/GateControl
	
	//    MARK: - Config Files location -
	var configFolderPath: String = "/etc/GateControl"		//	Standard UNIX path (but with wrong capitalisiation…)
	
	
	//    MARK: - Hardware Choices -
	//	From GPIO Package
	var piHardwareVersion: SupportedBoard = .RaspberryPi3	//	Assuming Pi 3 (Pi 4 shares the same numbering)
	var relayControlSignalGPIOPin: GPIOName = .P18			//	GPIO 18, base board number 12 (6th down on the right-hand-side column)
															//	(when the USB ports are at the bottom of the device)
															//	(and the pins are on the right side of the board.)
	
	//    MARK: - Beacon Scanner -
	var beaconExpiryDuration: Double = 30.0					//	Number of Seconds after which we assume that the beacon has left.
	
	
	//	MARK: - Functions -
	
	///	Prints the config as JSON - Useful for copying to a file.
	func spitOutJSON() {
		do {
			let encodedData = try JSONEncoder().encode(self)
			let jsonString = String(data: encodedData, encoding: .utf8)
			print(jsonString ?? "Could not encode JSON")
		} catch {
			print(error)
		}
	}
	
	
	mutating func loadConfigFromFilesystem() {
		var importedConfig: AppConfiguration
		let importPath = configFolderPath + "/config.json"
		do {
			if let importedJsonData = try String(contentsOfFile: importPath).data(using: .utf8) {
				importedConfig = try JSONDecoder().decode(AppConfiguration.self, from: importedJsonData)
			} else {
				print("AppConfig: could not convert string to JSON \(importPath)")
				return
			}
		} catch {
			print("AppConfig: Could not load config from JSON - \(importPath) - \(error)")
			return
		}
		importedConfig.convertStringPropertiesToEnumProperties()
		//	We should now have a valid config instance.

		//	Swap ourselves for the imported instance.
		self = importedConfig
	}
	
	mutating func convertStringPropertiesToEnumProperties() {

		/*
		
		 public enum LogLevel: UInt8, Sendable {
			 case trace      = 1
			 case verbose    = 2
			 case debug      = 3
			 case info       = 4
			 case notice     = 5
			 case warning    = 6
			 case error      = 7
			 case critical   = 8
		 }
		 
		 */
		
		consoleLoggingLevel = LogLevel(rawValue: consoleLoggingLevelAsInt)  ?? .info
		piHardwareVersion = SupportedBoard(rawValue: piHardwareVersionAsString) ?? .RaspberryPi3
		relayControlSignalGPIOPin = GPIOName(rawValue: relayControlSignalGPIOPinAsString) ?? .P18

	}

}

It is quite easy to make "external" enum types Codable (so long if you don't consider a crime conforming a type you don't own to a protocol you don't own). With this:

protocol RawCodable: RawRepresentable where RawValue: Codable {
    func encode(to encoder: Encoder) throws
    init(from decoder: Decoder) throws
}

enum RawCodableError: Error {
    case unknownRawValue
}

extension RawCodable {
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(rawValue)
    }
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let value = try container.decode(RawValue.self)
        guard let v = Self(rawValue: value) else {
            throw RawCodableError.unknownRawValue
        }
        self = v
    }
}

You just conform any RawRepresentable type whose RawValue is Codable:

extension LogLevel: RawCodable {}

If however you do consider conforming a type you don't own to a protocol you don't own a crime - you can create a generic wrapper struct:

struct CodableWrapper<Wrapped: RawRepresentable> : Codable where Wrapped.RawValue: Codable {
    let wrapped: Wrapped
    
    init(_ wrapped: Wrapped) {
        self.wrapped = wrapped
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let value = try container.decode(Wrapped.RawValue.self)
        guard let wrapped = Wrapped(rawValue: value) else {
            throw RawCodableError.unknownRawValue
        }
        self.init(wrapped)
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(wrapped.rawValue)
    }
}

And then:

var consoleLoggingLevel: CodableWrapper<LogLevel> = .init(.info)
    ...
    consoleLoggingLevel.wrapped = .info

You can further improve it by using a property wrapper to use "consoleLoggingLevel" instead of a longer "consoleLoggingLevel.wrapped".

1 Like

That’s very interesting. Thanks very much Tera.