From 6e3bd043809aa3826d0ffdfd4d0d81f4ef6dfc7c Mon Sep 17 00:00:00 2001 From: Davide Tarantino Date: Thu, 6 May 2021 18:36:50 +0200 Subject: [PATCH 01/14] poc for middleware --- Sources/App/Environments/AppEnvironment.swift | 3 ++ .../StartAndStopServerButtonViewModel.swift | 2 +- Sources/Server/AppMiddleware.swift | 27 ++++++++++++ Sources/Server/AppServer.swift | 42 +++++++++++++++---- 4 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 Sources/Server/AppMiddleware.swift diff --git a/Sources/App/Environments/AppEnvironment.swift b/Sources/App/Environments/AppEnvironment.swift index 946af6ed..ee6e63f3 100644 --- a/Sources/App/Environments/AppEnvironment.swift +++ b/Sources/App/Environments/AppEnvironment.swift @@ -10,6 +10,9 @@ import SwiftUI final class AppEnvironment: ObservableObject { /// Whether the server is currently running. @Published var isServerRunning: Bool = false + + /// Whether the server is currently in record mode. + @Published var isServerRecording: Bool = false /// The selected app section, selected by using the app's Sidebar. @Published var selectedSection: SidebarSection = .server diff --git a/Sources/App/Views/Common/Buttons/StartAndStopServerButtonViewModel.swift b/Sources/App/Views/Common/Buttons/StartAndStopServerButtonViewModel.swift index 373208eb..2e4ca6af 100644 --- a/Sources/App/Views/Common/Buttons/StartAndStopServerButtonViewModel.swift +++ b/Sources/App/Views/Common/Buttons/StartAndStopServerButtonViewModel.swift @@ -22,7 +22,7 @@ final class StartAndStopServerButtonViewModel: ObservableObject { return } - try? appEnvironment.server.start(with: serverConfiguration) + try? appEnvironment.server.startRecording(with: serverConfiguration) } appEnvironment.isServerRunning.toggle() diff --git a/Sources/Server/AppMiddleware.swift b/Sources/Server/AppMiddleware.swift new file mode 100644 index 00000000..f0fb1fec --- /dev/null +++ b/Sources/Server/AppMiddleware.swift @@ -0,0 +1,27 @@ +// +// Mocka +// +import Combine +import Vapor + +final class AppMiddleware: Middleware { + let baseURL: URL + + init(baseURL: URL) { + self.baseURL = baseURL + } + + func respond(to request: Vapor.Request, chainingTo next: Responder) -> EventLoopFuture { + let requestURL = Vapor.URI(string: "https://ws-test.telepass.com\(request.url.path)") + let headers = request.headers.removing(name: "Host") + let clientRequest = ClientRequest(method: request.method, url: requestURL, headers: headers, body: request.body.data) + + return request.client.send(clientRequest) + .flatMap { clientResponse -> EventLoopFuture in + return clientResponse.encodeResponse(for: request) + } + .flatMapError { error in + return request.eventLoop.makeFailedFuture(error) + } + } +} diff --git a/Sources/Server/AppServer.swift b/Sources/Server/AppServer.swift index 129470bd..c05611a9 100644 --- a/Sources/Server/AppServer.swift +++ b/Sources/Server/AppServer.swift @@ -68,11 +68,8 @@ public class AppServer { } // MARK: - Methods - - /// Starts a new `Application` instance using the passed configuration. - /// - Parameter configuration: An object conforming to `ServerConfigurationProvider`. - /// - Throws: `ServerError.instanceAlreadyRunning` or a wrapped `Vapor` error. - public func start(with configuration: ServerConfigurationProvider) throws { + + public func startRecording(with configuration: ServerConnectionConfigurationProvider) throws { guard application == nil else { throw ServerError.instanceAlreadyRunning } @@ -88,15 +85,44 @@ public class AppServer { application?.logger = Logger(label: "Server Logger", factory: { _ in ConsoleLogHander(subject: consoleLogsSubject) }) application?.http.server.configuration.port = configuration.port application?.http.server.configuration.hostname = configuration.hostname - + application?.middleware.use(AppMiddleware(baseURL: URL(string: "ws-test.telepass.com")!)) + do { - registerRoutes(for: configuration.requests) try application?.server.start() } catch { // The most common error would be when we try to run the server on a PORT that is already used. throw ServerError.vapor(error: error) } } +// +// /// Starts a new `Application` instance using the passed configuration. +// /// - Parameter configuration: An object conforming to `ServerConfigurationProvider`. +// /// - Throws: `ServerError.instanceAlreadyRunning` or a wrapped `Vapor` error. +// public func start(with configuration: ServerConfigurationProvider) throws { +// guard application == nil else { +// throw ServerError.instanceAlreadyRunning +// } +// +// do { +// let environment = try Environment.detect() +// application = Application(environment) +// } catch { +// throw ServerError.vapor(error: error) +// } +// +// // Logger must be set at the beginning or it will result in missing the server start event. +// application?.logger = Logger(label: "Server Logger", factory: { _ in ConsoleLogHander(subject: consoleLogsSubject) }) +// application?.http.server.configuration.port = configuration.port +// application?.http.server.configuration.hostname = configuration.hostname +// +// do { +// registerRoutes(for: configuration.requests) +// try application?.server.start() +// } catch { +// // The most common error would be when we try to run the server on a PORT that is already used. +// throw ServerError.vapor(error: error) +// } +// } /// Shuts down the currently running instance of `Application`, if any. public func stop() throws { @@ -111,7 +137,7 @@ public class AppServer { /// - Throws: `ServerError.instanceAlreadyRunning` or a wrapped `Vapor` error. public func restart(with configuration: ServerConfigurationProvider) throws { try stop() - try start(with: configuration) + try startRecording(with: configuration) } /// Clears the buffered log events from the `consoleLogsSubject`. From d745c50eb89d8434376340804d86bdaa129034d6 Mon Sep 17 00:00:00 2001 From: Davide Tarantino Date: Thu, 13 May 2021 18:19:28 +0200 Subject: [PATCH 02/14] work in progress request save after network call in record mode --- Sources/App/Logic/RecordMode+Logic.swift | 5 + Sources/App/Mocka.swift | 2 +- Sources/App/Views/Sections/AppSection.swift | 11 ++- .../Views/Sections/AppSectionViewModel.swift | 98 +++++++++++++++++++ Sources/Server/AppMiddleware.swift | 43 +++++++- Sources/Server/AppServer.swift | 19 +++- 6 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 Sources/App/Logic/RecordMode+Logic.swift create mode 100644 Sources/App/Views/Sections/AppSectionViewModel.swift diff --git a/Sources/App/Logic/RecordMode+Logic.swift b/Sources/App/Logic/RecordMode+Logic.swift new file mode 100644 index 00000000..da04f62a --- /dev/null +++ b/Sources/App/Logic/RecordMode+Logic.swift @@ -0,0 +1,5 @@ +// +// Mocka +// + +import Foundation diff --git a/Sources/App/Mocka.swift b/Sources/App/Mocka.swift index e8636a44..4d01b267 100644 --- a/Sources/App/Mocka.swift +++ b/Sources/App/Mocka.swift @@ -14,7 +14,7 @@ struct Mocka: App { var body: some Scene { WindowGroup { - AppSection() + AppSection(viewModel: AppSectionViewModel(recordModeNetworkExchangesPublisher: appEnvironment.server.recordModeNetworkExchangesPublisher)) .frame( // Due to a bug of the `NavigationView` we cannot use the exactly minimum size. // We add `5` points to be sure to not close the sidebar while resizing the view. diff --git a/Sources/App/Views/Sections/AppSection.swift b/Sources/App/Views/Sections/AppSection.swift index 380cc9b1..5082dc97 100644 --- a/Sources/App/Views/Sections/AppSection.swift +++ b/Sources/App/Views/Sections/AppSection.swift @@ -2,6 +2,7 @@ // Mocka // +import MockaServer import SwiftUI /// This is the common app section used to show all the other sections. @@ -10,6 +11,9 @@ import SwiftUI struct AppSection: View { // MARK: - Stored Properties + + /// The associated ViewModel. + @ObservedObject var viewModel: AppSectionViewModel /// The app environment object. @EnvironmentObject var appEnvironment: AppEnvironment @@ -33,8 +37,13 @@ struct AppSection: View { // MARK: - Previews struct AppSectionPreview: PreviewProvider { + static let networkExchanges = [NetworkExchange]( + repeating: NetworkExchange.mock, + count: 10 + ) + static var previews: some View { - AppSection() + AppSection(viewModel: AppSectionViewModel(recordModeNetworkExchangesPublisher: networkExchanges.publisher.eraseToAnyPublisher())) .previewLayout(.fixed(width: 1024, height: 600)) .environmentObject(AppEnvironment()) } diff --git a/Sources/App/Views/Sections/AppSectionViewModel.swift b/Sources/App/Views/Sections/AppSectionViewModel.swift new file mode 100644 index 00000000..3cf31664 --- /dev/null +++ b/Sources/App/Views/Sections/AppSectionViewModel.swift @@ -0,0 +1,98 @@ +// +// Mocka +// + +import Combine +import Foundation +import MockaServer + +/// The ViewModel of the `AppSection`. +final class AppSectionViewModel: ObservableObject { + + // MARK: - Stored Properties + + /// The `Set` containing the list of subscriptions. + var subscriptions = Set() + + // MARK: - Init + + /// Creates a new instance with a `Publisher` of `NetworkExchange`s for the record mode. + /// - Parameter recordModeNetworkExchangesPublisher: The publisher of `NetworkExchange`s for the record mode. + init(recordModeNetworkExchangesPublisher: AnyPublisher) { + recordModeNetworkExchangesPublisher + .receive(on: RunLoop.main) + .sink { [weak self] _ in + + } + .store(in: &subscriptions) + } + + /// The user tapped the save button. + func createAndSaveRequest(from networkExchange: NetworkExchange) { + // The new created request. + let request = Request( + path: networkExchange.request.uri.path.components(separatedBy: "/"), + method: HTTPMethod(rawValue: networkExchange.request.httpMethod.rawValue)!, + expectedResponse: Response( + statusCode: Int(networkExchange.response.status.code), + contentType: networkExchange.response.headers.contentType!, + headers: networkExchange.response.headers.map { HTTPHeader(key: $0.name, value: $0.value) } + ) + ) + + let newRequestFolderName = Self.requestFolderName(request, requestName: networkExchange.request.uri.path) + + guard + currentRequest != nil, + let currentRequestFolder = currentRequestFolder, + let currentRequestParentFolder = currentRequestParentFolder + else { + // We are in create mode. + // Create new request folder. + try? Logic.SourceTree.addDirectory(at: selectedRequestParentFolder!.url, named: newRequestFolderName) + + // Add response, if any. + if displayedResponseBody.isNotEmpty, let expectedFileExtension = selectedContentType?.expectedFileExtension { + try? Logic.SourceTree.addResponse( + displayedResponseBody, + ofType: expectedFileExtension, + to: selectedRequestParentFolder!.url.appendingPathComponent(newRequestFolderName) + ) + } + + // Add request. + try? Logic.SourceTree.addRequest(request, to: selectedRequestParentFolder!.url.appendingPathComponent(newRequestFolderName)) + + return + } + + try? Logic.SourceTree.addDirectory(at: selectedRequestParentFolder!.url, named: newRequestFolderName) + // Delete old request folder. + try? Logic.SourceTree.deleteDirectory(at: currentRequestFolder.url.path) + + // Add response, if needed. + if displayedResponseBody.isNotEmpty, + let expectedFileExtension = selectedContentType?.expectedFileExtension, + let statusCode = Int(displayedStatusCode), + HTTPResponseStatus(statusCode: statusCode).mayHaveResponseBody + { + try? Logic.SourceTree.addResponse( + displayedResponseBody, + ofType: expectedFileExtension, + to: selectedRequestParentFolder!.url.appendingPathComponent(newRequestFolderName) + ) + } + + // Add request. + try? Logic.SourceTree.addRequest(request, to: selectedRequestParentFolder!.url.appendingPathComponent(newRequestFolderName)) + } + + /// Generates the name of the request folder. + /// - Parameters: + /// - request: The request we want to save. + /// - requestName: The custom name of the request. + /// - Returns: The name of the request folder. + static func requestFolderName(_ request: Request, requestName: String) -> String { + "\(request.method.rawValue) - \(requestName)" + } +} diff --git a/Sources/Server/AppMiddleware.swift b/Sources/Server/AppMiddleware.swift index f0fb1fec..f9ba4958 100644 --- a/Sources/Server/AppMiddleware.swift +++ b/Sources/Server/AppMiddleware.swift @@ -7,8 +7,14 @@ import Vapor final class AppMiddleware: Middleware { let baseURL: URL - init(baseURL: URL) { + let recordModeNetworkExchangesSubject: PassthroughSubject + + let configuration: ServerConnectionConfigurationProvider + + init(baseURL: URL, recordModeNetworkExchangesSubject: PassthroughSubject, configuration: ServerConnectionConfigurationProvider) { self.baseURL = baseURL + self.recordModeNetworkExchangesSubject = recordModeNetworkExchangesSubject + self.configuration = configuration } func respond(to request: Vapor.Request, chainingTo next: Responder) -> EventLoopFuture { @@ -17,11 +23,44 @@ final class AppMiddleware: Middleware { let clientRequest = ClientRequest(method: request.method, url: requestURL, headers: headers, body: request.body.data) return request.client.send(clientRequest) - .flatMap { clientResponse -> EventLoopFuture in + .flatMap { [weak self] clientResponse -> EventLoopFuture in + guard let self = self else { + return clientResponse.encodeResponse(for: request) + } + + let networkExchange = NetworkExchange( + request: DetailedRequest( + httpMethod: HTTPMethod(rawValue: request.method.rawValue)!, + uri: URI(scheme: URI.Scheme.http, host: self.configuration.hostname, port: self.configuration.port, path: request.url.path, query: request.url.query), + headers: request.headers, + body: self.body(from: request.body.data), + timestamp: Date().timeIntervalSince1970 + ), + response: DetailedResponse( + uri: URI(scheme: URI.Scheme.http, host: self.configuration.hostname, port: self.configuration.port, path: request.url.path), + headers: clientResponse.headers, + status: clientResponse.status, + body: self.body(from: clientResponse.body), + timestamp: Date().timeIntervalSince1970 + ) + ) + + self.recordModeNetworkExchangesSubject.send(networkExchange) return clientResponse.encodeResponse(for: request) } .flatMapError { error in return request.eventLoop.makeFailedFuture(error) } } + + /// Transforms readable bytes in the buffer to data. + /// - Parameter buffer: The `ByteBuffer` to read. + /// - Returns: `Data` read from the `ByteBuffer`. `nil` if `ByteBuffer` is `nil`. + private func body(from buffer: ByteBuffer?) -> Data? { + guard var bufferCopy = buffer else { + return nil + } + + return bufferCopy.readData(length: bufferCopy.readableBytes) + } } diff --git a/Sources/Server/AppServer.swift b/Sources/Server/AppServer.swift index c05611a9..6cf06497 100644 --- a/Sources/Server/AppServer.swift +++ b/Sources/Server/AppServer.swift @@ -24,6 +24,12 @@ public class AppServer { /// - Note: This property is marked `internal` to allow only the `Server` to send events. private let networkExchangesSubject = BufferedSubject() + /// The `PassthroughSubject` of `NetworkExchange`s for the record mode. + /// This subject is used to send and subscribe to `NetworkExchange`s. + /// Anytime a request/response exchange happens, a detailed version of the actors is generated and injected in this object. + /// - Note: This property is marked `internal` to allow only the `Server` to send events. + private let recordModeNetworkExchangesSubject = PassthroughSubject() + /// The `Set` containing the list of subscriptions. private var subscriptions = Set() @@ -38,6 +44,11 @@ public class AppServer { public var networkExchangesPublisher: AnyPublisher { networkExchangesSubject.eraseToAnyPublisher() } + + /// The `Publisher` of `NetworkExchange`s for the record mode. + public var recordModeNetworkExchangesPublisher: AnyPublisher { + recordModeNetworkExchangesSubject.eraseToAnyPublisher() + } /// The host associated with the running instance's configuration. internal var host: String? { @@ -85,7 +96,13 @@ public class AppServer { application?.logger = Logger(label: "Server Logger", factory: { _ in ConsoleLogHander(subject: consoleLogsSubject) }) application?.http.server.configuration.port = configuration.port application?.http.server.configuration.hostname = configuration.hostname - application?.middleware.use(AppMiddleware(baseURL: URL(string: "ws-test.telepass.com")!)) + application?.middleware.use( + AppMiddleware( + baseURL: URL(string: "ws-test.telepass.com")!, + recordModeNetworkExchangesSubject: recordModeNetworkExchangesSubject, + configuration: configuration + ) + ) do { try application?.server.start() From c3bdffc2aca18342d2d44e776eac6555c3db9864 Mon Sep 17 00:00:00 2001 From: Davide Tarantino Date: Thu, 20 May 2021 18:08:36 +0200 Subject: [PATCH 03/14] wip on request save --- .../Views/Sections/AppSectionViewModel.swift | 66 ++++++------------- 1 file changed, 19 insertions(+), 47 deletions(-) diff --git a/Sources/App/Views/Sections/AppSectionViewModel.swift b/Sources/App/Views/Sections/AppSectionViewModel.swift index 3cf31664..e1fd8da2 100644 --- a/Sources/App/Views/Sections/AppSectionViewModel.swift +++ b/Sources/App/Views/Sections/AppSectionViewModel.swift @@ -21,78 +21,50 @@ final class AppSectionViewModel: ObservableObject { init(recordModeNetworkExchangesPublisher: AnyPublisher) { recordModeNetworkExchangesPublisher .receive(on: RunLoop.main) - .sink { [weak self] _ in - + .sink { [weak self] networkExchange in + self?.createAndSaveRequest(from: networkExchange) } .store(in: &subscriptions) } /// The user tapped the save button. func createAndSaveRequest(from networkExchange: NetworkExchange) { - // The new created request. + // The newly created request. let request = Request( path: networkExchange.request.uri.path.components(separatedBy: "/"), method: HTTPMethod(rawValue: networkExchange.request.httpMethod.rawValue)!, expectedResponse: Response( statusCode: Int(networkExchange.response.status.code), - contentType: networkExchange.response.headers.contentType!, + contentType: networkExchange.response.headers.contentType ?? .applicationJSON, headers: networkExchange.response.headers.map { HTTPHeader(key: $0.name, value: $0.value) } ) ) + + let desktopDirectory = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask)[0] - let newRequestFolderName = Self.requestFolderName(request, requestName: networkExchange.request.uri.path) + try! Logic.SourceTree.addDirectory(at: desktopDirectory, named: Self.requestFolderName(request)) - guard - currentRequest != nil, - let currentRequestFolder = currentRequestFolder, - let currentRequestParentFolder = currentRequestParentFolder - else { - // We are in create mode. - // Create new request folder. - try? Logic.SourceTree.addDirectory(at: selectedRequestParentFolder!.url, named: newRequestFolderName) - - // Add response, if any. - if displayedResponseBody.isNotEmpty, let expectedFileExtension = selectedContentType?.expectedFileExtension { - try? Logic.SourceTree.addResponse( - displayedResponseBody, - ofType: expectedFileExtension, - to: selectedRequestParentFolder!.url.appendingPathComponent(newRequestFolderName) - ) - } - - // Add request. - try? Logic.SourceTree.addRequest(request, to: selectedRequestParentFolder!.url.appendingPathComponent(newRequestFolderName)) - - return - } - - try? Logic.SourceTree.addDirectory(at: selectedRequestParentFolder!.url, named: newRequestFolderName) - // Delete old request folder. - try? Logic.SourceTree.deleteDirectory(at: currentRequestFolder.url.path) - - // Add response, if needed. - if displayedResponseBody.isNotEmpty, - let expectedFileExtension = selectedContentType?.expectedFileExtension, - let statusCode = Int(displayedStatusCode), - HTTPResponseStatus(statusCode: statusCode).mayHaveResponseBody + // Add response, if any. + if + let responseBodyData = networkExchange.response.body, + let responseBody = String(data: responseBodyData, encoding: .utf8), + let expectedFileExtension = request.expectedResponse.contentType.expectedFileExtension { - try? Logic.SourceTree.addResponse( - displayedResponseBody, + try! Logic.SourceTree.addResponse( + responseBody, ofType: expectedFileExtension, - to: selectedRequestParentFolder!.url.appendingPathComponent(newRequestFolderName) + to: desktopDirectory.appendingPathComponent(Self.requestFolderName(request)) ) } // Add request. - try? Logic.SourceTree.addRequest(request, to: selectedRequestParentFolder!.url.appendingPathComponent(newRequestFolderName)) + try! Logic.SourceTree.addRequest(request, to: desktopDirectory.appendingPathComponent(Self.requestFolderName(request))) } /// Generates the name of the request folder. - /// - Parameters: - /// - request: The request we want to save. - /// - requestName: The custom name of the request. + /// - Parameter request: The request we want to save. /// - Returns: The name of the request folder. - static func requestFolderName(_ request: Request, requestName: String) -> String { - "\(request.method.rawValue) - \(requestName)" + static func requestFolderName(_ request: Request) -> String { + "\(request.method.rawValue) - \(request.path)" } } From 50ab01a38d5b7bc13bc3d61ee965266204d72bab Mon Sep 17 00:00:00 2001 From: Davide Tarantino Date: Thu, 27 May 2021 17:52:37 +0200 Subject: [PATCH 04/14] rename and document RecordingMiddleware --- Sources/Server/AppMiddleware.swift | 66 ------------------ Sources/Server/RecordingMiddleware.swift | 89 ++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 66 deletions(-) delete mode 100644 Sources/Server/AppMiddleware.swift create mode 100644 Sources/Server/RecordingMiddleware.swift diff --git a/Sources/Server/AppMiddleware.swift b/Sources/Server/AppMiddleware.swift deleted file mode 100644 index f9ba4958..00000000 --- a/Sources/Server/AppMiddleware.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// Mocka -// -import Combine -import Vapor - -final class AppMiddleware: Middleware { - let baseURL: URL - - let recordModeNetworkExchangesSubject: PassthroughSubject - - let configuration: ServerConnectionConfigurationProvider - - init(baseURL: URL, recordModeNetworkExchangesSubject: PassthroughSubject, configuration: ServerConnectionConfigurationProvider) { - self.baseURL = baseURL - self.recordModeNetworkExchangesSubject = recordModeNetworkExchangesSubject - self.configuration = configuration - } - - func respond(to request: Vapor.Request, chainingTo next: Responder) -> EventLoopFuture { - let requestURL = Vapor.URI(string: "https://ws-test.telepass.com\(request.url.path)") - let headers = request.headers.removing(name: "Host") - let clientRequest = ClientRequest(method: request.method, url: requestURL, headers: headers, body: request.body.data) - - return request.client.send(clientRequest) - .flatMap { [weak self] clientResponse -> EventLoopFuture in - guard let self = self else { - return clientResponse.encodeResponse(for: request) - } - - let networkExchange = NetworkExchange( - request: DetailedRequest( - httpMethod: HTTPMethod(rawValue: request.method.rawValue)!, - uri: URI(scheme: URI.Scheme.http, host: self.configuration.hostname, port: self.configuration.port, path: request.url.path, query: request.url.query), - headers: request.headers, - body: self.body(from: request.body.data), - timestamp: Date().timeIntervalSince1970 - ), - response: DetailedResponse( - uri: URI(scheme: URI.Scheme.http, host: self.configuration.hostname, port: self.configuration.port, path: request.url.path), - headers: clientResponse.headers, - status: clientResponse.status, - body: self.body(from: clientResponse.body), - timestamp: Date().timeIntervalSince1970 - ) - ) - - self.recordModeNetworkExchangesSubject.send(networkExchange) - return clientResponse.encodeResponse(for: request) - } - .flatMapError { error in - return request.eventLoop.makeFailedFuture(error) - } - } - - /// Transforms readable bytes in the buffer to data. - /// - Parameter buffer: The `ByteBuffer` to read. - /// - Returns: `Data` read from the `ByteBuffer`. `nil` if `ByteBuffer` is `nil`. - private func body(from buffer: ByteBuffer?) -> Data? { - guard var bufferCopy = buffer else { - return nil - } - - return bufferCopy.readData(length: bufferCopy.readableBytes) - } -} diff --git a/Sources/Server/RecordingMiddleware.swift b/Sources/Server/RecordingMiddleware.swift new file mode 100644 index 00000000..5461a5a4 --- /dev/null +++ b/Sources/Server/RecordingMiddleware.swift @@ -0,0 +1,89 @@ +// +// Mocka +// +import Combine +import Vapor + +/// The `Middleware` used in the record mode. +/// This class will act as a MITM to intercept network calls to localhost, +/// creating real requests whose response will be sent back to the caller. +final class RecordingMiddleware: Middleware { + /// The base `URL` that will be used instead of localhost when performing the real network calls. + let baseURL: URL + + /// The `PassthroughSubject` used to send the request and response pair back to the app. + let recordModeNetworkExchangesSubject: PassthroughSubject + + /// The configuration of the server. + let configuration: ServerConnectionConfigurationProvider + + /// Initializes the `RecordingMiddleware. + /// - Parameters: + /// - baseURL: The base `URL` to perform the real network calls. + /// - recordModeNetworkExchangesSubject: The `PassthroughSubject` used to send the request and response pair back to the app. + /// - configuration: The configuration of the server. + init(baseURL: URL, recordModeNetworkExchangesSubject: PassthroughSubject, configuration: ServerConnectionConfigurationProvider) { + self.baseURL = baseURL + self.recordModeNetworkExchangesSubject = recordModeNetworkExchangesSubject + self.configuration = configuration + } + + func respond(to request: Vapor.Request, chainingTo next: Responder) -> EventLoopFuture { + let requestURL = Vapor.URI(string: "https://ws-test.telepass.com\(request.url.path)") + let headers = request.headers.removing(name: "Host") + let clientRequest = ClientRequest(method: request.method, url: requestURL, headers: headers, body: request.body.data) + + return request.client.send(clientRequest) + .flatMap { [weak self] clientResponse -> EventLoopFuture in + guard let self = self else { + return clientResponse.encodeResponse(for: request) + } + + self.recordModeNetworkExchangesSubject.send(self.networkExchange(from: request, and: clientResponse)) + return clientResponse.encodeResponse(for: request) + } + .flatMapError { error in + return request.eventLoop.makeFailedFuture(error) + } + } +} + +// MARK: - Helpers + +private extension RecordingMiddleware { + /// Transforms readable bytes in the buffer to data. + /// - Parameter buffer: The `ByteBuffer` to read. + /// - Returns: `Data` read from the `ByteBuffer`. `nil` if `ByteBuffer` is `nil`. + func body(from buffer: ByteBuffer?) -> Data? { + guard var bufferCopy = buffer else { + return nil + } + + return bufferCopy.readData(length: bufferCopy.readableBytes) + } + + + /// Creates the `NetworkExchange` based on the `Request` and `ClientResponse` pair. + /// - Parameters: + /// - request: The `Vapor.Request` for the network call. + /// - clientResponse: The `ClientResponse` returned by the request, if any. + /// - Returns: The `NetworkExchange` to be sent back to the app. + private func networkExchange(from request: Vapor.Request, and clientResponse: ClientResponse) -> NetworkExchange { + NetworkExchange( + request: DetailedRequest( + httpMethod: HTTPMethod(rawValue: request.method.rawValue)!, + uri: URI(scheme: URI.Scheme.http, host: self.configuration.hostname, port: self.configuration.port, path: request.url.path, query: request.url.query), + headers: request.headers, + body: self.body(from: request.body.data), + timestamp: Date().timeIntervalSince1970 + ), + response: DetailedResponse( + uri: URI(scheme: URI.Scheme.http, host: self.configuration.hostname, port: self.configuration.port, path: request.url.path), + headers: clientResponse.headers, + status: clientResponse.status, + body: self.body(from: clientResponse.body), + timestamp: Date().timeIntervalSince1970 + ) + ) + } +} From b13caebe713d00e46c599ceebea1de019fbe320c Mon Sep 17 00:00:00 2001 From: Davide Tarantino Date: Thu, 27 May 2021 17:53:01 +0200 Subject: [PATCH 05/14] manage gzip compressed responses --- Sources/Server/AppServer.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Server/AppServer.swift b/Sources/Server/AppServer.swift index 6cf06497..d7184d93 100644 --- a/Sources/Server/AppServer.swift +++ b/Sources/Server/AppServer.swift @@ -96,8 +96,9 @@ public class AppServer { application?.logger = Logger(label: "Server Logger", factory: { _ in ConsoleLogHander(subject: consoleLogsSubject) }) application?.http.server.configuration.port = configuration.port application?.http.server.configuration.hostname = configuration.hostname + application?.http.client.configuration.decompression = .enabled(limit: .none) application?.middleware.use( - AppMiddleware( + RecordingMiddleware( baseURL: URL(string: "ws-test.telepass.com")!, recordModeNetworkExchangesSubject: recordModeNetworkExchangesSubject, configuration: configuration From be8b8930be0df15a962f173f68439e4179ffec00 Mon Sep 17 00:00:00 2001 From: Davide Tarantino Date: Thu, 27 May 2021 18:10:55 +0200 Subject: [PATCH 06/14] add response overwrite --- .../Views/Sections/AppSectionViewModel.swift | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/Sources/App/Views/Sections/AppSectionViewModel.swift b/Sources/App/Views/Sections/AppSectionViewModel.swift index e1fd8da2..5153c8cc 100644 --- a/Sources/App/Views/Sections/AppSectionViewModel.swift +++ b/Sources/App/Views/Sections/AppSectionViewModel.swift @@ -22,13 +22,18 @@ final class AppSectionViewModel: ObservableObject { recordModeNetworkExchangesPublisher .receive(on: RunLoop.main) .sink { [weak self] networkExchange in - self?.createAndSaveRequest(from: networkExchange) + #warning("Add proper values") + self?.createAndSaveRequest( + from: networkExchange, + to: FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask)[0], + shouldOverwriteResponse: true + ) } .store(in: &subscriptions) } /// The user tapped the save button. - func createAndSaveRequest(from networkExchange: NetworkExchange) { + func createAndSaveRequest(from networkExchange: NetworkExchange, to directory: URL, shouldOverwriteResponse: Bool) { // The newly created request. let request = Request( path: networkExchange.request.uri.path.components(separatedBy: "/"), @@ -40,9 +45,13 @@ final class AppSectionViewModel: ObservableObject { ) ) - let desktopDirectory = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask)[0] - - try! Logic.SourceTree.addDirectory(at: desktopDirectory, named: Self.requestFolderName(request)) + if Logic.SourceTree.contents(of: directory).isNotEmpty, shouldOverwriteResponse { + try? Logic.SourceTree.deleteDirectory(at: directory.absoluteString) + } else { + return + } + + try? Logic.SourceTree.addDirectory(at: directory, named: Self.requestFolderName(request)) // Add response, if any. if @@ -50,15 +59,15 @@ final class AppSectionViewModel: ObservableObject { let responseBody = String(data: responseBodyData, encoding: .utf8), let expectedFileExtension = request.expectedResponse.contentType.expectedFileExtension { - try! Logic.SourceTree.addResponse( + try? Logic.SourceTree.addResponse( responseBody, ofType: expectedFileExtension, - to: desktopDirectory.appendingPathComponent(Self.requestFolderName(request)) + to: directory.appendingPathComponent(Self.requestFolderName(request)) ) } // Add request. - try! Logic.SourceTree.addRequest(request, to: desktopDirectory.appendingPathComponent(Self.requestFolderName(request))) + try? Logic.SourceTree.addRequest(request, to: directory.appendingPathComponent(Self.requestFolderName(request))) } /// Generates the name of the request folder. From 35c33f4354ae14901a5e583c3f93b8688bbd9230 Mon Sep 17 00:00:00 2001 From: Davide Tarantino Date: Thu, 27 May 2021 18:33:03 +0200 Subject: [PATCH 07/14] code cleanup --- Sources/App/Models/Request.swift | 12 ++++ .../StartAndStopServerButtonViewModel.swift | 2 +- .../Views/Sections/AppSectionViewModel.swift | 20 +++--- Sources/Server/AppServer.swift | 70 ++++++++++--------- .../MiddlewareConfigurationProvider.swift | 11 +++ Sources/Server/RecordingMiddleware.swift | 21 +++--- 6 files changed, 76 insertions(+), 60 deletions(-) create mode 100644 Sources/Server/MiddlewareConfigurationProvider.swift diff --git a/Sources/App/Models/Request.swift b/Sources/App/Models/Request.swift index d4e7523c..8ca21ac6 100644 --- a/Sources/App/Models/Request.swift +++ b/Sources/App/Models/Request.swift @@ -34,6 +34,18 @@ struct Request: Equatable, Hashable { self.method = method self.expectedResponse = expectedResponse } + + /// Creates a `Request` object starting from a `NetworkExchange` object. + /// - Parameter networkExchange: The `NetworkExchange` object received from the server. + init(from networkExchange: NetworkExchange) { + path = networkExchange.request.uri.path.components(separatedBy: "/") + method = HTTPMethod(rawValue: networkExchange.request.httpMethod.rawValue)! + expectedResponse = Response( + statusCode: Int(networkExchange.response.status.code), + contentType: networkExchange.response.headers.contentType ?? .applicationJSON, + headers: networkExchange.response.headers.map { HTTPHeader(key: $0.name, value: $0.value) } + ) + } /// Converts a `MockaApp.Request` into a `MockaServer.Request`. /// - Parameters: diff --git a/Sources/App/Views/Common/Buttons/StartAndStopServerButtonViewModel.swift b/Sources/App/Views/Common/Buttons/StartAndStopServerButtonViewModel.swift index 2e4ca6af..373208eb 100644 --- a/Sources/App/Views/Common/Buttons/StartAndStopServerButtonViewModel.swift +++ b/Sources/App/Views/Common/Buttons/StartAndStopServerButtonViewModel.swift @@ -22,7 +22,7 @@ final class StartAndStopServerButtonViewModel: ObservableObject { return } - try? appEnvironment.server.startRecording(with: serverConfiguration) + try? appEnvironment.server.start(with: serverConfiguration) } appEnvironment.isServerRunning.toggle() diff --git a/Sources/App/Views/Sections/AppSectionViewModel.swift b/Sources/App/Views/Sections/AppSectionViewModel.swift index 5153c8cc..33593416 100644 --- a/Sources/App/Views/Sections/AppSectionViewModel.swift +++ b/Sources/App/Views/Sections/AppSectionViewModel.swift @@ -32,18 +32,14 @@ final class AppSectionViewModel: ObservableObject { .store(in: &subscriptions) } - /// The user tapped the save button. + /// Creates a request from the received `NetworkExchange` and saves it to the provided folder. + /// If the response is already present, it is overwritten or not based on the `shouldOverwriteResponse` parameter. + /// - Parameters: + /// - networkExchange: The received `NetworkExchange` object, that contains the request/response pair. + /// - directory: The directory where to save the request and response. + /// - shouldOverwriteResponse: Whether or not the request and response should be overwritten if already present. func createAndSaveRequest(from networkExchange: NetworkExchange, to directory: URL, shouldOverwriteResponse: Bool) { - // The newly created request. - let request = Request( - path: networkExchange.request.uri.path.components(separatedBy: "/"), - method: HTTPMethod(rawValue: networkExchange.request.httpMethod.rawValue)!, - expectedResponse: Response( - statusCode: Int(networkExchange.response.status.code), - contentType: networkExchange.response.headers.contentType ?? .applicationJSON, - headers: networkExchange.response.headers.map { HTTPHeader(key: $0.name, value: $0.value) } - ) - ) + let request = Request(from: networkExchange) if Logic.SourceTree.contents(of: directory).isNotEmpty, shouldOverwriteResponse { try? Logic.SourceTree.deleteDirectory(at: directory.absoluteString) @@ -73,7 +69,7 @@ final class AppSectionViewModel: ObservableObject { /// Generates the name of the request folder. /// - Parameter request: The request we want to save. /// - Returns: The name of the request folder. - static func requestFolderName(_ request: Request) -> String { + static private func requestFolderName(_ request: Request) -> String { "\(request.method.rawValue) - \(request.path)" } } diff --git a/Sources/Server/AppServer.swift b/Sources/Server/AppServer.swift index d7184d93..af436261 100644 --- a/Sources/Server/AppServer.swift +++ b/Sources/Server/AppServer.swift @@ -80,7 +80,10 @@ public class AppServer { // MARK: - Methods - public func startRecording(with configuration: ServerConnectionConfigurationProvider) throws { + /// Starts a new `Application` instance using the passed configuration and uses it to record network calls. + /// - Parameter configuration: An object conforming to `MiddlewareConfigurationProvider`. + /// - Throws: `ServerError.instanceAlreadyRunning` or a wrapped `Vapor` error. + public func startRecording(with configuration: MiddlewareConfigurationProvider) throws { guard application == nil else { throw ServerError.instanceAlreadyRunning } @@ -99,9 +102,8 @@ public class AppServer { application?.http.client.configuration.decompression = .enabled(limit: .none) application?.middleware.use( RecordingMiddleware( - baseURL: URL(string: "ws-test.telepass.com")!, - recordModeNetworkExchangesSubject: recordModeNetworkExchangesSubject, - configuration: configuration + configuration: configuration, + recordModeNetworkExchangesSubject: recordModeNetworkExchangesSubject ) ) @@ -112,35 +114,35 @@ public class AppServer { throw ServerError.vapor(error: error) } } -// -// /// Starts a new `Application` instance using the passed configuration. -// /// - Parameter configuration: An object conforming to `ServerConfigurationProvider`. -// /// - Throws: `ServerError.instanceAlreadyRunning` or a wrapped `Vapor` error. -// public func start(with configuration: ServerConfigurationProvider) throws { -// guard application == nil else { -// throw ServerError.instanceAlreadyRunning -// } -// -// do { -// let environment = try Environment.detect() -// application = Application(environment) -// } catch { -// throw ServerError.vapor(error: error) -// } -// -// // Logger must be set at the beginning or it will result in missing the server start event. -// application?.logger = Logger(label: "Server Logger", factory: { _ in ConsoleLogHander(subject: consoleLogsSubject) }) -// application?.http.server.configuration.port = configuration.port -// application?.http.server.configuration.hostname = configuration.hostname -// -// do { -// registerRoutes(for: configuration.requests) -// try application?.server.start() -// } catch { -// // The most common error would be when we try to run the server on a PORT that is already used. -// throw ServerError.vapor(error: error) -// } -// } + + /// Starts a new `Application` instance using the passed configuration. + /// - Parameter configuration: An object conforming to `ServerConfigurationProvider`. + /// - Throws: `ServerError.instanceAlreadyRunning` or a wrapped `Vapor` error. + public func start(with configuration: ServerConfigurationProvider) throws { + guard application == nil else { + throw ServerError.instanceAlreadyRunning + } + + do { + let environment = try Environment.detect() + application = Application(environment) + } catch { + throw ServerError.vapor(error: error) + } + + // Logger must be set at the beginning or it will result in missing the server start event. + application?.logger = Logger(label: "Server Logger", factory: { _ in ConsoleLogHander(subject: consoleLogsSubject) }) + application?.http.server.configuration.port = configuration.port + application?.http.server.configuration.hostname = configuration.hostname + + do { + registerRoutes(for: configuration.requests) + try application?.server.start() + } catch { + // The most common error would be when we try to run the server on a PORT that is already used. + throw ServerError.vapor(error: error) + } + } /// Shuts down the currently running instance of `Application`, if any. public func stop() throws { @@ -155,7 +157,7 @@ public class AppServer { /// - Throws: `ServerError.instanceAlreadyRunning` or a wrapped `Vapor` error. public func restart(with configuration: ServerConfigurationProvider) throws { try stop() - try startRecording(with: configuration) + try start(with: configuration) } /// Clears the buffered log events from the `consoleLogsSubject`. diff --git a/Sources/Server/MiddlewareConfigurationProvider.swift b/Sources/Server/MiddlewareConfigurationProvider.swift new file mode 100644 index 00000000..fe727e96 --- /dev/null +++ b/Sources/Server/MiddlewareConfigurationProvider.swift @@ -0,0 +1,11 @@ +// +// Mocka +// + +import Foundation + +/// An object containing the required configuration to run the middleware. +public protocol MiddlewareConfigurationProvider: ServerConnectionConfigurationProvider { + /// The base `URL` to be used instead of the local host to perform real network calls. + var baseURL: URL { get } +} diff --git a/Sources/Server/RecordingMiddleware.swift b/Sources/Server/RecordingMiddleware.swift index 5461a5a4..f7991798 100644 --- a/Sources/Server/RecordingMiddleware.swift +++ b/Sources/Server/RecordingMiddleware.swift @@ -8,28 +8,23 @@ import Vapor /// This class will act as a MITM to intercept network calls to localhost, /// creating real requests whose response will be sent back to the caller. final class RecordingMiddleware: Middleware { - /// The base `URL` that will be used instead of localhost when performing the real network calls. - let baseURL: URL - + /// The configuration of the middleware. + let configuration: MiddlewareConfigurationProvider + /// The `PassthroughSubject` used to send the request and response pair back to the app. let recordModeNetworkExchangesSubject: PassthroughSubject - - /// The configuration of the server. - let configuration: ServerConnectionConfigurationProvider - + /// Initializes the `RecordingMiddleware. /// - Parameters: - /// - baseURL: The base `URL` to perform the real network calls. + /// - configuration: The configuration of the middleware. /// - recordModeNetworkExchangesSubject: The `PassthroughSubject` used to send the request and response pair back to the app. - /// - configuration: The configuration of the server. - init(baseURL: URL, recordModeNetworkExchangesSubject: PassthroughSubject, configuration: ServerConnectionConfigurationProvider) { - self.baseURL = baseURL - self.recordModeNetworkExchangesSubject = recordModeNetworkExchangesSubject + init(configuration: MiddlewareConfigurationProvider,recordModeNetworkExchangesSubject: PassthroughSubject) { self.configuration = configuration + self.recordModeNetworkExchangesSubject = recordModeNetworkExchangesSubject } func respond(to request: Vapor.Request, chainingTo next: Responder) -> EventLoopFuture { - let requestURL = Vapor.URI(string: "https://ws-test.telepass.com\(request.url.path)") + let requestURL = Vapor.URI(string: configuration.baseURL.absoluteString + request.url.path) let headers = request.headers.removing(name: "Host") let clientRequest = ClientRequest(method: request.method, url: requestURL, headers: headers, body: request.body.data) From 93b5e4afafd2d19bb6b8f5e7d19d363eb9238c67 Mon Sep 17 00:00:00 2001 From: Davide Tarantino Date: Thu, 3 Jun 2021 18:11:06 +0200 Subject: [PATCH 08/14] UI work in progress --- Sources/App/Environments/AppEnvironment.swift | 6 ++ Sources/App/Logic/SourceTree+Logic.swift | 2 +- .../RecordMode/RecordModeSettings.swift | 97 +++++++++++++++++++ .../RecordModeSettingsViewModel.swift | 47 +++++++++ 4 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift create mode 100644 Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift diff --git a/Sources/App/Environments/AppEnvironment.swift b/Sources/App/Environments/AppEnvironment.swift index ee6e63f3..69cbaadb 100644 --- a/Sources/App/Environments/AppEnvironment.swift +++ b/Sources/App/Environments/AppEnvironment.swift @@ -13,6 +13,12 @@ final class AppEnvironment: ObservableObject { /// Whether the server is currently in record mode. @Published var isServerRecording: Bool = false + + /// The base `URL` to be used by the middleware when performing network calls in record mode. + @Published var middlewareBaseURL: URL? = nil + + /// The path where the recorded responses and requests will be saved in record mode. + @Published var selectedRecordingPath: URL? = nil /// The selected app section, selected by using the app's Sidebar. @Published var selectedSection: SidebarSection = .server diff --git a/Sources/App/Logic/SourceTree+Logic.swift b/Sources/App/Logic/SourceTree+Logic.swift index 75e3a74c..975eca0b 100644 --- a/Sources/App/Logic/SourceTree+Logic.swift +++ b/Sources/App/Logic/SourceTree+Logic.swift @@ -184,7 +184,7 @@ extension Logic.SourceTree { /// Enumerates the contents of a directory. /// - Parameter url: The `URL` of the directory to scan. /// - Returns: An array of `FileSystemNode` containing all sub-nodes of the directory. - private static func contents(of url: URL) -> [FileSystemNode] { + static func contents(of url: URL) -> [FileSystemNode] { guard let directoryEnumerator = FileManager.default.enumerator( at: url, diff --git a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift new file mode 100644 index 00000000..390a4d44 --- /dev/null +++ b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift @@ -0,0 +1,97 @@ +// +// Mocka +// + +import SwiftUI +import UniformTypeIdentifiers + +/// The startup settings view. +/// This view is shown in case the `workspaceURL` doesn't exist. +struct RecordModeSettings: View { + + // MARK: - Stored Properties + + /// The app environment object. + @EnvironmentObject var appEnvironment: AppEnvironment + + /// The associated ViewModel. + @StateObject var viewModel: RecordModeSettingsViewModel + + // MARK: - Body + + var body: some View { + VStack { + Text("Before starting the record mode, you need to choose a path where the requests and responses will be saved.\nYou also need to input the base URL that will be used to perform the network calls.") + .frame(height: 32) + .font(.body) + .padding(.vertical) + + VStack(alignment: .leading) { + HStack(alignment: .top) { + Text("Recording folder path") + .font(.headline) + .frame(width: 120, height: 30, alignment: .trailing) + + VStack { + RoundedTextField(title: "Recording folder path", text: $viewModel.recordingPath) + .frame(width: 300) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.redEye, lineWidth: viewModel.recordingPathError == nil ? 0 : 1) + ) + + Text("Please note that the selected folder must exist and it will not be automatically created.") + .font(.subheadline) + .frame(width: 300, height: 30) + .padding(.top, -6) + .foregroundColor(.macchiato) + } + + Button("Select folder") { + viewModel.fileImporterIsPresented.toggle() + } + .frame(height: 30) + .fileImporter( + isPresented: $viewModel.fileImporterIsPresented, + allowedContentTypes: [UTType.folder], + allowsMultipleSelection: false, + onCompletion: viewModel.selectFolder(with:) + ) + } + + HStack { + Text("Recording base URL") + .font(.headline) + .frame(width: 120, alignment: .trailing) + + RoundedTextField(title: "Recording base URL", text: $viewModel.middlewareBaseURL) + .frame(width: 300) + } + } + + VStack(alignment: .trailing) { + Button( + action: { +// viewModel.confirmSettings(with: presentationMode) + }, + label: { + Text("OK") + .frame(width: 100, height: 21) + } + ) + .buttonStyle(AccentButtonStyle()) + .padding(.horizontal) + .padding(.top) + } + } + .padding(25) + } +} + +// MARK: - Previews + +struct RecordModeSettingsPreview: PreviewProvider { + static var previews: some View { + RecordModeSettings(viewModel: RecordModeSettingsViewModel()) + } +} diff --git a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift new file mode 100644 index 00000000..96ed6d3f --- /dev/null +++ b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift @@ -0,0 +1,47 @@ +// +// Mocka +// + +import SwiftUI +import UniformTypeIdentifiers + +/// The ViewModel of the `ServerSettings`. +final class RecordModeSettingsViewModel: ObservableObject { + + // MARK: - Stored Properties + + /// The folder where the record mode requests will be saved. + @Published var recordingPath: String = "" { + didSet { + // When the user modifies the `recordingPath` we must remove any `recordingPathError` if present. + // This is needed in order to remove the red `RoundedRectangle` around the `RoundedTextField` of the "record mode folder" entry. + // In this way the red `RoundedRectangle` will be hidden while the user is editing the `recordingPath` in the entry. + recordingPathError = nil + } + } + + /// The base URL that will be passed to the middleware for the record mode to start. + @Published var middlewareBaseURL: String = "" + + /// Handle the workspace path error. + @Published var recordingPathError: MockaError? = nil + + /// Whether the `fileImporter` is presented. + @Published var fileImporterIsPresented: Bool = false + + // MARK: - Functions + + /// The `fileImporter` completion function. + /// This function is called once the user selected a folder. + /// It sets the path in case of success, and the error in case of error. + /// - Parameter result: The `Result` object from the `fileImporter` completion. + func selectFolder(with result: Result<[URL], Error>) { + guard let recordingFolder = Logic.Settings.selectFolder(from: result) else { + recordingPathError = .missingWorkspacePathValue + return + } + + self.recordingPath = recordingFolder + recordingPathError = nil + } +} From 023227f2976ee9b856dff5affdb74bbe231549db Mon Sep 17 00:00:00 2001 From: Davide Tarantino Date: Thu, 1 Jul 2021 18:32:27 +0200 Subject: [PATCH 09/14] wip on UI --- Sources/App/Environments/AppEnvironment.swift | 9 +++++ Sources/App/Helpers/SFSymbol.swift | 6 ++++ Sources/App/Helpers/Size.swift | 4 +-- Sources/App/Mocka.swift | 3 +- .../App/Models/MiddlewareConfiguration.swift | 34 +++++++++++++++++++ Sources/App/Resources/MockaApp.entitlements | 2 ++ .../StartAndStopRecordModeButton.swift | 31 +++++++++++++++++ ...tartAndStopRecordModeButtonViewModel.swift | 30 ++++++++++++++++ Sources/App/Views/Sections/AppSection.swift | 2 +- .../Views/Sections/AppSectionViewModel.swift | 18 ++++++++-- .../Sections/Server/List/ServerList.swift | 2 ++ .../Views/Sections/Settings/AppSettings.swift | 8 +++++ .../RecordMode/RecordModeSettings.swift | 6 ++-- .../RecordModeSettingsViewModel.swift | 24 +++++++++++++ Sources/Server/RecordingMiddleware.swift | 2 +- 15 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 Sources/App/Models/MiddlewareConfiguration.swift create mode 100644 Sources/App/Views/Common/Buttons/StartAndStopRecordModeButton.swift create mode 100644 Sources/App/Views/Common/Buttons/StartAndStopRecordModeButtonViewModel.swift diff --git a/Sources/App/Environments/AppEnvironment.swift b/Sources/App/Environments/AppEnvironment.swift index 69cbaadb..237d4e1d 100644 --- a/Sources/App/Environments/AppEnvironment.swift +++ b/Sources/App/Environments/AppEnvironment.swift @@ -33,4 +33,13 @@ final class AppEnvironment: ObservableObject { var serverConfiguration: ServerConfiguration? { Logic.Settings.serverConfiguration } + + /// The global record mode middleware configuration. + var middlewareConfiguration: MiddlewareConfiguration? { + guard let baseURL = middlewareBaseURL, let hostname = serverConfiguration?.hostname, let port = serverConfiguration?.port else { + return nil + } + + return MiddlewareConfiguration(baseURL: baseURL, hostname: hostname, port: port) + } } diff --git a/Sources/App/Helpers/SFSymbol.swift b/Sources/App/Helpers/SFSymbol.swift index f38f1916..79d27047 100644 --- a/Sources/App/Helpers/SFSymbol.swift +++ b/Sources/App/Helpers/SFSymbol.swift @@ -23,6 +23,12 @@ enum SFSymbol: String { /// Plus circle icon. case plusCircle = "plus.circle" + + /// Start recording icon. + case startRecording = "record.circle" + + /// Stop recording icon. + case stopRecording = "record.circle.fill" /// Refresh icon. case refresh = "arrow.triangle.2.circlepath" diff --git a/Sources/App/Helpers/Size.swift b/Sources/App/Helpers/Size.swift index 1dcae107..1de91281 100644 --- a/Sources/App/Helpers/Size.swift +++ b/Sources/App/Helpers/Size.swift @@ -16,14 +16,14 @@ enum Size { static let minimumAppHeight: CGFloat = 600 /// Minimum List width. - static let minimumListWidth: CGFloat = 370 + static let minimumListWidth: CGFloat = 380 /// Minimum Detail width. static let minimumDetailWidth: CGFloat = 400 /// Minimum Filter text field width. static var minimumFilterTextFieldWidth: CGFloat { - minimumListWidth - 140 + minimumListWidth - 160 } /// Minimum App section width. diff --git a/Sources/App/Mocka.swift b/Sources/App/Mocka.swift index 4d01b267..3a897199 100644 --- a/Sources/App/Mocka.swift +++ b/Sources/App/Mocka.swift @@ -14,7 +14,7 @@ struct Mocka: App { var body: some Scene { WindowGroup { - AppSection(viewModel: AppSectionViewModel(recordModeNetworkExchangesPublisher: appEnvironment.server.recordModeNetworkExchangesPublisher)) + AppSection(viewModel: AppSectionViewModel(recordModeNetworkExchangesPublisher: appEnvironment.server.recordModeNetworkExchangesPublisher, appEnvironment: appEnvironment)) .frame( // Due to a bug of the `NavigationView` we cannot use the exactly minimum size. // We add `5` points to be sure to not close the sidebar while resizing the view. @@ -40,6 +40,7 @@ struct Mocka: App { Settings { AppSettings() + .environmentObject(appEnvironment) } } } diff --git a/Sources/App/Models/MiddlewareConfiguration.swift b/Sources/App/Models/MiddlewareConfiguration.swift new file mode 100644 index 00000000..180c73c4 --- /dev/null +++ b/Sources/App/Models/MiddlewareConfiguration.swift @@ -0,0 +1,34 @@ +// +// Mocka +// + +import Foundation +import MockaServer + +/// An object containing the parameters needed to configure the server's connection. +struct MiddlewareConfiguration: MiddlewareConfigurationProvider, Codable { + /// The base `URL` on which the middleware will start requests. + var baseURL: URL + + /// The host part of the `URL`. + let hostname: String + + /// The port listening to incoming requests. + let port: Int + + /// Creates a new `ServerConnectionConfiguration` object. + /// - Parameters: + /// - baseURL: The base `URL` on which the middleware will start requests. + /// - hostname: The host part of the `URL`. + /// - port: The port listening to incoming requests. + init( + baseURL: URL, + hostname: String, + port: Int + ) { + self.baseURL = baseURL + self.hostname = hostname + self.port = port + } +} + diff --git a/Sources/App/Resources/MockaApp.entitlements b/Sources/App/Resources/MockaApp.entitlements index bc29a693..ce411892 100644 --- a/Sources/App/Resources/MockaApp.entitlements +++ b/Sources/App/Resources/MockaApp.entitlements @@ -8,6 +8,8 @@ com.apple.security.files.user-selected.read-write + com.apple.security.network.client + com.apple.security.network.server diff --git a/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButton.swift b/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButton.swift new file mode 100644 index 00000000..dfb0eac8 --- /dev/null +++ b/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButton.swift @@ -0,0 +1,31 @@ +// +// Mocka +// + +import SwiftUI + +/// The start and stop record mode button. +/// This button automatically handles the start and stop of the record mode +/// by using the `AppEnvironment`. +struct StartAndStopRecordModeButton: View { + + // MARK: - Stored Properties + + /// The app environment object. + @EnvironmentObject var appEnvironment: AppEnvironment + + /// The associated ViewModel. + @StateObject var viewModel: StartAndStopRecordModeButtonViewModel = StartAndStopRecordModeButtonViewModel() + + // MARK: - Body + + var body: some View { + SymbolButton( + symbolName: appEnvironment.isServerRecording ? .stopRecording : .startRecording, + action: { + viewModel.startAndStopRecordMode(on: appEnvironment) + } + ) + .disabled(appEnvironment.middlewareConfiguration == nil) + } +} diff --git a/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButtonViewModel.swift b/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButtonViewModel.swift new file mode 100644 index 00000000..c173bbdc --- /dev/null +++ b/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButtonViewModel.swift @@ -0,0 +1,30 @@ +// +// Mocka +// + +import Foundation + +/// The ViewModel of the `ServerToolbar`. +final class StartAndStopRecordModeButtonViewModel: ObservableObject { + + // MARK: - Functions + + /// Start and stop the record mode. + /// - Parameter appEnvironment: The `AppEnvironment` instance. + func startAndStopRecordMode(on appEnvironment: AppEnvironment) { + switch appEnvironment.isServerRecording { + case true: + try? appEnvironment.server.stop() + + case false: + guard let middlewareConfiguration = appEnvironment.middlewareConfiguration else { + return + } + + try? appEnvironment.server.startRecording(with: middlewareConfiguration) + } + + appEnvironment.isServerRunning.toggle() + appEnvironment.isServerRecording.toggle() + } +} diff --git a/Sources/App/Views/Sections/AppSection.swift b/Sources/App/Views/Sections/AppSection.swift index bb840fa2..ff8d1998 100644 --- a/Sources/App/Views/Sections/AppSection.swift +++ b/Sources/App/Views/Sections/AppSection.swift @@ -44,7 +44,7 @@ struct AppSectionPreview: PreviewProvider { ) static var previews: some View { - AppSection(viewModel: AppSectionViewModel(recordModeNetworkExchangesPublisher: networkExchanges.publisher.eraseToAnyPublisher())) + AppSection(viewModel: AppSectionViewModel(recordModeNetworkExchangesPublisher: networkExchanges.publisher.eraseToAnyPublisher(), appEnvironment: AppEnvironment())) .previewLayout(.fixed(width: 1024, height: 600)) .environmentObject(AppEnvironment()) } diff --git a/Sources/App/Views/Sections/AppSectionViewModel.swift b/Sources/App/Views/Sections/AppSectionViewModel.swift index 33593416..362403fc 100644 --- a/Sources/App/Views/Sections/AppSectionViewModel.swift +++ b/Sources/App/Views/Sections/AppSectionViewModel.swift @@ -13,19 +13,33 @@ final class AppSectionViewModel: ObservableObject { /// The `Set` containing the list of subscriptions. var subscriptions = Set() + + var appEnvironment: AppEnvironment + + /// The path for the request and response to save in the record mode. + var recordingPath: URL? { + appEnvironment.selectedRecordingPath + } // MARK: - Init /// Creates a new instance with a `Publisher` of `NetworkExchange`s for the record mode. /// - Parameter recordModeNetworkExchangesPublisher: The publisher of `NetworkExchange`s for the record mode. - init(recordModeNetworkExchangesPublisher: AnyPublisher) { + init(recordModeNetworkExchangesPublisher: AnyPublisher, appEnvironment: AppEnvironment) { + self.appEnvironment = appEnvironment + recordModeNetworkExchangesPublisher .receive(on: RunLoop.main) .sink { [weak self] networkExchange in + guard let recordingPath = self?.recordingPath else { + print("ops") + return + } + #warning("Add proper values") self?.createAndSaveRequest( from: networkExchange, - to: FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask)[0], + to: recordingPath, shouldOverwriteResponse: true ) } diff --git a/Sources/App/Views/Sections/Server/List/ServerList.swift b/Sources/App/Views/Sections/Server/List/ServerList.swift index ae460db0..401065ad 100644 --- a/Sources/App/Views/Sections/Server/List/ServerList.swift +++ b/Sources/App/Views/Sections/Server/List/ServerList.swift @@ -68,6 +68,8 @@ struct ServerList: View { viewModel.clearNetworkExchanges() } ) + + StartAndStopRecordModeButton() } } } diff --git a/Sources/App/Views/Sections/Settings/AppSettings.swift b/Sources/App/Views/Sections/Settings/AppSettings.swift index f375c4bf..a03820e2 100644 --- a/Sources/App/Views/Sections/Settings/AppSettings.swift +++ b/Sources/App/Views/Sections/Settings/AppSettings.swift @@ -7,6 +7,9 @@ import SwiftUI /// This is the main app settings `Settings`. struct AppSettings: View { + /// The app environment object. + @EnvironmentObject var appEnvironment: AppEnvironment + // MARK: - Body var body: some View { @@ -15,6 +18,11 @@ struct AppSettings: View { .tabItem { Label("Server", systemImage: SFSymbol.document.rawValue) } + + RecordModeSettings(viewModel: RecordModeSettingsViewModel()) + .tabItem { + Label("Record mode", systemImage: SFSymbol.startRecording.rawValue) + } } } } diff --git a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift index 390a4d44..86d24d73 100644 --- a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift +++ b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift @@ -22,7 +22,7 @@ struct RecordModeSettings: View { var body: some View { VStack { Text("Before starting the record mode, you need to choose a path where the requests and responses will be saved.\nYou also need to input the base URL that will be used to perform the network calls.") - .frame(height: 32) + .frame(height: 50) .font(.body) .padding(.vertical) @@ -30,7 +30,7 @@ struct RecordModeSettings: View { HStack(alignment: .top) { Text("Recording folder path") .font(.headline) - .frame(width: 120, height: 30, alignment: .trailing) + .frame(width: 120, alignment: .trailing) VStack { RoundedTextField(title: "Recording folder path", text: $viewModel.recordingPath) @@ -72,7 +72,7 @@ struct RecordModeSettings: View { VStack(alignment: .trailing) { Button( action: { -// viewModel.confirmSettings(with: presentationMode) + viewModel.confirmSettings(with: appEnvironment) }, label: { Text("OK") diff --git a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift index 96ed6d3f..915b0940 100644 --- a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift +++ b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift @@ -44,4 +44,28 @@ final class RecordModeSettingsViewModel: ObservableObject { self.recordingPath = recordingFolder recordingPathError = nil } + + /// Confirms the selected startup settings + /// by creating the configuration file in the right path. + /// In case of error the `workspaceURL` returns to `nil`. + /// - Parameter presentationMode: The `View` `PresentationMode`. + func confirmSettings(with appEnvironment: AppEnvironment) { + let recordingURL = URL(fileURLWithPath: recordingPath) + let middlewareURL = URL(string: middlewareBaseURL) + + do { + try Logic.WorkspacePath.checkURLAndCreateFolderIfNeeded(at: recordingURL) + + appEnvironment.middlewareBaseURL = middlewareURL + appEnvironment.selectedRecordingPath = recordingURL + + NSApplication.shared.keyWindow?.close() + } catch { + guard let recordingPathError = error as? MockaError else { + return + } + + self.recordingPathError = recordingPathError + } + } } diff --git a/Sources/Server/RecordingMiddleware.swift b/Sources/Server/RecordingMiddleware.swift index f7991798..b7b75e25 100644 --- a/Sources/Server/RecordingMiddleware.swift +++ b/Sources/Server/RecordingMiddleware.swift @@ -18,7 +18,7 @@ final class RecordingMiddleware: Middleware { /// - Parameters: /// - configuration: The configuration of the middleware. /// - recordModeNetworkExchangesSubject: The `PassthroughSubject` used to send the request and response pair back to the app. - init(configuration: MiddlewareConfigurationProvider,recordModeNetworkExchangesSubject: PassthroughSubject) { + init(configuration: MiddlewareConfigurationProvider, recordModeNetworkExchangesSubject: PassthroughSubject) { self.configuration = configuration self.recordModeNetworkExchangesSubject = recordModeNetworkExchangesSubject } From 829538f70f21466ac5cf6b859a7af9ca9b015f4f Mon Sep 17 00:00:00 2001 From: Davide Tarantino Date: Thu, 8 Jul 2021 17:52:17 +0200 Subject: [PATCH 10/14] fix --- .../Views/Sections/AppSectionViewModel.swift | 20 +++++++++++-------- Sources/Server/RecordingMiddleware.swift | 12 +++++++---- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/Sources/App/Views/Sections/AppSectionViewModel.swift b/Sources/App/Views/Sections/AppSectionViewModel.swift index 362403fc..02100a23 100644 --- a/Sources/App/Views/Sections/AppSectionViewModel.swift +++ b/Sources/App/Views/Sections/AppSectionViewModel.swift @@ -54,14 +54,18 @@ final class AppSectionViewModel: ObservableObject { /// - shouldOverwriteResponse: Whether or not the request and response should be overwritten if already present. func createAndSaveRequest(from networkExchange: NetworkExchange, to directory: URL, shouldOverwriteResponse: Bool) { let request = Request(from: networkExchange) + let requestDirectoryName = Self.requestDirectoryName(request) + let requestDirectory = directory.appendingPathComponent(requestDirectoryName) - if Logic.SourceTree.contents(of: directory).isNotEmpty, shouldOverwriteResponse { - try? Logic.SourceTree.deleteDirectory(at: directory.absoluteString) - } else { - return + if Logic.SourceTree.contents(of: requestDirectory).isNotEmpty { + if shouldOverwriteResponse { + try? Logic.SourceTree.deleteDirectory(at: requestDirectoryName) + } else { + return + } } - try? Logic.SourceTree.addDirectory(at: directory, named: Self.requestFolderName(request)) + try? Logic.SourceTree.addDirectory(at: directory, named: requestDirectoryName) // Add response, if any. if @@ -72,18 +76,18 @@ final class AppSectionViewModel: ObservableObject { try? Logic.SourceTree.addResponse( responseBody, ofType: expectedFileExtension, - to: directory.appendingPathComponent(Self.requestFolderName(request)) + to: directory.appendingPathComponent(requestDirectoryName) ) } // Add request. - try? Logic.SourceTree.addRequest(request, to: directory.appendingPathComponent(Self.requestFolderName(request))) + try? Logic.SourceTree.addRequest(request, to: requestDirectory) } /// Generates the name of the request folder. /// - Parameter request: The request we want to save. /// - Returns: The name of the request folder. - static private func requestFolderName(_ request: Request) -> String { + static private func requestDirectoryName(_ request: Request) -> String { "\(request.method.rawValue) - \(request.path)" } } diff --git a/Sources/Server/RecordingMiddleware.swift b/Sources/Server/RecordingMiddleware.swift index b7b75e25..f87d188e 100644 --- a/Sources/Server/RecordingMiddleware.swift +++ b/Sources/Server/RecordingMiddleware.swift @@ -30,12 +30,16 @@ final class RecordingMiddleware: Middleware { return request.client.send(clientRequest) .flatMap { [weak self] clientResponse -> EventLoopFuture in + var response = clientResponse + guard let self = self else { - return clientResponse.encodeResponse(for: request) + return response.encodeResponse(for: request) } - - self.recordModeNetworkExchangesSubject.send(self.networkExchange(from: request, and: clientResponse)) - return clientResponse.encodeResponse(for: request) + + response.headers = clientResponse.headers.removing(name: "Content-Encoding") + + self.recordModeNetworkExchangesSubject.send(self.networkExchange(from: request, and: response)) + return response.encodeResponse(for: request) } .flatMapError { error in return request.eventLoop.makeFailedFuture(error) From 210ebcc4d20b9eb9c10ac5531f5cf9293b753ab4 Mon Sep 17 00:00:00 2001 From: Davide Tarantino Date: Thu, 15 Jul 2021 17:56:51 +0200 Subject: [PATCH 11/14] improve and clean code --- Sources/App/Environments/AppEnvironment.swift | 3 ++ Sources/App/Helpers/SFSymbol.swift | 3 -- Sources/App/Mocka.swift | 7 +++++ .../StartAndStopRecordModeButton.swift | 10 +++++-- ...tartAndStopRecordModeButtonViewModel.swift | 14 ++++----- .../Views/Common/Buttons/SymbolButton.swift | 6 +++- .../Editor/SourceTree/SourceTree.swift | 2 ++ .../Sections/Server/List/ServerList.swift | 2 -- .../Views/Sections/Settings/AppSettings.swift | 5 ---- .../RecordMode/RecordModeSettings.swift | 6 ++-- .../RecordModeSettingsViewModel.swift | 30 +++++++++++++++---- 11 files changed, 57 insertions(+), 31 deletions(-) diff --git a/Sources/App/Environments/AppEnvironment.swift b/Sources/App/Environments/AppEnvironment.swift index 237d4e1d..5f9cfcea 100644 --- a/Sources/App/Environments/AppEnvironment.swift +++ b/Sources/App/Environments/AppEnvironment.swift @@ -28,6 +28,9 @@ final class AppEnvironment: ObservableObject { /// Whether the startup settings should be shown or not. @Published var shouldShowStartupSettings = !Logic.Settings.isWorkspaceURLValid + + /// Whether or not the record mode settings are shown. + @Published var isRecordModeSettingsPresented: Bool = false /// The global server configuration. var serverConfiguration: ServerConfiguration? { diff --git a/Sources/App/Helpers/SFSymbol.swift b/Sources/App/Helpers/SFSymbol.swift index 79d27047..6e20035b 100644 --- a/Sources/App/Helpers/SFSymbol.swift +++ b/Sources/App/Helpers/SFSymbol.swift @@ -26,9 +26,6 @@ enum SFSymbol: String { /// Start recording icon. case startRecording = "record.circle" - - /// Stop recording icon. - case stopRecording = "record.circle.fill" /// Refresh icon. case refresh = "arrow.triangle.2.circlepath" diff --git a/Sources/App/Mocka.swift b/Sources/App/Mocka.swift index 3a897199..4e18b043 100644 --- a/Sources/App/Mocka.swift +++ b/Sources/App/Mocka.swift @@ -30,6 +30,13 @@ struct Mocka: App { ) { ServerSettings(viewModel: ServerSettingsViewModel(isShownFromSettings: false)) } + .sheet( + isPresented: $appEnvironment.isRecordModeSettingsPresented + ) { + RecordModeSettings(viewModel: RecordModeSettingsViewModel(appEnvironment: appEnvironment)) + .environmentObject(appEnvironment) + } + } .windowStyle(HiddenTitleBarWindowStyle()) .windowToolbarStyle(UnifiedWindowToolbarStyle()) diff --git a/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButton.swift b/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButton.swift index dfb0eac8..3992b3bc 100644 --- a/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButton.swift +++ b/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButton.swift @@ -21,11 +21,15 @@ struct StartAndStopRecordModeButton: View { var body: some View { SymbolButton( - symbolName: appEnvironment.isServerRecording ? .stopRecording : .startRecording, + symbolName: appEnvironment.isServerRecording ? .stopCircle : .startRecording, + color: appEnvironment.isServerRunning ? Color.redEye : nil, action: { - viewModel.startAndStopRecordMode(on: appEnvironment) + if appEnvironment.middlewareConfiguration == nil { + appEnvironment.isRecordModeSettingsPresented.toggle() + } else { + viewModel.startAndStopRecordMode(on: appEnvironment) + } } ) - .disabled(appEnvironment.middlewareConfiguration == nil) } } diff --git a/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButtonViewModel.swift b/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButtonViewModel.swift index c173bbdc..108feddf 100644 --- a/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButtonViewModel.swift +++ b/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButtonViewModel.swift @@ -15,16 +15,12 @@ final class StartAndStopRecordModeButtonViewModel: ObservableObject { switch appEnvironment.isServerRecording { case true: try? appEnvironment.server.stop() - + + appEnvironment.isServerRunning.toggle() + appEnvironment.isServerRecording.toggle() + case false: - guard let middlewareConfiguration = appEnvironment.middlewareConfiguration else { - return - } - - try? appEnvironment.server.startRecording(with: middlewareConfiguration) + appEnvironment.isRecordModeSettingsPresented.toggle() } - - appEnvironment.isServerRunning.toggle() - appEnvironment.isServerRecording.toggle() } } diff --git a/Sources/App/Views/Common/Buttons/SymbolButton.swift b/Sources/App/Views/Common/Buttons/SymbolButton.swift index a1d36b9f..0c1230a7 100644 --- a/Sources/App/Views/Common/Buttons/SymbolButton.swift +++ b/Sources/App/Views/Common/Buttons/SymbolButton.swift @@ -12,9 +12,12 @@ struct SymbolButton: View { /// The name of the SF Symbol. var symbolName: SFSymbol + /// An optional foreground color for the button. + var color: Color? + /// The action to execute when the button is tapped. var action: () -> Void - + // MARK: - Body var body: some View { @@ -27,6 +30,7 @@ struct SymbolButton: View { ) .buttonStyle(PlainButtonStyle()) .frame(width: 20, height: 20, alignment: .center) + .foregroundColor(color) } } diff --git a/Sources/App/Views/Sections/Editor/SourceTree/SourceTree.swift b/Sources/App/Views/Sections/Editor/SourceTree/SourceTree.swift index 8255591d..bc5b6214 100644 --- a/Sources/App/Views/Sections/Editor/SourceTree/SourceTree.swift +++ b/Sources/App/Views/Sections/Editor/SourceTree/SourceTree.swift @@ -95,6 +95,8 @@ struct SourceTree: View { viewModel.isShowingCreateRequestDetailView = true } ) + + StartAndStopRecordModeButton() } } } diff --git a/Sources/App/Views/Sections/Server/List/ServerList.swift b/Sources/App/Views/Sections/Server/List/ServerList.swift index 401065ad..ae460db0 100644 --- a/Sources/App/Views/Sections/Server/List/ServerList.swift +++ b/Sources/App/Views/Sections/Server/List/ServerList.swift @@ -68,8 +68,6 @@ struct ServerList: View { viewModel.clearNetworkExchanges() } ) - - StartAndStopRecordModeButton() } } } diff --git a/Sources/App/Views/Sections/Settings/AppSettings.swift b/Sources/App/Views/Sections/Settings/AppSettings.swift index a03820e2..1d76aa7d 100644 --- a/Sources/App/Views/Sections/Settings/AppSettings.swift +++ b/Sources/App/Views/Sections/Settings/AppSettings.swift @@ -18,11 +18,6 @@ struct AppSettings: View { .tabItem { Label("Server", systemImage: SFSymbol.document.rawValue) } - - RecordModeSettings(viewModel: RecordModeSettingsViewModel()) - .tabItem { - Label("Record mode", systemImage: SFSymbol.startRecording.rawValue) - } } } } diff --git a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift index 86d24d73..4836d01a 100644 --- a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift +++ b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift @@ -72,10 +72,10 @@ struct RecordModeSettings: View { VStack(alignment: .trailing) { Button( action: { - viewModel.confirmSettings(with: appEnvironment) + viewModel.confirmSettingsAndStartRecording() }, label: { - Text("OK") + Text("Start recording") .frame(width: 100, height: 21) } ) @@ -92,6 +92,6 @@ struct RecordModeSettings: View { struct RecordModeSettingsPreview: PreviewProvider { static var previews: some View { - RecordModeSettings(viewModel: RecordModeSettingsViewModel()) + RecordModeSettings(viewModel: RecordModeSettingsViewModel(appEnvironment: AppEnvironment())) } } diff --git a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift index 915b0940..8a828092 100644 --- a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift +++ b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift @@ -10,8 +10,10 @@ final class RecordModeSettingsViewModel: ObservableObject { // MARK: - Stored Properties + var appEnvironment: AppEnvironment + /// The folder where the record mode requests will be saved. - @Published var recordingPath: String = "" { + @Published var recordingPath: String { didSet { // When the user modifies the `recordingPath` we must remove any `recordingPathError` if present. // This is needed in order to remove the red `RoundedRectangle` around the `RoundedTextField` of the "record mode folder" entry. @@ -21,7 +23,7 @@ final class RecordModeSettingsViewModel: ObservableObject { } /// The base URL that will be passed to the middleware for the record mode to start. - @Published var middlewareBaseURL: String = "" + @Published var middlewareBaseURL: String /// Handle the workspace path error. @Published var recordingPathError: MockaError? = nil @@ -29,6 +31,17 @@ final class RecordModeSettingsViewModel: ObservableObject { /// Whether the `fileImporter` is presented. @Published var fileImporterIsPresented: Bool = false + // MARK: - Init + + /// Creates a new instance with the app environment. + /// - Parameter appEnvironment: The app environment. + init(appEnvironment: AppEnvironment) { + self.appEnvironment = appEnvironment + + middlewareBaseURL = appEnvironment.middlewareBaseURL?.absoluteString ?? "" + recordingPath = appEnvironment.selectedRecordingPath?.path ?? "" + } + // MARK: - Functions /// The `fileImporter` completion function. @@ -48,8 +61,7 @@ final class RecordModeSettingsViewModel: ObservableObject { /// Confirms the selected startup settings /// by creating the configuration file in the right path. /// In case of error the `workspaceURL` returns to `nil`. - /// - Parameter presentationMode: The `View` `PresentationMode`. - func confirmSettings(with appEnvironment: AppEnvironment) { + func confirmSettingsAndStartRecording() { let recordingURL = URL(fileURLWithPath: recordingPath) let middlewareURL = URL(string: middlewareBaseURL) @@ -58,8 +70,16 @@ final class RecordModeSettingsViewModel: ObservableObject { appEnvironment.middlewareBaseURL = middlewareURL appEnvironment.selectedRecordingPath = recordingURL + appEnvironment.isRecordModeSettingsPresented.toggle() + + guard let middlewareConfiguration = appEnvironment.middlewareConfiguration, appEnvironment.isServerRecording.isFalse else { + return + } + + try? appEnvironment.server.startRecording(with: middlewareConfiguration) - NSApplication.shared.keyWindow?.close() + appEnvironment.isServerRunning.toggle() + appEnvironment.isServerRecording.toggle() } catch { guard let recordingPathError = error as? MockaError else { return From e1a901cb30f8a5324aa1478f87111bda3ad1685d Mon Sep 17 00:00:00 2001 From: Davide Tarantino Date: Thu, 5 Aug 2021 16:59:40 +0200 Subject: [PATCH 12/14] fix UI and doc --- Sources/App/Environments/AppEnvironment.swift | 3 + Sources/App/Logic/RecordMode+Logic.swift | 5 -- .../Views/Sections/AppSectionViewModel.swift | 8 +-- .../RecordMode/RecordModeSettings.swift | 68 +++++++++++++------ .../RecordModeSettingsViewModel.swift | 3 + Sources/Server/RecordingMiddleware.swift | 2 + 6 files changed, 57 insertions(+), 32 deletions(-) delete mode 100644 Sources/App/Logic/RecordMode+Logic.swift diff --git a/Sources/App/Environments/AppEnvironment.swift b/Sources/App/Environments/AppEnvironment.swift index 5f9cfcea..f7518c0a 100644 --- a/Sources/App/Environments/AppEnvironment.swift +++ b/Sources/App/Environments/AppEnvironment.swift @@ -17,6 +17,9 @@ final class AppEnvironment: ObservableObject { /// The base `URL` to be used by the middleware when performing network calls in record mode. @Published var middlewareBaseURL: URL? = nil + /// Whether or not the response should be overwritten in the record mode. + @Published var shouldOverwriteResponse: Bool = true + /// The path where the recorded responses and requests will be saved in record mode. @Published var selectedRecordingPath: URL? = nil diff --git a/Sources/App/Logic/RecordMode+Logic.swift b/Sources/App/Logic/RecordMode+Logic.swift deleted file mode 100644 index da04f62a..00000000 --- a/Sources/App/Logic/RecordMode+Logic.swift +++ /dev/null @@ -1,5 +0,0 @@ -// -// Mocka -// - -import Foundation diff --git a/Sources/App/Views/Sections/AppSectionViewModel.swift b/Sources/App/Views/Sections/AppSectionViewModel.swift index 02100a23..77a078f9 100644 --- a/Sources/App/Views/Sections/AppSectionViewModel.swift +++ b/Sources/App/Views/Sections/AppSectionViewModel.swift @@ -32,15 +32,13 @@ final class AppSectionViewModel: ObservableObject { .receive(on: RunLoop.main) .sink { [weak self] networkExchange in guard let recordingPath = self?.recordingPath else { - print("ops") return } - #warning("Add proper values") self?.createAndSaveRequest( from: networkExchange, to: recordingPath, - shouldOverwriteResponse: true + shouldOverwriteResponse: appEnvironment.shouldOverwriteResponse ) } .store(in: &subscriptions) @@ -65,7 +63,7 @@ final class AppSectionViewModel: ObservableObject { } } - try? Logic.SourceTree.addDirectory(at: directory, named: requestDirectoryName) + _ = try? Logic.SourceTree.addDirectory(at: directory, named: requestDirectoryName) // Add response, if any. if @@ -88,6 +86,6 @@ final class AppSectionViewModel: ObservableObject { /// - Parameter request: The request we want to save. /// - Returns: The name of the request folder. static private func requestDirectoryName(_ request: Request) -> String { - "\(request.method.rawValue) - \(request.path)" + "\(request.method.rawValue) - \(request.path.joined(separator: "/"))" } } diff --git a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift index 4836d01a..c98ced91 100644 --- a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift +++ b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift @@ -26,15 +26,16 @@ struct RecordModeSettings: View { .font(.body) .padding(.vertical) - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 16) { HStack(alignment: .top) { Text("Recording folder path") - .font(.headline) + .font(.subheadline) .frame(width: 120, alignment: .trailing) + .padding(.top, 11) VStack { RoundedTextField(title: "Recording folder path", text: $viewModel.recordingPath) - .frame(width: 300) + .frame(width: 344) .overlay( RoundedRectangle(cornerRadius: 6) .stroke(Color.redEye, lineWidth: viewModel.recordingPathError == nil ? 0 : 1) @@ -42,7 +43,7 @@ struct RecordModeSettings: View { Text("Please note that the selected folder must exist and it will not be automatically created.") .font(.subheadline) - .frame(width: 300, height: 30) + .frame(width: 344, height: 30) .padding(.top, -6) .foregroundColor(.macchiato) } @@ -50,7 +51,7 @@ struct RecordModeSettings: View { Button("Select folder") { viewModel.fileImporterIsPresented.toggle() } - .frame(height: 30) + .frame(height: 36) .fileImporter( isPresented: $viewModel.fileImporterIsPresented, allowedContentTypes: [UTType.folder], @@ -59,30 +60,53 @@ struct RecordModeSettings: View { ) } - HStack { + HStack(alignment: .top) { Text("Recording base URL") - .font(.headline) + .font(.subheadline) .frame(width: 120, alignment: .trailing) + .padding(.top, 11) RoundedTextField(title: "Recording base URL", text: $viewModel.middlewareBaseURL) - .frame(width: 300) + .frame(width: 344) } - } - - VStack(alignment: .trailing) { - Button( - action: { - viewModel.confirmSettingsAndStartRecording() - }, - label: { - Text("Start recording") - .frame(width: 100, height: 21) + + HStack { + Toggle(isOn: $viewModel.shouldOverwriteResponse) { + Text("Overwrite already existing responses") } - ) - .buttonStyle(AccentButtonStyle()) - .padding(.horizontal) - .padding(.top) + .padding(.top, 10) + .padding(.leading, 128) + } + } + + VStack { + HStack { + Spacer() + + Button( + action: { + appEnvironment.isRecordModeSettingsPresented.toggle() + }, + label: { + Text("Cancel") + .padding(.horizontal) + } + ) + + Button( + action: { + viewModel.confirmSettingsAndStartRecording() + }, + label: { + Text("Start recording") + .frame(height: 20) + .padding(.horizontal) + } + ) + .buttonStyle(AccentButtonStyle()) + } } + .padding(.top) } .padding(25) } diff --git a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift index 8a828092..386f4681 100644 --- a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift +++ b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift @@ -31,6 +31,9 @@ final class RecordModeSettingsViewModel: ObservableObject { /// Whether the `fileImporter` is presented. @Published var fileImporterIsPresented: Bool = false + /// Whether or not the overwrite response checkbox is enabled. + @Published var shouldOverwriteResponse: Bool = true + // MARK: - Init /// Creates a new instance with the app environment. diff --git a/Sources/Server/RecordingMiddleware.swift b/Sources/Server/RecordingMiddleware.swift index f87d188e..921195a7 100644 --- a/Sources/Server/RecordingMiddleware.swift +++ b/Sources/Server/RecordingMiddleware.swift @@ -36,6 +36,8 @@ final class RecordingMiddleware: Middleware { return response.encodeResponse(for: request) } + // We already decoded the response if it is encoded in a format like gzip, so we have to remove the content encoding header, + // to prevent a deserialization failure. response.headers = clientResponse.headers.removing(name: "Content-Encoding") self.recordModeNetworkExchangesSubject.send(self.networkExchange(from: request, and: response)) From d4e24a0b618ba5b1237ebbd8f313fc1d8f3242a7 Mon Sep 17 00:00:00 2001 From: Davide Tarantino Date: Thu, 5 Aug 2021 17:25:39 +0200 Subject: [PATCH 13/14] remove user-selected.read-write --- Sources/App/Resources/MockaApp.entitlements | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/App/Resources/MockaApp.entitlements b/Sources/App/Resources/MockaApp.entitlements index 0dc8f746..58d3ab6b 100644 --- a/Sources/App/Resources/MockaApp.entitlements +++ b/Sources/App/Resources/MockaApp.entitlements @@ -4,8 +4,6 @@ com.apple.security.cs.disable-library-validation - com.apple.security.files.user-selected.read-write - com.apple.security.network.client com.apple.security.network.server From cbd1a1e9442ca936bd6c96bde18774165a653c5a Mon Sep 17 00:00:00 2001 From: FabrizioBrancati Date: Thu, 2 Sep 2021 12:46:12 +0000 Subject: [PATCH 14/14] Format code --- Sources/App/Environments/AppEnvironment.swift | 14 +++--- Sources/App/Helpers/SFSymbol.swift | 2 +- Sources/App/Mocka.swift | 47 ++++++++++--------- .../App/Models/MiddlewareConfiguration.swift | 3 +- Sources/App/Models/Request.swift | 2 +- ...tartAndStopRecordModeButtonViewModel.swift | 4 +- .../Views/Common/Buttons/SymbolButton.swift | 2 +- Sources/App/Views/Sections/AppSection.swift | 11 +++-- .../Views/Sections/AppSectionViewModel.swift | 30 ++++++------ .../Editor/SourceTree/SourceTree.swift | 2 +- .../RecordMode/RecordModeSettings.swift | 18 +++---- .../RecordModeSettingsViewModel.swift | 20 ++++---- Sources/Server/AppServer.swift | 17 +++---- Sources/Server/RecordingMiddleware.swift | 16 +++---- 14 files changed, 98 insertions(+), 90 deletions(-) diff --git a/Sources/App/Environments/AppEnvironment.swift b/Sources/App/Environments/AppEnvironment.swift index f7518c0a..e08c2a31 100644 --- a/Sources/App/Environments/AppEnvironment.swift +++ b/Sources/App/Environments/AppEnvironment.swift @@ -10,16 +10,16 @@ import SwiftUI final class AppEnvironment: ObservableObject { /// Whether the server is currently running. @Published var isServerRunning: Bool = false - + /// Whether the server is currently in record mode. @Published var isServerRecording: Bool = false - + /// The base `URL` to be used by the middleware when performing network calls in record mode. @Published var middlewareBaseURL: URL? = nil - + /// Whether or not the response should be overwritten in the record mode. @Published var shouldOverwriteResponse: Bool = true - + /// The path where the recorded responses and requests will be saved in record mode. @Published var selectedRecordingPath: URL? = nil @@ -31,7 +31,7 @@ final class AppEnvironment: ObservableObject { /// Whether the startup settings should be shown or not. @Published var shouldShowStartupSettings = !Logic.Settings.isWorkspaceURLValid - + /// Whether or not the record mode settings are shown. @Published var isRecordModeSettingsPresented: Bool = false @@ -39,13 +39,13 @@ final class AppEnvironment: ObservableObject { var serverConfiguration: ServerConfiguration? { Logic.Settings.serverConfiguration } - + /// The global record mode middleware configuration. var middlewareConfiguration: MiddlewareConfiguration? { guard let baseURL = middlewareBaseURL, let hostname = serverConfiguration?.hostname, let port = serverConfiguration?.port else { return nil } - + return MiddlewareConfiguration(baseURL: baseURL, hostname: hostname, port: port) } } diff --git a/Sources/App/Helpers/SFSymbol.swift b/Sources/App/Helpers/SFSymbol.swift index 6e20035b..6ec421ef 100644 --- a/Sources/App/Helpers/SFSymbol.swift +++ b/Sources/App/Helpers/SFSymbol.swift @@ -23,7 +23,7 @@ enum SFSymbol: String { /// Plus circle icon. case plusCircle = "plus.circle" - + /// Start recording icon. case startRecording = "record.circle" diff --git a/Sources/App/Mocka.swift b/Sources/App/Mocka.swift index 2dcdc5ae..2e193ca6 100644 --- a/Sources/App/Mocka.swift +++ b/Sources/App/Mocka.swift @@ -14,28 +14,31 @@ struct Mocka: App { var body: some Scene { WindowGroup { - AppSection(viewModel: AppSectionViewModel(recordModeNetworkExchangesPublisher: appEnvironment.server.recordModeNetworkExchangesPublisher, appEnvironment: appEnvironment)) - .frame( - // Due to a bug of the `NavigationView` we cannot use the exactly minimum size. - // We add `5` points to be sure to not close the sidebar while resizing the view. - minWidth: Size.minimumSidebarWidth + Size.minimumListWidth + Size.minimumDetailWidth + 5, - maxWidth: .infinity, - minHeight: Size.minimumAppHeight, - maxHeight: .infinity, - alignment: .leading - ) - .environmentObject(appEnvironment) - .sheet( - isPresented: $appEnvironment.shouldShowStartupSettings - ) { - ServerSettings(viewModel: ServerSettingsViewModel(isShownFromSettings: false)) - } - .sheet( - isPresented: $appEnvironment.isRecordModeSettingsPresented - ) { - RecordModeSettings(viewModel: RecordModeSettingsViewModel(appEnvironment: appEnvironment)) - .environmentObject(appEnvironment) - } + AppSection( + viewModel: AppSectionViewModel( + recordModeNetworkExchangesPublisher: appEnvironment.server.recordModeNetworkExchangesPublisher, appEnvironment: appEnvironment) + ) + .frame( + // Due to a bug of the `NavigationView` we cannot use the exactly minimum size. + // We add `5` points to be sure to not close the sidebar while resizing the view. + minWidth: Size.minimumSidebarWidth + Size.minimumListWidth + Size.minimumDetailWidth + 5, + maxWidth: .infinity, + minHeight: Size.minimumAppHeight, + maxHeight: .infinity, + alignment: .leading + ) + .environmentObject(appEnvironment) + .sheet( + isPresented: $appEnvironment.shouldShowStartupSettings + ) { + ServerSettings(viewModel: ServerSettingsViewModel(isShownFromSettings: false)) + } + .sheet( + isPresented: $appEnvironment.isRecordModeSettingsPresented + ) { + RecordModeSettings(viewModel: RecordModeSettingsViewModel(appEnvironment: appEnvironment)) + .environmentObject(appEnvironment) + } } .windowStyle(HiddenTitleBarWindowStyle()) diff --git a/Sources/App/Models/MiddlewareConfiguration.swift b/Sources/App/Models/MiddlewareConfiguration.swift index 180c73c4..9e4102b2 100644 --- a/Sources/App/Models/MiddlewareConfiguration.swift +++ b/Sources/App/Models/MiddlewareConfiguration.swift @@ -9,7 +9,7 @@ import MockaServer struct MiddlewareConfiguration: MiddlewareConfigurationProvider, Codable { /// The base `URL` on which the middleware will start requests. var baseURL: URL - + /// The host part of the `URL`. let hostname: String @@ -31,4 +31,3 @@ struct MiddlewareConfiguration: MiddlewareConfigurationProvider, Codable { self.port = port } } - diff --git a/Sources/App/Models/Request.swift b/Sources/App/Models/Request.swift index 8ca21ac6..dfbecea8 100644 --- a/Sources/App/Models/Request.swift +++ b/Sources/App/Models/Request.swift @@ -34,7 +34,7 @@ struct Request: Equatable, Hashable { self.method = method self.expectedResponse = expectedResponse } - + /// Creates a `Request` object starting from a `NetworkExchange` object. /// - Parameter networkExchange: The `NetworkExchange` object received from the server. init(from networkExchange: NetworkExchange) { diff --git a/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButtonViewModel.swift b/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButtonViewModel.swift index 108feddf..bb5add23 100644 --- a/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButtonViewModel.swift +++ b/Sources/App/Views/Common/Buttons/StartAndStopRecordModeButtonViewModel.swift @@ -15,10 +15,10 @@ final class StartAndStopRecordModeButtonViewModel: ObservableObject { switch appEnvironment.isServerRecording { case true: try? appEnvironment.server.stop() - + appEnvironment.isServerRunning.toggle() appEnvironment.isServerRecording.toggle() - + case false: appEnvironment.isRecordModeSettingsPresented.toggle() } diff --git a/Sources/App/Views/Common/Buttons/SymbolButton.swift b/Sources/App/Views/Common/Buttons/SymbolButton.swift index 0c1230a7..4e841ff7 100644 --- a/Sources/App/Views/Common/Buttons/SymbolButton.swift +++ b/Sources/App/Views/Common/Buttons/SymbolButton.swift @@ -17,7 +17,7 @@ struct SymbolButton: View { /// The action to execute when the button is tapped. var action: () -> Void - + // MARK: - Body var body: some View { diff --git a/Sources/App/Views/Sections/AppSection.swift b/Sources/App/Views/Sections/AppSection.swift index ff8d1998..f1b69a5f 100644 --- a/Sources/App/Views/Sections/AppSection.swift +++ b/Sources/App/Views/Sections/AppSection.swift @@ -11,7 +11,7 @@ import SwiftUI struct AppSection: View { // MARK: - Stored Properties - + /// The associated ViewModel. @ObservedObject var viewModel: AppSectionViewModel @@ -44,8 +44,11 @@ struct AppSectionPreview: PreviewProvider { ) static var previews: some View { - AppSection(viewModel: AppSectionViewModel(recordModeNetworkExchangesPublisher: networkExchanges.publisher.eraseToAnyPublisher(), appEnvironment: AppEnvironment())) - .previewLayout(.fixed(width: 1024, height: 600)) - .environmentObject(AppEnvironment()) + AppSection( + viewModel: AppSectionViewModel( + recordModeNetworkExchangesPublisher: networkExchanges.publisher.eraseToAnyPublisher(), appEnvironment: AppEnvironment()) + ) + .previewLayout(.fixed(width: 1024, height: 600)) + .environmentObject(AppEnvironment()) } } diff --git a/Sources/App/Views/Sections/AppSectionViewModel.swift b/Sources/App/Views/Sections/AppSectionViewModel.swift index 77a078f9..e3d63302 100644 --- a/Sources/App/Views/Sections/AppSectionViewModel.swift +++ b/Sources/App/Views/Sections/AppSectionViewModel.swift @@ -13,9 +13,9 @@ final class AppSectionViewModel: ObservableObject { /// The `Set` containing the list of subscriptions. var subscriptions = Set() - + var appEnvironment: AppEnvironment - + /// The path for the request and response to save in the record mode. var recordingPath: URL? { appEnvironment.selectedRecordingPath @@ -27,23 +27,24 @@ final class AppSectionViewModel: ObservableObject { /// - Parameter recordModeNetworkExchangesPublisher: The publisher of `NetworkExchange`s for the record mode. init(recordModeNetworkExchangesPublisher: AnyPublisher, appEnvironment: AppEnvironment) { self.appEnvironment = appEnvironment - + recordModeNetworkExchangesPublisher .receive(on: RunLoop.main) .sink { [weak self] networkExchange in guard let recordingPath = self?.recordingPath else { return } - - self?.createAndSaveRequest( - from: networkExchange, - to: recordingPath, - shouldOverwriteResponse: appEnvironment.shouldOverwriteResponse - ) + + self? + .createAndSaveRequest( + from: networkExchange, + to: recordingPath, + shouldOverwriteResponse: appEnvironment.shouldOverwriteResponse + ) } .store(in: &subscriptions) } - + /// Creates a request from the received `NetworkExchange` and saves it to the provided folder. /// If the response is already present, it is overwritten or not based on the `shouldOverwriteResponse` parameter. /// - Parameters: @@ -54,7 +55,7 @@ final class AppSectionViewModel: ObservableObject { let request = Request(from: networkExchange) let requestDirectoryName = Self.requestDirectoryName(request) let requestDirectory = directory.appendingPathComponent(requestDirectoryName) - + if Logic.SourceTree.contents(of: requestDirectory).isNotEmpty { if shouldOverwriteResponse { try? Logic.SourceTree.deleteDirectory(at: requestDirectoryName) @@ -62,12 +63,11 @@ final class AppSectionViewModel: ObservableObject { return } } - + _ = try? Logic.SourceTree.addDirectory(at: directory, named: requestDirectoryName) // Add response, if any. - if - let responseBodyData = networkExchange.response.body, + if let responseBodyData = networkExchange.response.body, let responseBody = String(data: responseBodyData, encoding: .utf8), let expectedFileExtension = request.expectedResponse.contentType.expectedFileExtension { @@ -81,7 +81,7 @@ final class AppSectionViewModel: ObservableObject { // Add request. try? Logic.SourceTree.addRequest(request, to: requestDirectory) } - + /// Generates the name of the request folder. /// - Parameter request: The request we want to save. /// - Returns: The name of the request folder. diff --git a/Sources/App/Views/Sections/Editor/SourceTree/SourceTree.swift b/Sources/App/Views/Sections/Editor/SourceTree/SourceTree.swift index bc5b6214..41fd11c4 100644 --- a/Sources/App/Views/Sections/Editor/SourceTree/SourceTree.swift +++ b/Sources/App/Views/Sections/Editor/SourceTree/SourceTree.swift @@ -95,7 +95,7 @@ struct SourceTree: View { viewModel.isShowingCreateRequestDetailView = true } ) - + StartAndStopRecordModeButton() } } diff --git a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift index c98ced91..53ba2fdf 100644 --- a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift +++ b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettings.swift @@ -21,10 +21,12 @@ struct RecordModeSettings: View { var body: some View { VStack { - Text("Before starting the record mode, you need to choose a path where the requests and responses will be saved.\nYou also need to input the base URL that will be used to perform the network calls.") - .frame(height: 50) - .font(.body) - .padding(.vertical) + Text( + "Before starting the record mode, you need to choose a path where the requests and responses will be saved.\nYou also need to input the base URL that will be used to perform the network calls." + ) + .frame(height: 50) + .font(.body) + .padding(.vertical) VStack(alignment: .leading, spacing: 16) { HStack(alignment: .top) { @@ -69,7 +71,7 @@ struct RecordModeSettings: View { RoundedTextField(title: "Recording base URL", text: $viewModel.middlewareBaseURL) .frame(width: 344) } - + HStack { Toggle(isOn: $viewModel.shouldOverwriteResponse) { Text("Overwrite already existing responses") @@ -78,11 +80,11 @@ struct RecordModeSettings: View { .padding(.leading, 128) } } - + VStack { HStack { Spacer() - + Button( action: { appEnvironment.isRecordModeSettingsPresented.toggle() @@ -92,7 +94,7 @@ struct RecordModeSettings: View { .padding(.horizontal) } ) - + Button( action: { viewModel.confirmSettingsAndStartRecording() diff --git a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift index 386f4681..3d086ee8 100644 --- a/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift +++ b/Sources/App/Views/Sections/Settings/RecordMode/RecordModeSettingsViewModel.swift @@ -9,9 +9,9 @@ import UniformTypeIdentifiers final class RecordModeSettingsViewModel: ObservableObject { // MARK: - Stored Properties - + var appEnvironment: AppEnvironment - + /// The folder where the record mode requests will be saved. @Published var recordingPath: String { didSet { @@ -30,7 +30,7 @@ final class RecordModeSettingsViewModel: ObservableObject { /// Whether the `fileImporter` is presented. @Published var fileImporterIsPresented: Bool = false - + /// Whether or not the overwrite response checkbox is enabled. @Published var shouldOverwriteResponse: Bool = true @@ -40,11 +40,11 @@ final class RecordModeSettingsViewModel: ObservableObject { /// - Parameter appEnvironment: The app environment. init(appEnvironment: AppEnvironment) { self.appEnvironment = appEnvironment - + middlewareBaseURL = appEnvironment.middlewareBaseURL?.absoluteString ?? "" recordingPath = appEnvironment.selectedRecordingPath?.path ?? "" } - + // MARK: - Functions /// The `fileImporter` completion function. @@ -60,17 +60,17 @@ final class RecordModeSettingsViewModel: ObservableObject { self.recordingPath = recordingFolder recordingPathError = nil } - + /// Confirms the selected startup settings /// by creating the configuration file in the right path. /// In case of error the `workspaceURL` returns to `nil`. func confirmSettingsAndStartRecording() { let recordingURL = URL(fileURLWithPath: recordingPath) let middlewareURL = URL(string: middlewareBaseURL) - + do { try Logic.WorkspacePath.checkURLAndCreateFolderIfNeeded(at: recordingURL) - + appEnvironment.middlewareBaseURL = middlewareURL appEnvironment.selectedRecordingPath = recordingURL appEnvironment.isRecordModeSettingsPresented.toggle() @@ -78,9 +78,9 @@ final class RecordModeSettingsViewModel: ObservableObject { guard let middlewareConfiguration = appEnvironment.middlewareConfiguration, appEnvironment.isServerRecording.isFalse else { return } - + try? appEnvironment.server.startRecording(with: middlewareConfiguration) - + appEnvironment.isServerRunning.toggle() appEnvironment.isServerRecording.toggle() } catch { diff --git a/Sources/Server/AppServer.swift b/Sources/Server/AppServer.swift index af436261..3948f585 100644 --- a/Sources/Server/AppServer.swift +++ b/Sources/Server/AppServer.swift @@ -44,7 +44,7 @@ public class AppServer { public var networkExchangesPublisher: AnyPublisher { networkExchangesSubject.eraseToAnyPublisher() } - + /// The `Publisher` of `NetworkExchange`s for the record mode. public var recordModeNetworkExchangesPublisher: AnyPublisher { recordModeNetworkExchangesSubject.eraseToAnyPublisher() @@ -79,7 +79,7 @@ public class AppServer { } // MARK: - Methods - + /// Starts a new `Application` instance using the passed configuration and uses it to record network calls. /// - Parameter configuration: An object conforming to `MiddlewareConfigurationProvider`. /// - Throws: `ServerError.instanceAlreadyRunning` or a wrapped `Vapor` error. @@ -100,13 +100,14 @@ public class AppServer { application?.http.server.configuration.port = configuration.port application?.http.server.configuration.hostname = configuration.hostname application?.http.client.configuration.decompression = .enabled(limit: .none) - application?.middleware.use( - RecordingMiddleware( - configuration: configuration, - recordModeNetworkExchangesSubject: recordModeNetworkExchangesSubject + application?.middleware + .use( + RecordingMiddleware( + configuration: configuration, + recordModeNetworkExchangesSubject: recordModeNetworkExchangesSubject + ) ) - ) - + do { try application?.server.start() } catch { diff --git a/Sources/Server/RecordingMiddleware.swift b/Sources/Server/RecordingMiddleware.swift index 921195a7..96a73ad8 100644 --- a/Sources/Server/RecordingMiddleware.swift +++ b/Sources/Server/RecordingMiddleware.swift @@ -13,7 +13,7 @@ final class RecordingMiddleware: Middleware { /// The `PassthroughSubject` used to send the request and response pair back to the app. let recordModeNetworkExchangesSubject: PassthroughSubject - + /// Initializes the `RecordingMiddleware. /// - Parameters: /// - configuration: The configuration of the middleware. @@ -22,24 +22,24 @@ final class RecordingMiddleware: Middleware { self.configuration = configuration self.recordModeNetworkExchangesSubject = recordModeNetworkExchangesSubject } - + func respond(to request: Vapor.Request, chainingTo next: Responder) -> EventLoopFuture { let requestURL = Vapor.URI(string: configuration.baseURL.absoluteString + request.url.path) let headers = request.headers.removing(name: "Host") let clientRequest = ClientRequest(method: request.method, url: requestURL, headers: headers, body: request.body.data) - + return request.client.send(clientRequest) .flatMap { [weak self] clientResponse -> EventLoopFuture in var response = clientResponse - + guard let self = self else { return response.encodeResponse(for: request) } - + // We already decoded the response if it is encoded in a format like gzip, so we have to remove the content encoding header, // to prevent a deserialization failure. response.headers = clientResponse.headers.removing(name: "Content-Encoding") - + self.recordModeNetworkExchangesSubject.send(self.networkExchange(from: request, and: response)) return response.encodeResponse(for: request) } @@ -63,7 +63,6 @@ private extension RecordingMiddleware { return bufferCopy.readData(length: bufferCopy.readableBytes) } - /// Creates the `NetworkExchange` based on the `Request` and `ClientResponse` pair. /// - Parameters: /// - request: The `Vapor.Request` for the network call. @@ -73,7 +72,8 @@ private extension RecordingMiddleware { NetworkExchange( request: DetailedRequest( httpMethod: HTTPMethod(rawValue: request.method.rawValue)!, - uri: URI(scheme: URI.Scheme.http, host: self.configuration.hostname, port: self.configuration.port, path: request.url.path, query: request.url.query), + uri: URI( + scheme: URI.Scheme.http, host: self.configuration.hostname, port: self.configuration.port, path: request.url.path, query: request.url.query), headers: request.headers, body: self.body(from: request.body.data), timestamp: Date().timeIntervalSince1970