Skip to content

Commit

Permalink
ios: implement Envoy builder & add tests (#343)
Browse files Browse the repository at this point in the history
Implements a new `EnvoyBuilder` type which consumers will now use to create new instances of `Envoy` going forward (rather than initializing it directly).

This supports the following using a builder pattern:
- Adding log levels
- Adding configuration options that were previously part of `Configuration`
- Specifying a YAML file override for consumers who may want to use a custom configuration instead of the default template
- Creating an instance of `Envoy` with these configurations using an underlying `EnvoyEngine` implementation
- Injecting mock engines so we don't start real instances of Envoy when testing

Example usage:

```swift
let envoy = try EnvoyBuilder()
  .addStatsFlushSeconds(30)
  .addDNSRefreshSeconds(30)
  .addLogLevel(.trace)
  .build()
```

Signed-off-by: Michael Rebello <[email protected]>
Signed-off-by: JP Simard <[email protected]>
  • Loading branch information
rebello95 authored and jpsim committed Nov 28, 2022
1 parent a4bb8c4 commit 64a7e6d
Show file tree
Hide file tree
Showing 9 changed files with 249 additions and 81 deletions.
2 changes: 1 addition & 1 deletion mobile/examples/objective-c/hello_world/AppDelegate.mm
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ - (BOOL)application:(UIApplication *)application
[self.window setRootViewController:controller];
[self.window makeKeyAndVisible];

self.envoy = [[Envoy alloc] initWithConfig:[Configuration new]];
self.envoy = [[EnvoyBuilder new] build];
NSLog(@"Finished launching!");
return YES;
}
Expand Down
2 changes: 1 addition & 1 deletion mobile/examples/swift/hello_world/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import UIKit

@UIApplicationMain
final class AppDelegate: UIResponder, UIApplicationDelegate {
private let envoy = try! Envoy()
private let envoy = try! EnvoyBuilder().build()

var window: UIWindow?

Expand Down
5 changes: 5 additions & 0 deletions mobile/library/objective-c/EnvoyEngine.h
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ typedef NSDictionary<NSString *, NSArray<NSString *> *> EnvoyHeaders;
/// Wrapper layer for calling into Envoy's C/++ API.
@protocol EnvoyEngine

/**
Create a new instance of the engine.
*/
- (instancetype)init;

/**
Run the Envoy engine with the provided config and log level.
Expand Down
2 changes: 1 addition & 1 deletion mobile/library/swift/src/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ swift_static_framework(
name = "ios_framework",
srcs = [
"Client.swift",
"Configuration.swift",
"Envoy.swift",
"EnvoyBuilder.swift",
"Error.swift",
"LogLevel.swift",
"Request.swift",
Expand Down
62 changes: 0 additions & 62 deletions mobile/library/swift/src/Configuration.swift

This file was deleted.

26 changes: 10 additions & 16 deletions mobile/library/swift/src/Envoy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import Foundation

@objcMembers
public final class Envoy: NSObject {
private let engine: EnvoyEngine = EnvoyEngineImpl()
private let runner: RunnerThread

/// Indicates whether this Envoy instance is currently active and running.
Expand All @@ -15,36 +14,31 @@ public final class Envoy: NSObject {
return self.runner.isFinished
}

/// Initialize a new Envoy instance using a typed configuration.
///
/// - parameter config: Configuration to use for starting Envoy.
/// - parameter logLevel: Log level to use for this instance.
public convenience init(config: Configuration = Configuration(), logLevel: LogLevel = .info) throws {
self.init(configYAML: try config.build(), logLevel: logLevel)
}

/// Initialize a new Envoy instance using a string configuration.
///
/// - parameter configYAML: Configuration YAML to use for starting Envoy.
/// - parameter logLevel: Log level to use for this instance.
public init(configYAML: String, logLevel: LogLevel = .info) {
self.runner = RunnerThread(config: configYAML, engine: self.engine, logLevel: logLevel)
/// - parameter engine: The underlying engine to use for starting Envoy.
init(configYAML: String, logLevel: LogLevel = .info, engine: EnvoyEngine) {
self.runner = RunnerThread(configYAML: configYAML, logLevel: logLevel, engine: engine)
self.runner.start()
}

// MARK: - Private

private final class RunnerThread: Thread {
private let engine: EnvoyEngine
private let config: String
private let configYAML: String
private let logLevel: LogLevel

init(config: String, engine: EnvoyEngine, logLevel: LogLevel) {
self.engine = engine
self.config = config
init(configYAML: String, logLevel: LogLevel, engine: EnvoyEngine) {
self.configYAML = configYAML
self.logLevel = logLevel
self.engine = engine
}

override func main() {
self.engine.run(withConfig: self.config, logLevel: self.logLevel.stringValue)
self.engine.run(withConfig: self.configYAML, logLevel: self.logLevel.stringValue)
}
}
}
124 changes: 124 additions & 0 deletions mobile/library/swift/src/EnvoyBuilder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import Foundation

/// Error that may be thrown by the Envoy builder.
@objc
public enum EnvoyBuilderError: Int, Swift.Error {
/// Not all keys within the provided template were resolved.
case unresolvedTemplateKey
}

/// Builder used for creating new instances of Envoy.
@objcMembers
public final class EnvoyBuilder: NSObject {
private var engineType: EnvoyEngine.Type = EnvoyEngineImpl.self
private var logLevel: LogLevel = .info
private var configYAML: String?

private var connectTimeoutSeconds: UInt = 30
private var dnsRefreshSeconds: UInt = 60
private var statsFlushSeconds: UInt = 60

// MARK: - Public

/// Add a log level to use with Envoy.
public func addLogLevel(_ logLevel: LogLevel) -> EnvoyBuilder {
self.logLevel = logLevel
return self
}

// MARK: - YAML configuration options

/// Add a YAML file to use as a configuration.
/// Setting this will supersede any other configuration settings in the builder.
@discardableResult
public func addConfigYAML(_ configYAML: String?) -> EnvoyBuilder {
self.configYAML = configYAML
return self
}

/// Add a timeout for new network connections to hosts in the cluster.
@discardableResult
public func addConnectTimeoutSeconds(_ connectTimeoutSeconds: UInt) -> EnvoyBuilder {
self.connectTimeoutSeconds = connectTimeoutSeconds
return self
}

/// Add a rate at which to refresh DNS.
@discardableResult
public func addDNSRefreshSeconds(_ dnsRefreshSeconds: UInt) -> EnvoyBuilder {
self.dnsRefreshSeconds = dnsRefreshSeconds
return self
}

/// Add an interval at which to flush Envoy stats.
@discardableResult
public func addStatsFlushSeconds(_ statsFlushSeconds: UInt) -> EnvoyBuilder {
self.statsFlushSeconds = statsFlushSeconds
return self
}

/// Builds a new instance of Envoy using the provided configurations.
///
/// - returns: A new instance of Envoy.
public func build() throws -> Envoy {
let engine = self.engineType.init()
let configYAML = try self.configYAML ?? self.resolvedYAML()
return Envoy(configYAML: configYAML, logLevel: self.logLevel, engine: engine)
}

// MARK: - Internal

/// Add a specific implementation of `EnvoyEngine` to use for starting Envoy.
/// A new instance of this engine will be created when `build()` is called.
/// Used for testing, as initializing with `EnvoyEngine.Type` results in a
/// segfault: https://github.com/lyft/envoy-mobile/issues/334
@discardableResult
func addEngineType(_ engineType: EnvoyEngine.Type) -> EnvoyBuilder {
self.engineType = engineType
return self
}

/// Processes the YAML template provided, replacing keys with values from the configuration.
///
/// - parameter template: The template YAML file to use.
///
/// - returns: A resolved YAML file with all template keys replaced.
func resolvedYAML(_ template: String = EnvoyConfiguration.templateString()) throws -> String {
var template = template
let templateKeysToValues: [String: String] = [
"connect_timeout": "\(self.connectTimeoutSeconds)s",
"dns_refresh_rate": "\(self.dnsRefreshSeconds)s",
"stats_flush_interval": "\(self.statsFlushSeconds)s",
]

for (templateKey, value) in templateKeysToValues {
while let range = template.range(of: "{{ \(templateKey) }}") {
template = template.replacingCharacters(in: range, with: value)
}
}

if template.contains("{{") {
throw EnvoyBuilderError.unresolvedTemplateKey
}

return template
}
}

// MARK: - Objective-C helpers

extension Envoy {
/// Convenience builder function to allow for cleaner Objective-C syntax.
///
/// For example:
///
/// Envoy *envoy = [EnvoyBuilder withBuild:^(EnvoyBuilder *builder) {
/// [builder addDNSRefreshSeconds:30];
/// }];
@objc
public static func with(build: (EnvoyBuilder) -> Void) throws -> Envoy {
let builder = EnvoyBuilder()
build(builder)
return try builder.build()
}
}
7 changes: 7 additions & 0 deletions mobile/library/swift/test/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ licenses(["notice"]) # Apache 2

load("//bazel:swift_test.bzl", "envoy_mobile_swift_test")

envoy_mobile_swift_test(
name = "envoy_builder_tests",
srcs = [
"EnvoyBuilderTests.swift",
],
)

envoy_mobile_swift_test(
name = "request_builder_tests",
srcs = [
Expand Down
100 changes: 100 additions & 0 deletions mobile/library/swift/test/EnvoyBuilderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
@testable import Envoy
import Foundation
import XCTest

private let kMockTemplate = """
mock_template:
- name: mock
connect_timeout: {{ connect_timeout }}
dns_refresh_rate: {{ dns_refresh_rate }}
stats_flush_interval: {{ stats_flush_interval }}
"""

private final class MockEnvoyEngine: NSObject, EnvoyEngine {
static var onRun: ((_ config: String, _ logLevel: String?) -> Void)?

func run(withConfig config: String) -> Int32 {
MockEnvoyEngine.onRun?(config, nil)
return 0
}

func run(withConfig config: String, logLevel: String) -> Int32 {
MockEnvoyEngine.onRun?(config, logLevel)
return 0
}

func setup() {}

func startStream(with observer: EnvoyObserver) -> EnvoyHTTPStream {
return EnvoyHTTPStream()
}
}

final class EnvoyBuilderTests: XCTestCase {
override func tearDown() {
super.tearDown()
MockEnvoyEngine.onRun = nil
}

func testAddingCustomConfigYAMLUsesSpecifiedYAMLWhenRunningEnvoy() throws {
let expectation = self.expectation(description: "Run called with expected data")
MockEnvoyEngine.onRun = { yaml, _ in
XCTAssertEqual("foobar", yaml)
expectation.fulfill()
}

_ = try EnvoyBuilder()
.addEngineType(MockEnvoyEngine.self)
.addConfigYAML("foobar")
.build()
self.waitForExpectations(timeout: 0.01)
}

func testAddingLogLevelAddsLogLevelWhenRunningEnvoy() throws {
let expectation = self.expectation(description: "Run called with expected data")
MockEnvoyEngine.onRun = { _, logLevel in
XCTAssertEqual("trace", logLevel)
expectation.fulfill()
}

_ = try EnvoyBuilder()
.addEngineType(MockEnvoyEngine.self)
.addLogLevel(.trace)
.build()
self.waitForExpectations(timeout: 0.01)
}

func testResolvesYAMLWithConnectTimeout() throws {
let resolvedYAML = try EnvoyBuilder()
.addEngineType(MockEnvoyEngine.self)
.addConnectTimeoutSeconds(200)
.resolvedYAML(kMockTemplate)

XCTAssertTrue(resolvedYAML.contains("connect_timeout: 200s"))
}

func testResolvesYAMLWithDNSRefreshSeconds() throws {
let resolvedYAML = try EnvoyBuilder()
.addEngineType(MockEnvoyEngine.self)
.addDNSRefreshSeconds(200)
.resolvedYAML(kMockTemplate)

XCTAssertTrue(resolvedYAML.contains("dns_refresh_rate: 200s"))
}

func testResolvesYAMLWithStatsFlushSeconds() throws {
let resolvedYAML = try EnvoyBuilder()
.addEngineType(MockEnvoyEngine.self)
.addStatsFlushSeconds(200)
.resolvedYAML(kMockTemplate)

XCTAssertTrue(resolvedYAML.contains("stats_flush_interval: 200s"))
}

func testThrowsWhenUnresolvedValueInTemplate() {
let builder = EnvoyBuilder().addEngineType(MockEnvoyEngine.self)
XCTAssertThrowsError(try builder.resolvedYAML("{{ missing }}")) { error in
XCTAssertEqual(.unresolvedTemplateKey, error as? EnvoyBuilderError)
}
}
}

0 comments on commit 64a7e6d

Please sign in to comment.