diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 93d1b89..9bfd869 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -17,17 +17,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - swiftlint: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - - name: Run SwiftLint - uses: norio-nomura/action-swiftlint@3.1.0 - with: - args: --strict - test-linux: runs-on: ubuntu-latest diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..27735f7 --- /dev/null +++ b/.swift-format @@ -0,0 +1,15 @@ +{ + "lineBreakAroundMultilineExpressionChainComponents": true, + "lineBreakBeforeControlFlowKeywords": true, + "lineBreakBeforeEachArgument": true, + "lineBreakBeforeEachGenericRequirement": true, + "lineLength": 120, + "prioritizeKeepingFunctionOutputTogether": true, + "rules": { + "NeverUseImplicitlyUnwrappedOptionals": true, + "NoLeadingUnderscores": true, + "ValidateDocumentationComments": true, + }, + "tabWidth": 2, + "version": 1, +} diff --git a/.swiftlint.yml b/.swiftlint.yml deleted file mode 100755 index 20604c9..0000000 --- a/.swiftlint.yml +++ /dev/null @@ -1,375 +0,0 @@ -# Basic Configuration -opt_in_rules: -- array_init -- attributes -- closure_end_indentation -- closure_spacing -- conditional_returns_on_newline -- contains_over_first_not_nil -- convenience_type -- empty_count -- empty_string -- empty_xctest_method -- explicit_init -- explicit_type_interface -- fallthrough -- fatal_error_message -- file_header -- file_name -- file_types_order -- first_where -- function_default_parameter_at_end -- implicitly_unwrapped_optional -- is_disjoint -- joined_default_parameter -- let_var_whitespace -- literal_expression_end_indentation -- lower_acl_than_parent -- missing_docs -- modifier_order -- multiline_arguments -- multiline_arguments_brackets -- multiline_literal_brackets -- multiline_parameters -- multiline_parameters_brackets -- nimble_operator -- no_extension_access_modifier -- number_separator -- object_literal -- operator_usage_whitespace -- overridden_super_call -- override_in_extension -- pattern_matching_keywords -- private_action -- private_outlet -- prohibited_super_call -- quick_discouraged_call -- quick_discouraged_focused_test -- quick_discouraged_pending_test -- redundant_nil_coalescing -- redundant_type_annotation -- single_test_class -- sorted_first_last -- sorted_imports -- switch_case_on_newline -- type_contents_order -- unavailable_function -- unneeded_parentheses_in_closure_argument -- untyped_error_in_catch -- vertical_parameter_alignment_on_call -- vertical_whitespace_between_cases -- vertical_whitespace_closing_braces -- vertical_whitespace_opening_braces -- yoda_condition - -disabled_rules: -- cyclomatic_complexity -- force_cast -- todo -- type_name - -included: -- Sources -- Tests - -excluded: -- Tests/LinuxMain.swift - -# Rule Configurations -conditional_returns_on_newline: - if_only: true - -explicit_type_interface: - allow_redundancy: true - excluded: - - local - -file_name: - suffix_pattern: "Ext?|\\+.*" - -file_types_order: - order: - - supporting_type - - main_type - - extension - -identifier_name: - excluded: - - id - -large_tuple: - warning: 3 - error: 5 - -line_length: 140 - -type_contents_order: - order: - - case - - [type_alias, associated_type] - - subtype - - type_property - - ib_inspectable - - instance_property - - ib_outlet - - initializer - - type_method - - view_life_cycle_method - - ib_action - - other_method - - subscript - -# Custom Rules -custom_rules: - class_name_suffix_collection_view_controller: - included: ".*.swift" - regex: 'class +\w+(?]+>)? *: +\w+CollectionViewController' - name: "Class Name Suffix View Controller" - message: "All `CollectionViewController` subclasses should end on `CollectionViewController`." - severity: warning - class_name_suffix_table_view_controller: - included: ".*.swift" - regex: 'class +\w+(?]+>)? *: +\w+TableViewController' - name: "Class Name Suffix View Controller" - message: "All `TableViewController` subclasses should end on `TableViewController`." - severity: warning - class_name_suffix_view_controller: - included: ".*.swift" - regex: 'class +\w+(?]+>)? *: +\w+ViewController' - name: "Class Name Suffix View Controller" - message: "All `ViewController` subclasses should end on `ViewController`." - severity: warning - closure_params_parantheses: - included: ".*.swift" - regex: '\{\s*\((?!self)[^):]+\)\s*in' - name: "Unnecessary Closure Params Parantheses" - message: "Don't use parantheses around non-typed parameters in a closure." - severity: warning - comment_type_note: - included: ".*.swift" - regex: '// *(?:WORKAROUND|HACK|WARNING)[:\\s]' - name: "Comment Type NOTE" - message: "Use a '// NOTE:' comment instead." - severity: warning - comment_type_refactor: - included: ".*.swift" - regex: '// *(?:TODO|NOTE)[:\\s][^\n]*(?:refactor|REFACTOR|Refactor)' - name: "Comment Type REFACTOR" - message: "Use a '// REFACTOR:' comment instead." - severity: warning - comment_type_todo: - included: ".*.swift" - regex: '// *(?:BUG|MOCK|FIXME|RELEASE|TEST)[:\\s]' - name: "Comment Type TODO" - message: "Use a '// TODO:' comment instead." - severity: warning - controller_class_name_suffix: - included: ".*.swift" - regex: 'class +\w+(?\w+)(?:<[^\>]+>)? *\{.*static let `default`(?:: *\k)? *= *\k\(.*(?<=private) init\(' - name: "Singleton Default Private Init" - message: "Singletons with a `default` object (pseudo-singletons) should not declare init methods as private." - severity: warning - singleton_shared_final: - included: ".*.swift" - regex: '(?\w+)(?:<[^\>]+>)? *\{.*static let shared(?:: *\k)? *= *\k\(' - name: "Singleton Shared Final" - message: "Singletons with a single object (`shared`) should be marked as final." - severity: warning - singleton_shared_private_init: - included: ".*.swift" - regex: 'class +(?\w+)(?:<[^\>]+>)? *\{.*static let shared(?:: *\k)? *= *\k\(.*(?<= |\t|public|internal) init\(' - name: "Singleton Shared Private Init" - message: "Singletons with a single object (`shared`) should declare their init method(s) as private." - severity: warning - singleton_shared_single_object: - included: ".*.swift" - regex: 'class +(?\w+)(?:<[^\>]+>)? *\{.*(?:static let shared(?:: *\k)? *= *\k\(.*static let \w+(?:: *\k)? *= *\k\(|static let \w+(?:: *\k)? *= *\k\(.*static let shared(?:: *\k)? *= *\k\()' - name: "Singleton Shared Single Object" - message: "Singletons with a `shared` object (real Singletons) should not have other static let properties. Use `default` instead (if needed)." - severity: warning - switch_associated_value_style: - included: ".*.swift" - regex: 'case\s+[^\(][^\n]*(?:\(let |[^\)], let)' - name: "Switch Associated Value Style" - message: "Always put the `let` in front of case – even if only one associated value captured." - severity: warning - toggle_bool: - included: ".*.swift" - regex: '(?<=\n)[ \t]*(?\w+) *= *!\k(?=\s)' - name: "Toggle Bool" - message: "Use `toggle()` instead of toggling manually." - severity: warning - too_much_indentation: - included: ".*.swift" - regex: '\n {0}[^\s\/][^\n]*[^,|&]\n+ {5,}\S|\n {4}[^\s\/][^\n]*[^,|&]\n+ {9,}\S|\n {8}[^\s\/][^\n]*[^,|&]\n+ {13,}\S|\n {12}[^\s\/][^\n]*[^,|&]\n+ {17,}\S|\n {16}[^\s\/][^\n]*[^,|&]\n+ {21,}\S|\n {20}[^\s\/][^\n]*[^,|&]\n+ {25,}\S' - name: "Too Much Indentation" - message: "Don't indent code by more than 4 whitespaces." - severity: warning - too_much_unindentation: - included: ".*.swift" - regex: ' {28}[^\s\.](.|[^\n]*[^\)][^\ ][^\}])\n+ {0,23}[^\s\/]| {24}[^\s\.](.|[^\n]*[^\)][^\ ][^\}])\n+ {0,19}[^\s\/]| {20}[^\s\.](.|[^\n]*[^\)][^\ ][^\}])\n+ {0,15}[^\s\/]| {16}[^\s\.](.|[^\n]*[^\)][^\ ][^\}])\n+ {0,11}[^\s\/]| {12}[^\s\.](.|[^\n]*[^\)][^\ ][^\}])\n+ {0,7}[^\s\/]| {8}[^\s\.](.|[^\n]*[^\)][^\ ][^\}])\n+ {0,3}[^\s\/]' - name: "Too Much Unindentation" - message: "Don't unindent code by more than 4 whitespaces." - severity: warning - tuple_index: - included: ".*.swift" - regex: '(\$\d|\w*[^\d \(\[\{])\.\d' - name: "Tuple Index" - message: "Prevent unwraping tuples by their index – define a typealias with named components instead." - severity: warning - unnecessary_case_break: - included: ".*.swift" - regex: '(case |default)(?:[^\n\}]+\n){2,}\s*break *\n|\n *\n *break(?:\n *\n|\n *\})' - name: "Unnecessary Case Break" - message: "Don't use break in switch cases – Swift breaks by default." - severity: warning - unnecessary_nil_assignment: - included: ".*.swift" - regex: 'var \S+\s*:\s*[^\s]+\?\s*=\s*nil' - name: "Unnecessary Nil Assignment" - message: "Don't assign nil as a value when defining an optional type – it's nil by default." - severity: warning - vertical_whitespaces_around_mark: - included: ".*.swift" - regex: '\/\/\s*MARK:[^\n]*(\n\n)|(\n\n\n)[ \t]*\/\/\s*MARK:|[^\s{]\n[^\n\/]*\/\/\s*MARK:' - name: "Vertical Whitespaces Around MARK:" - message: "Include a single vertical whitespace (empty line) before and none after MARK: comments." - severity: warning - view_controller_variable_naming: - included: ".*.swift" - regex: '(?:let|var) +\w*(?:vc|VC|Vc|viewC|viewController|ViewController) *=' - name: "View Controller Variable Naming" - message: "Always name your view controller variables with the suffix `ViewCtrl`." - severity: warning - whitespace_around_range_operators: - included: ".*.swift" - regex: '\w\.\.[<\.]\w' - name: "Whitespace around Range Operators" - message: "A range operator should be surrounded by a single whitespace." - severity: warning - whitespace_comment_start: - included: ".*.swift" - regex: '[^:#\]\}\)][^:#\]\}\)]\/\/[^\s\/]' - name: "Whitespace Comment Start" - message: "A comment should always start with a whitespace." - severity: warning diff --git a/CHANGELOG.md b/CHANGELOG.md index 44c7619..aa38e83 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Security - None. +## [0.4.0] - 2020-11-21 +### Added +- Microya now supports Combine publishers, just call `publisher(on:decodeBodyTo:)` or `publisher(on:)` instead of `performRequest(on:decodeBodyTo:)` or `performRequest(on:)` and you'll get an `AnyPublisher` request stream to subscribe to. In success cases you will receive the decoded typed object, in error cases an `ApiError` object exactly like within the `performRequest` completion closure. But instead of a `Result` type you can use `sink` or `catch` from the Combine framework. +### Changed +- The `queryParameters` is no longer of type `[String: String]`, but `[String: QueryParameterValue]` now. Existing code like `["search": searchTerm]` will need to be updated to `["search": .string(searchTerm)]`. Apart from `.string` this now also allows specifying an array of strings like so: `["tags": .array(userSelectedTags)]`. String & array literals are supported directly, e.g. `["sort": "createdAt"]` or `["sort": ["createdAt", "id"]]`. + ## [0.3.0] - 2020-11-03 ### Added - New `ApiProvider` type encapsulating different request methods with support for plugins. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7565209..8c372df 100755 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,8 +10,7 @@ This section will tell you how you can get started contributing to Microya. Before you start developing, please make sure you have the following tools installed on your machine: -- Xcode 10.0+ -- [SwiftLint](https://github.com/realm/SwiftLint) +- Xcode 12.2+ ### Commit Messages diff --git a/Package.swift b/Package.swift index cabd19b..1bdfc95 100755 --- a/Package.swift +++ b/Package.swift @@ -2,16 +2,16 @@ import PackageDescription let package = Package( - name: "Microya", - platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13)], - products: [ - .library(name: "Microya", targets: ["Microya"]) - ], - targets: [ - .target(name: "Microya"), - .testTarget( - name: "MicroyaTests", - dependencies: ["Microya"] - ) - ] + name: "Microya", + platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13)], + products: [ + .library(name: "Microya", targets: ["Microya"]) + ], + targets: [ + .target(name: "Microya"), + .testTarget( + name: "MicroyaTests", + dependencies: ["Microya"] + ), + ] ) diff --git a/README.md b/README.md index 245bad7..125fa39 100755 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ alt="codebeat badge"> - Version: 0.3.0 + Version: 0.4.0 Swift: 5.3 ( +performRequest( on endpoint: EndpointType, - decodeBodyTo: T, - completion: @escaping (Result>) -> Void + decodeBodyTo: ResultType.Type, + completion: @escaping (Result>) -> Void ) /// Performs the request for the chosen endpoint synchronously (waits for the result) and returns the result. @@ -193,7 +182,7 @@ There's also extra methods for endpoints where you don't expect a response body: ```swift /// Performs the asynchronous request for the chosen write-only endpoint and calls the completion closure with the result. -performRequest(on endpoint: EndpointType, completion: @escaping (Result>) -> Void) +performRequest(on endpoint: EndpointType, completion: @escaping (Result>) -> Void) /// Performs the request for the chosen write-only endpoint synchronously (waits for the result). performRequestAndWait(on endpoint: EndpointType) -> Result> @@ -217,7 +206,7 @@ provider.performRequest(on: endpoint, decodeBodyTo: [String: String].self) { res } } -// OR, if you prefere a synchronous call, use the `AndWait` variant +// OR, if you prefer a synchronous call, use the `AndWait` variant switch provider.performRequestAndWait(on: endpoint, decodeBodyTo: [String: String].self) { case let .success(translationsByLanguage): @@ -228,7 +217,7 @@ case let .failure(apiError): } ``` -Note that you can also use the throwing `get()` function of Swift 5's `Result` type instead of using a `switch` statement: +Note that you can also use the throwing `get()` function of Swift 5's `Result` type instead of using a `switch`: ```Swift provider.performRequest(on: endpoint, decodeBodyTo: [String: String].self) { result in @@ -236,7 +225,7 @@ provider.performRequest(on: endpoint, decodeBodyTo: [String: String].self) { res // use the already decoded `[String: String]` result } -// OR, if you prefere a synchronous call, use the `AndWait` variant +// OR, if you prefer a synchronous call, use the `AndWait` variant let translationsByLanguage = try provider.performRequestAndWait(on: endpoint, decodeBodyTo: [String: String].self).get() // use the already decoded `[String: String]` result @@ -244,6 +233,38 @@ let translationsByLanguage = try provider.performRequestAndWait(on: endpoint, de There's even useful functional methods defined on the `Result` type like `map()`, `flatMap()` or `mapError()` and `flatMapError()`. See the "Transforming Result" section in [this](https://www.hackingwithswift.com/articles/161/how-to-use-result-in-swift) article for more information. +### Combine Support + + `performRequest(on:decodeBodyTo:)` or `performRequest()` + +If you are using Combine in your project (e.g. because you're using SwiftUI), you might want to replace the calls to `performRequest(on:decodeBodyTo:)` or `performRequest(on:)` with the Combine calls `publisher(on:decodeBodyTo:)` or `publisher(on:)`. This will give you an `AnyPublisher` request stream to subscribe to. In success cases you will receive the decoded typed object, in error cases an `ApiError` object exactly like within the `performRequest` completion closure. But instead of a `Result` type you can use `sink` or `catch` from the Combine framework. + +For example, the usage with Combine might look something like this: + +```Swift +var cancellables: Set = [] + +provider.publisher(on: endpoint, decodeBodyTo: TranslationsResponse.self) + .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main) + .subscribe(on: DispatchQueue.global()) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { _ in } + receiveValue: { (translationsResponse: TranslationsResponse) in + // do something with the success response object + } + ) + .catch { apiError in + switch apiError { + case let .clientError(statusCode, clientError): + // show an alert to customer with status code & data from clientError body + default: + logger.handleApiError(apiError) + } + } + .store(in: &cancellables) +``` + ### Plugins The initializer of `ApiProvider` accepts an array of `Plugin` objects. You can implement your own plugins or use one of the existing ones in the [Plugins](https://github.com/Flinesoft/Microya/tree/main/Sources/Microya/Plugins) directory. Here's are the callbacks a custom `Plugin` subclass can override: @@ -308,7 +329,7 @@ public var headers: [String: String] { ] } -public var queryParameters: [String: String] { [:] } +public var queryParameters: [String: QueryParameterValue] { [:] } ``` So technically, the `Endpoint` type only requires you to specify the following 4 things: diff --git a/Sources/Microya/Core/ApiError.swift b/Sources/Microya/Core/ApiError.swift index 0050aa3..cbf1a5a 100644 --- a/Sources/Microya/Core/ApiError.swift +++ b/Sources/Microya/Core/ApiError.swift @@ -1,28 +1,28 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif /// Collection of all possible exception that can be thrown when using `JsonApi`. public enum ApiError: Error { - /// The request was sent, but the server response was not received. Typically an issue with the internet connection. - case noResponseReceived(error: Error?) + /// The request was sent, but the server response was not received. Typically an issue with the internet connection. + case noResponseReceived(error: Error?) - /// The request was sent and the server responded, but the response did not include any body although a body was requested. - case noDataInResponse(statusCode: Int) + /// The request was sent and the server responded, but the response did not include any body although a body was requested. + case noDataInResponse(statusCode: Int) - /// The request was sent and the server responded with a body, but the conversion of the body to the given type failed. - case responseDataConversionFailed(type: String, error: Error) + /// The request was sent and the server responded with a body, but the conversion of the body to the given type failed. + case responseDataConversionFailed(type: String, error: Error) - /// The request was sent and the server responded, but the server reports that something is wrong with the request. - case clientError(statusCode: Int, clientError: ClientErrorType?) + /// The request was sent and the server responded, but the server reports that something is wrong with the request. + case clientError(statusCode: Int, clientError: ClientErrorType?) - /// The request was sent and the server responded, but there seems to be an error which needs to be fixed on the server. - case serverError(statusCode: Int) + /// The request was sent and the server responded, but there seems to be an error which needs to be fixed on the server. + case serverError(statusCode: Int) - /// The request was sent and the server responded, but with an unexpected status code. - case unexpectedStatusCode(statusCode: Int) + /// The request was sent and the server responded, but with an unexpected status code. + case unexpectedStatusCode(statusCode: Int) - /// Server responded with a non HTTP response, although an HTTP request was made. Either a bug in `JsonApi` or on the server side. - case unexpectedResponseType(response: URLResponse) + /// Server responded with a non HTTP response, although an HTTP request was made. Either a bug in `JsonApi` or on the server side. + case unexpectedResponseType(response: URLResponse) } diff --git a/Sources/Microya/Core/ApiProvider.swift b/Sources/Microya/Core/ApiProvider.swift index c498164..6e65006 100644 --- a/Sources/Microya/Core/ApiProvider.swift +++ b/Sources/Microya/Core/ApiProvider.swift @@ -1,163 +1,256 @@ +#if canImport(Combine) + import Combine +#endif import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif /// The API provider class to make the requests on. open class ApiProvider { - /// The Result received, either the expected `Decodable` response object or a `JsonApiError` case. - public typealias TypedResult = Result> + /// The Result received, either the expected `Decodable` response object or a `JsonApiError` case. + public typealias TypedResult = Result> - /// The lower level Result structure received directly from the native `URLSession` data task calls. - public typealias URLSessionResult = (data: Data?, response: URLResponse?, error: Error?) + /// The lower level Result structure received directly from the native `URLSession` data task calls. + public typealias URLSessionResult = (data: Data?, response: URLResponse?, error: Error?) - /// The plugins to apply per request. - public let plugins: [Plugin] + /// The plugins to apply per request. + public let plugins: [Plugin] - /// Initializes a new API provider with the given plugins applied to every request. - public init(plugins: [Plugin] = []) { - self.plugins = plugins - } + /// Initializes a new API provider with the given plugins applied to every request. + public init( + plugins: [Plugin] = [] + ) { + self.plugins = plugins + } - /// Performs the asynchronous request for the chosen write-only endpoint and calls the completion closure with the result. + #if canImport(Combine) + /// Returns a publisher which performs a request to the server when new values are requested. /// Returns a `EmptyBodyResponse` on success. /// - /// - WARNING: Do not use this if you expect a body response, use `performRequest(decodeBodyTo:complation:)` instead. - public func performRequest(on endpoint: EndpointType, completion: @escaping (TypedResult) -> Void) { - self.performRequest(on: endpoint, decodeBodyTo: EmptyBodyResponse.self, completion: completion) + /// - WARNING: Do not use this if you expect a body response, use `publisher(on:decodeBodyTo:)` instead. + public func publisher( + on endpoint: EndpointType + ) -> AnyPublisher> { + self.publisher(on: endpoint, decodeBodyTo: EmptyBodyResponse.self) } - /// Performs the request for the chosen write-only endpoint synchronously (waits for the result). - /// Returns a `EmptyBodyResponse` on success. - /// - /// - WARNING: Do not use this if you expect a body response, use `performRequestAndWait(decodeBodyTo:)` instead. - /// - NOTE: Calling this will block the current thread until the result is available. Use `performRequest` instead for an async call. - public func performRequestAndWait(on endpoint: EndpointType) -> TypedResult { - self.performRequestAndWait(on: endpoint, decodeBodyTo: EmptyBodyResponse.self) - } - - /// Performs the asynchronous request for the chosen endpoint and calls the completion closure with the result. + /// Returns a publisher which performs a request to the server when new values are requested. /// Specify the expected result type as the `Decodable` generic type. - public func performRequest( - on endpoint: EndpointType, - decodeBodyTo: ResultType.Type, - completion: @escaping (TypedResult) -> Void - ) { - var request: URLRequest = endpoint.buildRequest() - - for plugin in plugins { - plugin.modifyRequest(&request, endpoint: endpoint) - } + public func publisher( + on endpoint: EndpointType, + decodeBodyTo: ResultType.Type + ) -> AnyPublisher> { + var request: URLRequest = endpoint.buildRequest() - for plugin in plugins { - plugin.willPerformRequest(request, endpoint: endpoint) + for plugin in plugins { + plugin.modifyRequest(&request, endpoint: endpoint) + } + + for plugin in plugins { + plugin.willPerformRequest(request, endpoint: endpoint) + } + + var urlSessionResult: URLSessionResult? + + return URLSession.shared.dataTaskPublisher(for: request) + .mapError { (urlError) -> ApiError in + urlSessionResult = (data: nil, response: nil, error: urlError) + let apiError: ApiError = self.mapToClientErrorType(error: urlError) + let typedResult: TypedResult = .failure(apiError) + + for plugin in self.plugins { + plugin.didPerformRequest(urlSessionResult: urlSessionResult!, typedResult: typedResult, endpoint: endpoint) + } + + return apiError } + .tryMap { (data: Data, response: URLResponse) -> ResultType in + urlSessionResult = (data: data, response: response, error: nil) + let resultType: ResultType = try self.decodeBodyToResultType( + data: data, + response: response, + endpoint: endpoint + ) - let dataTask = URLSession.shared.dataTask(with: request) { data, response, error in - let urlSessionResult: URLSessionResult = (data: data, response: response, error: error) - let typedResult: TypedResult = self.decodeBody(from: urlSessionResult, endpoint: endpoint) + for plugin in self.plugins { + plugin.didPerformRequest( + urlSessionResult: urlSessionResult!, + typedResult: .success(resultType), + endpoint: endpoint + ) + } - for plugin in self.plugins { - plugin.didPerformRequest(urlSessionResult: urlSessionResult, typedResult: typedResult, endpoint: endpoint) - } + return resultType + } + .mapError { error in + let apiError = error as! ApiError + let urlSessionResult: URLSessionResult = urlSessionResult ?? (data: nil, response: nil, error: nil) + let typedResult: TypedResult = .failure(apiError) + + for plugin in self.plugins { + plugin.didPerformRequest(urlSessionResult: urlSessionResult, typedResult: typedResult, endpoint: endpoint) + } - completion(typedResult) + return apiError } + .eraseToAnyPublisher() + } + #endif + + /// Performs the asynchronous request for the chosen write-only endpoint and calls the completion closure with the result. + /// Returns a `EmptyBodyResponse` on success. + /// + /// - WARNING: Do not use this if you expect a body response, use `performRequest(on:decodeBodyTo:complation:)` instead. + public func performRequest(on endpoint: EndpointType, completion: @escaping (TypedResult) -> Void) + { + self.performRequest(on: endpoint, decodeBodyTo: EmptyBodyResponse.self, completion: completion) + } + + /// Performs the request for the chosen write-only endpoint synchronously (waits for the result). + /// Returns a `EmptyBodyResponse` on success. + /// + /// - WARNING: Do not use this if you expect a body response, use `performRequestAndWait(on:decodeBodyTo:)` instead. + /// - NOTE: Calling this will block the current thread until the result is available. Use `performRequest` instead for an async call. + public func performRequestAndWait(on endpoint: EndpointType) -> TypedResult { + self.performRequestAndWait(on: endpoint, decodeBodyTo: EmptyBodyResponse.self) + } + + /// Performs the asynchronous request for the chosen endpoint and calls the completion closure with the result. + /// Specify the expected result type as the `Decodable` generic type. + public func performRequest( + on endpoint: EndpointType, + decodeBodyTo: ResultType.Type, + completion: @escaping (TypedResult) -> Void + ) { + var request: URLRequest = endpoint.buildRequest() - dataTask.resume() + for plugin in plugins { + plugin.modifyRequest(&request, endpoint: endpoint) } - /// Performs the request for the chosen endpoint synchronously (waits for the result) and returns the result. - /// Specify the expected result type as the `Decodable` generic type. - /// - /// - NOTE: Calling this will block the current thread until the result is available. Use `performRequest` instead for an asyn call. - public func performRequestAndWait( - on endpoint: EndpointType, - decodeBodyTo bodyType: ResultType.Type - ) -> TypedResult { - let dispatchGroup = DispatchGroup() - dispatchGroup.enter() - - var result: TypedResult? - - self.performRequest(on: endpoint, decodeBodyTo: bodyType) { (asyncResult: TypedResult) in - result = asyncResult - dispatchGroup.leave() - } + for plugin in plugins { + plugin.willPerformRequest(request, endpoint: endpoint) + } + + let dataTask = URLSession.shared.dataTask(with: request) { data, response, error in + let urlSessionResult: URLSessionResult = (data: data, response: response, error: error) + let typedResult: TypedResult = self.decodeBody(from: urlSessionResult, endpoint: endpoint) - dispatchGroup.wait() + for plugin in self.plugins { + plugin.didPerformRequest(urlSessionResult: urlSessionResult, typedResult: typedResult, endpoint: endpoint) + } - return result! + completion(typedResult) } - private func decodeBody( - from urlSessionResult: URLSessionResult, - endpoint: EndpointType - ) -> TypedResult { - if let error = urlSessionResult.error { - return .failure(ApiError.noResponseReceived(error: error)) - } + dataTask.resume() + } - guard let response = urlSessionResult.response else { - return .failure(ApiError.noResponseReceived(error: nil)) - } + /// Performs the request for the chosen endpoint synchronously (waits for the result) and returns the result. + /// Specify the expected result type as the `Decodable` generic type. + /// + /// - NOTE: Calling this will block the current thread until the result is available. Use `performRequest` instead for an asyn call. + public func performRequestAndWait( + on endpoint: EndpointType, + decodeBodyTo bodyType: ResultType.Type + ) -> TypedResult { + let dispatchGroup = DispatchGroup() + dispatchGroup.enter() - guard let httpResponse = response as? HTTPURLResponse else { - return .failure(ApiError.unexpectedResponseType(response: response)) - } + var result: TypedResult? - switch httpResponse.statusCode { - case 200 ..< 300: - if ResultType.self == EmptyBodyResponse.self { - return .success(EmptyBodyResponse() as! ResultType) - } - - guard let data = urlSessionResult.data else { - return .failure(ApiError.noDataInResponse(statusCode: httpResponse.statusCode)) - } - - do { - return .success(try endpoint.decoder.decode(ResultType.self, from: data)) - } catch { - return .failure( - ApiError.responseDataConversionFailed( - type: String(describing: ResultType.self), - error: error - ) - ) - } - - case 400 ..< 500: - if ResultType.self == EmptyBodyResponse.self { - return .failure( - ApiError.clientError( - statusCode: httpResponse.statusCode, - clientError: nil - ) - ) - } - - guard let data = urlSessionResult.data else { - return .failure(ApiError.noDataInResponse(statusCode: httpResponse.statusCode)) - } - - let clientError = try? endpoint.decoder.decode(EndpointType.ClientErrorType.self, from: data) - return .failure( - ApiError.clientError( - statusCode: httpResponse.statusCode, - clientError: clientError - ) - ) + self.performRequest(on: endpoint, decodeBodyTo: bodyType) { (asyncResult: TypedResult) in + result = asyncResult + dispatchGroup.leave() + } - case 500 ..< 600: - return .failure( - ApiError.serverError(statusCode: httpResponse.statusCode) - ) + dispatchGroup.wait() - default: - return .failure( - ApiError.unexpectedStatusCode(statusCode: httpResponse.statusCode) - ) - } + return result! + } + + private func decodeBody( + from urlSessionResult: URLSessionResult, + endpoint: EndpointType + ) -> TypedResult { + if let dataTaskError = urlSessionResult.error { + return .failure(mapToClientErrorType(error: dataTaskError)) + } + + do { + return try .success( + decodeBodyToResultType(data: urlSessionResult.data, response: urlSessionResult.response, endpoint: endpoint) + ) + } + catch { + return .failure(error as! ApiError) + } + } + + private func mapToClientErrorType(error: Error) -> ApiError { + .noResponseReceived(error: error) + } + + private func decodeBodyToResultType( + data: Data?, + response: URLResponse?, + endpoint: EndpointType + ) throws -> ResultType { + guard let response = response else { + throw ApiError.noResponseReceived(error: nil) + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw ApiError.unexpectedResponseType(response: response) + } + + switch httpResponse.statusCode { + case 200..<300: + if ResultType.self == EmptyBodyResponse.self { + return EmptyBodyResponse() as! ResultType + } + + guard let data = data else { + throw ApiError.noDataInResponse(statusCode: httpResponse.statusCode) + } + + do { + return try endpoint.decoder.decode(ResultType.self, from: data) + } + catch { + throw ApiError + .responseDataConversionFailed( + type: String(describing: ResultType.self), + error: error + ) + } + + case 400..<500: + if ResultType.self == EmptyBodyResponse.self { + throw ApiError + .clientError( + statusCode: httpResponse.statusCode, + clientError: nil + ) + } + + guard let data = data else { + throw ApiError.noDataInResponse(statusCode: httpResponse.statusCode) + } + + let clientError = try? endpoint.decoder.decode(EndpointType.ClientErrorType.self, from: data) + throw ApiError + .clientError( + statusCode: httpResponse.statusCode, + clientError: clientError + ) + + case 500..<600: + throw ApiError.serverError(statusCode: httpResponse.statusCode) + + default: + throw ApiError.unexpectedStatusCode(statusCode: httpResponse.statusCode) } + } } diff --git a/Sources/Microya/Core/Endpoint.swift b/Sources/Microya/Core/Endpoint.swift index 74998ff..0778b3a 100644 --- a/Sources/Microya/Core/Endpoint.swift +++ b/Sources/Microya/Core/Endpoint.swift @@ -1,93 +1,93 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif /// Helper type for request where no body is expected as part of the response. -public struct EmptyBodyResponse: Decodable { /* no body needed */ } +public struct EmptyBodyResponse: Decodable { /* no body needed */ } /// The protocol which defines the structure of an API endpoint. public protocol Endpoint { - /// The error body type the server responds with for any client errors. - associatedtype ClientErrorType: Decodable + /// The error body type the server responds with for any client errors. + associatedtype ClientErrorType: Decodable - /// The JSON decoder to be used for decoding. - var decoder: JSONDecoder { get } + /// The JSON decoder to be used for decoding. + var decoder: JSONDecoder { get } - /// The JSON encoder to be used for encoding. - var encoder: JSONEncoder { get } + /// The JSON encoder to be used for encoding. + var encoder: JSONEncoder { get } - /// The common base URL of the API endpoints. - var baseUrl: URL { get } + /// The common base URL of the API endpoints. + var baseUrl: URL { get } - /// The headers to be sent per request. - var headers: [String: String] { get } + /// The headers to be sent per request. + var headers: [String: String] { get } - /// The subpath to be added to the base URL. - var subpath: String { get } + /// The subpath to be added to the base URL. + var subpath: String { get } - /// The HTTP method to be used for the request. - var method: HttpMethod { get } + /// The HTTP method to be used for the request. + var method: HttpMethod { get } - /// The URL query parameters to be sent (part after ? in URLs, e.g. google.com?query=Harry+Potter). - var queryParameters: [String: String] { get } + /// The URL query parameters to be sent (part after ? in URLs, e.g. google.com?query=Harry+Potter). + var queryParameters: [String: QueryParameterValue] { get } } extension Endpoint { - func buildRequest() -> URLRequest { - var request = URLRequest(url: buildRequestUrl()) + func buildRequest() -> URLRequest { + var request = URLRequest(url: buildRequestUrl()) - method.apply(to: &request) + method.apply(to: &request) - for (field, value) in headers { - request.setValue(value, forHTTPHeaderField: field) - } - - return request + for (field, value) in headers { + request.setValue(value, forHTTPHeaderField: field) } - private func buildRequestUrl() -> URL { - var urlComponents = URLComponents( - url: baseUrl.appendingPathComponent(subpath), - resolvingAgainstBaseURL: false - )! - - if !queryParameters.isEmpty { - urlComponents.queryItems = [] - for (key, value) in queryParameters { - urlComponents.queryItems?.append(URLQueryItem(name: key, value: value)) - } - } + return request + } + + private func buildRequestUrl() -> URL { + var urlComponents = URLComponents( + url: baseUrl.appendingPathComponent(subpath), + resolvingAgainstBaseURL: false + )! - return urlComponents.url! + if !queryParameters.isEmpty { + urlComponents.queryItems = [] + for (key, value) in queryParameters { + for stringValue in value.values { + urlComponents.queryItems?.append(URLQueryItem(name: key, value: stringValue)) + } + } } -} -// swiftlint:disable missing_docs + return urlComponents.url! + } +} // Provide sensible default to effectively make some of the protocol requirements optional. extension Endpoint { - public var decoder: JSONDecoder { - JSONDecoder() - } - - public var encoder: JSONEncoder { - JSONEncoder() - } - - public var plugins: [Plugin] { - [] - } - - public var headers: [String: String] { - [ - "Content-Type": "application/json", - "Accept": "application/json", - "Accept-Language": Locale.current.languageCode ?? "en" - ] - } - - public var queryParameters: [String: String] { - [:] - } + public var decoder: JSONDecoder { + JSONDecoder() + } + + public var encoder: JSONEncoder { + JSONEncoder() + } + + public var plugins: [Plugin] { + [] + } + + public var headers: [String: String] { + [ + "Content-Type": "application/json", + "Accept": "application/json", + "Accept-Language": Locale.current.languageCode ?? "en", + ] + } + + public var queryParameters: [String: QueryParameterValue] { + [:] + } } diff --git a/Sources/Microya/Core/HttpMethod.swift b/Sources/Microya/Core/HttpMethod.swift index 8b18e18..cb1fc3d 100644 --- a/Sources/Microya/Core/HttpMethod.swift +++ b/Sources/Microya/Core/HttpMethod.swift @@ -1,37 +1,37 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif /// The relevant HTTP request methods defined in the HTTP standard. public enum HttpMethod { - /// The GET HTTP method. - case get + /// The GET HTTP method. + case get - /// The POST HTTP method. Required body data to be sent. - case post(body: Data) + /// The POST HTTP method. Required body data to be sent. + case post(body: Data) - /// The PATCH HTTP method. Required body data to be sent. - case patch(body: Data) + /// The PATCH HTTP method. Required body data to be sent. + case patch(body: Data) - /// The DELETE HTTP method. - case delete + /// The DELETE HTTP method. + case delete - func apply(to request: inout URLRequest) { - switch self { - case .get: - request.httpMethod = "GET" + func apply(to request: inout URLRequest) { + switch self { + case .get: + request.httpMethod = "GET" - case let .post(body): - request.httpMethod = "POST" - request.httpBody = body + case let .post(body): + request.httpMethod = "POST" + request.httpBody = body - case let .patch(body): - request.httpMethod = "PATCH" - request.httpBody = body + case let .patch(body): + request.httpMethod = "PATCH" + request.httpBody = body - case .delete: - request.httpMethod = "DELETE" - } + case .delete: + request.httpMethod = "DELETE" } + } } diff --git a/Sources/Microya/Core/Plugin.swift b/Sources/Microya/Core/Plugin.swift index 5b46bb8..6e6dbdb 100644 --- a/Sources/Microya/Core/Plugin.swift +++ b/Sources/Microya/Core/Plugin.swift @@ -1,6 +1,6 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif /// A Plugin receives callbacks to perform side effects wherever a request is sent or received. @@ -10,19 +10,19 @@ import FoundationNetworking /// - hide and show a network activity indicator /// - inject additional information into a request (like for authentication) open class Plugin { - /// Initializes a new plugin object. - public init() {} + /// Initializes a new plugin object. + public init() {} - /// Called to modify a request before sending. - open func modifyRequest(_ request: inout URLRequest, endpoint: EndpointType) { /* no-op */ } + /// Called to modify a request before sending. + open func modifyRequest(_ request: inout URLRequest, endpoint: EndpointType) { /* no-op */ } - /// Called immediately before a request is sent. - open func willPerformRequest(_ request: URLRequest, endpoint: EndpointType) { /* no-op */ } + /// Called immediately before a request is sent. + open func willPerformRequest(_ request: URLRequest, endpoint: EndpointType) { /* no-op */ } - /// Called after a response has been received & decoded, but before calling the completion handler. - open func didPerformRequest( - urlSessionResult: ApiProvider.URLSessionResult, - typedResult: ApiProvider.TypedResult, - endpoint: EndpointType - ) { /* no-op */ } + /// Called after a response has been received & decoded, but before calling the completion handler. + open func didPerformRequest( + urlSessionResult: ApiProvider.URLSessionResult, + typedResult: ApiProvider.TypedResult, + endpoint: EndpointType + ) { /* no-op */ } } diff --git a/Sources/Microya/Core/QueryParameterValue.swift b/Sources/Microya/Core/QueryParameterValue.swift new file mode 100644 index 0000000..8930121 --- /dev/null +++ b/Sources/Microya/Core/QueryParameterValue.swift @@ -0,0 +1,36 @@ +import Foundation + +/// The value of a query parameter. Supports initialization via string & array literals. +public enum QueryParameterValue { + /// The singular string entry. + case string(String) + + /// The array string entry. + case array([String]) + + var values: [String] { + switch self { + case let .string(value): + return [value] + + case let .array(values): + return values + } + } +} + +extension QueryParameterValue: ExpressibleByStringLiteral { + public init( + stringLiteral value: String + ) { + self = .string(value) + } +} + +extension QueryParameterValue: ExpressibleByArrayLiteral { + public init( + arrayLiteral elements: String... + ) { + self = .array(Array(elements)) + } +} diff --git a/Sources/Microya/Plugins/HttpBasicAuthPlugin.swift b/Sources/Microya/Plugins/HttpBasicAuthPlugin.swift index ebfb44a..a3ef3c1 100644 --- a/Sources/Microya/Plugins/HttpBasicAuthPlugin.swift +++ b/Sources/Microya/Plugins/HttpBasicAuthPlugin.swift @@ -1,20 +1,22 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif /// Provides support for the HTTP "Authorization" header based on the "Basic" scheme. /// See also: https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication public class HttpBasicAuthPlugin: Plugin { - private let tokenClosure: () -> String? + private let tokenClosure: () -> String? - public init(tokenClosure: @escaping () -> String?) { - self.tokenClosure = tokenClosure - } + public init( + tokenClosure: @escaping () -> String? + ) { + self.tokenClosure = tokenClosure + } - override public func modifyRequest(_ request: inout URLRequest, endpoint: EndpointType) { - if let token = tokenClosure() { - request.addValue("Basic \(token)", forHTTPHeaderField: "Authorization") - } + override public func modifyRequest(_ request: inout URLRequest, endpoint: EndpointType) { + if let token = tokenClosure() { + request.addValue("Basic \(token)", forHTTPHeaderField: "Authorization") } + } } diff --git a/Sources/Microya/Plugins/ProgressIndicatorPlugin.swift b/Sources/Microya/Plugins/ProgressIndicatorPlugin.swift index 7386c7c..abaf215 100644 --- a/Sources/Microya/Plugins/ProgressIndicatorPlugin.swift +++ b/Sources/Microya/Plugins/ProgressIndicatorPlugin.swift @@ -1,6 +1,6 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif /// Allows to show & hide a progress indicator in a given way provided by a closure whenever there are any ongoing requests. @@ -8,33 +8,36 @@ import FoundationNetworking /// An object of this type can be used for multiple endpoints to show a progress indicator as long as one of them is still ongoing. /// Or separate objects could be used for each endpoint to show a different and independent progress indicator per endpoint. public class ProgressIndicatorPlugin: Plugin { - private var ongoingRequests: Int = 0 + private var ongoingRequests: Int = 0 - private let showIndicator: () -> Void - private let hideIndicator: () -> Void + private let showIndicator: () -> Void + private let hideIndicator: () -> Void - public init(showIndicator: @escaping () -> Void, hideIndicator: @escaping () -> Void) { - self.showIndicator = showIndicator - self.hideIndicator = hideIndicator - } + public init( + showIndicator: @escaping () -> Void, + hideIndicator: @escaping () -> Void + ) { + self.showIndicator = showIndicator + self.hideIndicator = hideIndicator + } - override public func willPerformRequest(_ request: URLRequest, endpoint: EndpointType) { - ongoingRequests += 1 + override public func willPerformRequest(_ request: URLRequest, endpoint: EndpointType) { + ongoingRequests += 1 - if ongoingRequests == 1 { - showIndicator() - } + if ongoingRequests == 1 { + showIndicator() } + } - override public func didPerformRequest( - urlSessionResult: ApiProvider.URLSessionResult, - typedResult: ApiProvider.TypedResult, - endpoint: EndpointType - ) { - ongoingRequests -= 1 + override public func didPerformRequest( + urlSessionResult: ApiProvider.URLSessionResult, + typedResult: ApiProvider.TypedResult, + endpoint: EndpointType + ) { + ongoingRequests -= 1 - if ongoingRequests == 0 { - hideIndicator() - } + if ongoingRequests == 0 { + hideIndicator() } + } } diff --git a/Sources/Microya/Plugins/RequestLoggerPlugin.swift b/Sources/Microya/Plugins/RequestLoggerPlugin.swift index 25ecef8..28564c8 100644 --- a/Sources/Microya/Plugins/RequestLoggerPlugin.swift +++ b/Sources/Microya/Plugins/RequestLoggerPlugin.swift @@ -1,17 +1,19 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif /// Allows to log requests the given way provided by a closure before the requests are sent. public class RequestLoggerPlugin: Plugin { - private let logClosure: (URLRequest) -> Void + private let logClosure: (URLRequest) -> Void - public init(logClosure: @escaping (URLRequest) -> Void) { - self.logClosure = logClosure - } + public init( + logClosure: @escaping (URLRequest) -> Void + ) { + self.logClosure = logClosure + } - override public func willPerformRequest(_ request: URLRequest, endpoint: EndpointType) { - logClosure(request) - } + override public func willPerformRequest(_ request: URLRequest, endpoint: EndpointType) { + logClosure(request) + } } diff --git a/Sources/Microya/Plugins/ResponseLoggerPlugin.swift b/Sources/Microya/Plugins/ResponseLoggerPlugin.swift index 79d9e50..584dc14 100644 --- a/Sources/Microya/Plugins/ResponseLoggerPlugin.swift +++ b/Sources/Microya/Plugins/ResponseLoggerPlugin.swift @@ -1,22 +1,24 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif /// Allows to log responses the given way provided by a closure after the response has been received, /// but before the completion block is called. public class ResponseLoggerPlugin: Plugin { - private let logClosure: (ApiProvider.URLSessionResult) -> Void + private let logClosure: (ApiProvider.URLSessionResult) -> Void - public init(logClosure: @escaping (ApiProvider.URLSessionResult) -> Void) { - self.logClosure = logClosure - } + public init( + logClosure: @escaping (ApiProvider.URLSessionResult) -> Void + ) { + self.logClosure = logClosure + } - override public func didPerformRequest( - urlSessionResult: ApiProvider.URLSessionResult, - typedResult: ApiProvider.TypedResult, - endpoint: EndpointType - ) { - logClosure(urlSessionResult) - } + override public func didPerformRequest( + urlSessionResult: ApiProvider.URLSessionResult, + typedResult: ApiProvider.TypedResult, + endpoint: EndpointType + ) { + logClosure(urlSessionResult) + } } diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index 0f02d44..145b634 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -1,22 +1,26 @@ // Generated using Sourcery 1.0.0 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT - @testable import MicroyaTests import XCTest // swiftlint:disable line_length file_length extension MicroyaIntegrationTests { - static var allTests: [(String, (MicroyaIntegrationTests) -> () throws -> Void)] = [ - ("testIndex", testIndex), - ("testPost", testPost), - ("testGet", testGet), - ("testPatch", testPatch), - ("testDelete", testDelete) - ] + static var allTests: [(String, (MicroyaIntegrationTests) -> () throws -> Void)] = [ + ("testIndex", testIndex), + ("testPost", testPost), + ("testGet", testGet), + ("testPatch", testPatch), + ("testDelete", testDelete), + ("testIndexCombine", testIndexCombine), + ("testPostCombine", testPostCombine), + ("testGetCombine", testGetCombine), + ("testPatchCombine", testPatchCombine), + ("testDeleteCombine", testDeleteCombine), + ] } XCTMain([ - testCase(MicroyaIntegrationTests.allTests) + testCase(MicroyaIntegrationTests.allTests) ]) diff --git a/Tests/MicroyaTests/MicroyaIntegrationTests.swift b/Tests/MicroyaTests/MicroyaIntegrationTests.swift index bc52f2b..a1ef6d0 100755 --- a/Tests/MicroyaTests/MicroyaIntegrationTests.swift +++ b/Tests/MicroyaTests/MicroyaIntegrationTests.swift @@ -1,145 +1,349 @@ +#if canImport(Combine) + import Combine +#endif #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif @testable import Microya import XCTest class MicroyaIntegrationTests: XCTestCase { - private let fooBarID: String = "aBcDeF012-gHiJkLMnOpQ3456-RsTuVwXyZ789" + private let fooBarID: String = "aBcDeF012-gHiJkLMnOpQ3456-RsTuVwXyZ789" - override func setUpWithError() throws { - try super.setUpWithError() + #if canImport(Combine) + var cancellables: Set = [] + #endif - TestDataStore.reset() - } + override func setUpWithError() throws { + try super.setUpWithError() + + #if canImport(Combine) + cancellables = [] + #endif + + TestDataStore.reset() + } + + func testIndex() throws { + let typedResponseBody = + try sampleApiProvider.performRequestAndWait( + on: .index(sortedBy: "updatedAt"), + decodeBodyTo: PostmanEchoResponse.self + ) + .get() + + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Content-Type"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept-Language"], "en") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Authorization"], "Basic abc123") + + XCTAssertEqual(TestDataStore.request?.httpMethod, "GET") + XCTAssertEqual(TestDataStore.request?.url?.path, "/get") + XCTAssertEqual(TestDataStore.request?.url?.query, "sortedBy=updatedAt") + + XCTAssertNotNil(TestDataStore.urlSessionResult?.data) + XCTAssertNil(TestDataStore.urlSessionResult?.error) + XCTAssertNotNil(TestDataStore.urlSessionResult?.response) + + XCTAssertEqual(typedResponseBody.args, ["sortedBy": "updatedAt"]) + XCTAssertEqual(typedResponseBody.headers["content-type"], "application/json") + XCTAssertEqual(typedResponseBody.headers["accept"], "application/json") + XCTAssertEqual(typedResponseBody.headers["accept-language"], "en") + XCTAssertEqual(typedResponseBody.url, "https://postman-echo.com/get?sortedBy=updatedAt") + } + + func testPost() throws { + let typedResponseBody = + try sampleApiProvider.performRequestAndWait( + on: .post(fooBar: FooBar(foo: "Lorem", bar: "Ipsum")), + decodeBodyTo: PostmanEchoResponse.self + ) + .get() + + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Content-Type"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept-Language"], "en") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Authorization"], "Basic abc123") + + XCTAssertEqual(TestDataStore.request?.httpMethod, "POST") + XCTAssertEqual(TestDataStore.request?.url?.path, "/post") + XCTAssertNil(TestDataStore.request?.url?.query) + + XCTAssertNotNil(TestDataStore.urlSessionResult?.data) + XCTAssertNil(TestDataStore.urlSessionResult?.error) + XCTAssertNotNil(TestDataStore.urlSessionResult?.response) + + XCTAssertEqual(typedResponseBody.args, [:]) + XCTAssertEqual(typedResponseBody.headers["content-type"], "application/json") + XCTAssertEqual(typedResponseBody.headers["accept"], "application/json") + XCTAssertEqual(typedResponseBody.headers["accept-language"], "en") + XCTAssertEqual(typedResponseBody.url, "https://postman-echo.com/post") + } + + func testGet() throws { + let expectation = XCTestExpectation() - func testIndex() throws { - let typedResponseBody = try sampleApiProvider.performRequestAndWait( - on: .index(sortedBy: "updatedAt"), - decodeBodyTo: PostmanEchoResponse.self - ).get() - - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Content-Type"], "application/json") - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept"], "application/json") - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept-Language"], "en") - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Authorization"], "Basic abc123") - - XCTAssertEqual(TestDataStore.request?.httpMethod, "GET") - XCTAssertEqual(TestDataStore.request?.url?.path, "/get") - XCTAssertEqual(TestDataStore.request?.url?.query, "sortedBy=updatedAt") - - XCTAssertNotNil(TestDataStore.urlSessionResult?.data) - XCTAssertNil(TestDataStore.urlSessionResult?.error) - XCTAssertNotNil(TestDataStore.urlSessionResult?.response) - - XCTAssertEqual(typedResponseBody.args, ["sortedBy": "updatedAt"]) - XCTAssertEqual(typedResponseBody.headers["content-type"], "application/json") - XCTAssertEqual(typedResponseBody.headers["accept"], "application/json") - XCTAssertEqual(typedResponseBody.headers["accept-language"], "en") - XCTAssertEqual(typedResponseBody.url, "https://postman-echo.com/get?sortedBy=updatedAt") + XCTAssertFalse(TestDataStore.showingProgressIndicator) + + sampleApiProvider.performRequest(on: .get(fooBarID: fooBarID), decodeBodyTo: PostmanEchoResponse.self) { result in + switch result { + case .success: + XCTFail("Expected to receive error due to missing endpoint path.") + + default: + break + } + + expectation.fulfill() } - func testPost() throws { - let typedResponseBody = try sampleApiProvider.performRequestAndWait( - on: .post(fooBar: FooBar(foo: "Lorem", bar: "Ipsum")), - decodeBodyTo: PostmanEchoResponse.self - ).get() - - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Content-Type"], "application/json") - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept"], "application/json") - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept-Language"], "en") - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Authorization"], "Basic abc123") - - XCTAssertEqual(TestDataStore.request?.httpMethod, "POST") - XCTAssertEqual(TestDataStore.request?.url?.path, "/post") - XCTAssertNil(TestDataStore.request?.url?.query) - - XCTAssertNotNil(TestDataStore.urlSessionResult?.data) - XCTAssertNil(TestDataStore.urlSessionResult?.error) - XCTAssertNotNil(TestDataStore.urlSessionResult?.response) - - XCTAssertEqual(typedResponseBody.args, [:]) - XCTAssertEqual(typedResponseBody.headers["content-type"], "application/json") - XCTAssertEqual(typedResponseBody.headers["accept"], "application/json") - XCTAssertEqual(typedResponseBody.headers["accept-language"], "en") - XCTAssertEqual(typedResponseBody.url, "https://postman-echo.com/post") + XCTAssertTrue(TestDataStore.showingProgressIndicator) + wait(for: [expectation], timeout: 10) + XCTAssertFalse(TestDataStore.showingProgressIndicator) + + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Content-Type"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept-Language"], "en") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Authorization"], "Basic abc123") + + XCTAssertEqual(TestDataStore.request?.httpMethod, "GET") + XCTAssertEqual(TestDataStore.request?.url?.path, "/get/\(fooBarID)") + XCTAssertNil(TestDataStore.request?.url?.query) + } + + func testPatch() throws { + XCTAssertThrowsError( + try sampleApiProvider.performRequestAndWait( + on: .patch(fooBarID: fooBarID, fooBar: FooBar(foo: "Dolor", bar: "Amet")), + decodeBodyTo: PostmanEchoResponse.self + ) + .get() + ) + + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Content-Type"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept-Language"], "en") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Authorization"], "Basic abc123") + + XCTAssertEqual(TestDataStore.request?.httpMethod, "PATCH") + XCTAssertEqual(TestDataStore.request?.url?.path, "/patch/\(fooBarID)") + XCTAssertNil(TestDataStore.request?.url?.query) + + XCTAssertNotNil(TestDataStore.urlSessionResult?.data) + XCTAssertNil(TestDataStore.urlSessionResult?.error) + XCTAssertNotNil(TestDataStore.urlSessionResult?.response) + } + + func testDelete() throws { + let result = sampleApiProvider.performRequestAndWait(on: .delete) + + switch result { + case .success: + break + + default: + XCTFail("Expected request to succeed.") } - func testGet() throws { - let expectation = XCTestExpectation() + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Content-Type"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept-Language"], "en") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Authorization"], "Basic abc123") - XCTAssertFalse(TestDataStore.showingProgressIndicator) + XCTAssertEqual(TestDataStore.request?.httpMethod, "DELETE") + XCTAssertEqual(TestDataStore.request?.url?.path, "/delete") + XCTAssertNil(TestDataStore.request?.url?.query) - sampleApiProvider.performRequest(on: .get(fooBarID: fooBarID), decodeBodyTo: PostmanEchoResponse.self) { result in - switch result { - case .success: - XCTFail("Expected to receive error due to missing endpoint path.") + XCTAssertNotNil(TestDataStore.urlSessionResult?.data) + XCTAssertNil(TestDataStore.urlSessionResult?.error) + XCTAssertNotNil(TestDataStore.urlSessionResult?.response) + } - default: - break - } + func testIndexCombine() throws { + #if canImport(Combine) + let expectation = XCTestExpectation() - expectation.fulfill() + sampleApiProvider.publisher( + on: .index(sortedBy: "updatedAt"), + decodeBodyTo: PostmanEchoResponse.self + ) + .sink( + receiveCompletion: { _ in }, + receiveValue: { typedResponseBody in + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Content-Type"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept-Language"], "en") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Authorization"], "Basic abc123") + + XCTAssertEqual(TestDataStore.request?.httpMethod, "GET") + XCTAssertEqual(TestDataStore.request?.url?.path, "/get") + XCTAssertEqual(TestDataStore.request?.url?.query, "sortedBy=updatedAt") + + XCTAssertNotNil(TestDataStore.urlSessionResult?.data) + XCTAssertNil(TestDataStore.urlSessionResult?.error) + XCTAssertNotNil(TestDataStore.urlSessionResult?.response) + + XCTAssertEqual(typedResponseBody.args, ["sortedBy": "updatedAt"]) + XCTAssertEqual(typedResponseBody.headers["content-type"], "application/json") + XCTAssertEqual(typedResponseBody.headers["accept"], "application/json") + XCTAssertEqual(typedResponseBody.headers["accept-language"], "en") + XCTAssertEqual(typedResponseBody.url, "https://postman-echo.com/get?sortedBy=updatedAt") + + expectation.fulfill() } + ) + .store(in: &cancellables) - XCTAssertTrue(TestDataStore.showingProgressIndicator) - wait(for: [expectation], timeout: 10) - XCTAssertFalse(TestDataStore.showingProgressIndicator) + wait(for: [expectation], timeout: 10) + #endif + } - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Content-Type"], "application/json") - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept"], "application/json") - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept-Language"], "en") - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Authorization"], "Basic abc123") + func testPostCombine() throws { + #if canImport(Combine) + let expectation = XCTestExpectation() - XCTAssertEqual(TestDataStore.request?.httpMethod, "GET") - XCTAssertEqual(TestDataStore.request?.url?.path, "/get/\(fooBarID)") - XCTAssertNil(TestDataStore.request?.url?.query) - } + sampleApiProvider.publisher( + on: .post(fooBar: FooBar(foo: "Lorem", bar: "Ipsum")), + decodeBodyTo: PostmanEchoResponse.self + ) + .sink( + receiveCompletion: { _ in }, + receiveValue: { typedResponseBody in + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Content-Type"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept-Language"], "en") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Authorization"], "Basic abc123") + + XCTAssertEqual(TestDataStore.request?.httpMethod, "POST") + XCTAssertEqual(TestDataStore.request?.url?.path, "/post") + XCTAssertNil(TestDataStore.request?.url?.query) + + XCTAssertNotNil(TestDataStore.urlSessionResult?.data) + XCTAssertNil(TestDataStore.urlSessionResult?.error) + XCTAssertNotNil(TestDataStore.urlSessionResult?.response) + + XCTAssertEqual(typedResponseBody.args, [:]) + XCTAssertEqual(typedResponseBody.headers["content-type"], "application/json") + XCTAssertEqual(typedResponseBody.headers["accept"], "application/json") + XCTAssertEqual(typedResponseBody.headers["accept-language"], "en") + XCTAssertEqual(typedResponseBody.url, "https://postman-echo.com/post") + + expectation.fulfill() + } + ) + .store(in: &cancellables) + + wait(for: [expectation], timeout: 10) + #endif + } + + func testGetCombine() throws { + #if canImport(Combine) + let expectation = XCTestExpectation() + + XCTAssertFalse(TestDataStore.showingProgressIndicator) + + sampleApiProvider.publisher(on: .get(fooBarID: fooBarID), decodeBodyTo: PostmanEchoResponse.self) + .sink( + receiveCompletion: { _ in + + XCTAssertFalse(TestDataStore.showingProgressIndicator) + + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Content-Type"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept-Language"], "en") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Authorization"], "Basic abc123") - func testPatch() throws { - XCTAssertThrowsError( - try sampleApiProvider.performRequestAndWait( - on: .patch(fooBarID: fooBarID, fooBar: FooBar(foo: "Dolor", bar: "Amet")), - decodeBodyTo: PostmanEchoResponse.self - ).get() + XCTAssertEqual(TestDataStore.request?.httpMethod, "GET") + XCTAssertEqual(TestDataStore.request?.url?.path, "/get/\(self.fooBarID)") + XCTAssertNil(TestDataStore.request?.url?.query) + + expectation.fulfill() + }, + receiveValue: { typedResponseBody in + XCTFail("Expected to receive error due to missing endpoint path.") + + expectation.fulfill() + } ) + .store(in: &cancellables) - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Content-Type"], "application/json") - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept"], "application/json") - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept-Language"], "en") - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Authorization"], "Basic abc123") + XCTAssertTrue(TestDataStore.showingProgressIndicator) + wait(for: [expectation], timeout: 10) + #endif + } - XCTAssertEqual(TestDataStore.request?.httpMethod, "PATCH") - XCTAssertEqual(TestDataStore.request?.url?.path, "/patch/\(fooBarID)") - XCTAssertNil(TestDataStore.request?.url?.query) + func testPatchCombine() throws { + #if canImport(Combine) + let expectation = XCTestExpectation() - XCTAssertNotNil(TestDataStore.urlSessionResult?.data) - XCTAssertNil(TestDataStore.urlSessionResult?.error) - XCTAssertNotNil(TestDataStore.urlSessionResult?.response) - } + sampleApiProvider.publisher( + on: .patch(fooBarID: fooBarID, fooBar: FooBar(foo: "Dolor", bar: "Amet")), + decodeBodyTo: PostmanEchoResponse.self + ) + .sink( + receiveCompletion: { completion in + switch completion { + case let .failure(.clientError(statusCode, clientError)): + XCTAssertEqual(statusCode, 404) + XCTAssertNil(clientError) + + default: + XCTFail("Expected to receive a 404 API error.") + } + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Content-Type"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept-Language"], "en") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Authorization"], "Basic abc123") - func testDelete() throws { - let result = sampleApiProvider.performRequestAndWait(on: .delete) + XCTAssertEqual(TestDataStore.request?.httpMethod, "PATCH") + XCTAssertEqual(TestDataStore.request?.url?.path, "/patch/\(self.fooBarID)") + XCTAssertNil(TestDataStore.request?.url?.query) - switch result { - case .success: - break + XCTAssertNotNil(TestDataStore.urlSessionResult?.data) + XCTAssertNil(TestDataStore.urlSessionResult?.error) + XCTAssertNotNil(TestDataStore.urlSessionResult?.response) - default: - XCTFail("Expected request to succeed.") + expectation.fulfill() + }, + receiveValue: { typedResponseBody in + XCTFail("Expected call to throw an error.") } + ) + .store(in: &cancellables) - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Content-Type"], "application/json") - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept"], "application/json") - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept-Language"], "en") - XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Authorization"], "Basic abc123") + wait(for: [expectation], timeout: 10) + #endif + } - XCTAssertEqual(TestDataStore.request?.httpMethod, "DELETE") - XCTAssertEqual(TestDataStore.request?.url?.path, "/delete") - XCTAssertNil(TestDataStore.request?.url?.query) + func testDeleteCombine() throws { + #if canImport(Combine) + let expectation = XCTestExpectation() - XCTAssertNotNil(TestDataStore.urlSessionResult?.data) - XCTAssertNil(TestDataStore.urlSessionResult?.error) - XCTAssertNotNil(TestDataStore.urlSessionResult?.response) - } + sampleApiProvider.publisher(on: .delete) + .sink( + receiveCompletion: { _ in }, + receiveValue: { typedResponseBody in + + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Content-Type"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept"], "application/json") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Accept-Language"], "en") + XCTAssertEqual(TestDataStore.request?.allHTTPHeaderFields?["Authorization"], "Basic abc123") + + XCTAssertEqual(TestDataStore.request?.httpMethod, "DELETE") + XCTAssertEqual(TestDataStore.request?.url?.path, "/delete") + XCTAssertNil(TestDataStore.request?.url?.query) + + XCTAssertNotNil(TestDataStore.urlSessionResult?.data) + XCTAssertNil(TestDataStore.urlSessionResult?.error) + XCTAssertNotNil(TestDataStore.urlSessionResult?.response) + + expectation.fulfill() + } + ) + .store(in: &cancellables) + + wait(for: [expectation], timeout: 10) + #endif + } } diff --git a/Tests/MicroyaTests/Supporting/FooBar.swift b/Tests/MicroyaTests/Supporting/FooBar.swift index fb52469..e4624a3 100644 --- a/Tests/MicroyaTests/Supporting/FooBar.swift +++ b/Tests/MicroyaTests/Supporting/FooBar.swift @@ -1,6 +1,6 @@ import Foundation struct FooBar: Encodable { - let foo: String - let bar: String + let foo: String + let bar: String } diff --git a/Tests/MicroyaTests/Supporting/PostmanEchoEndpoint.swift b/Tests/MicroyaTests/Supporting/PostmanEchoEndpoint.swift index 6e44d4d..320b911 100644 --- a/Tests/MicroyaTests/Supporting/PostmanEchoEndpoint.swift +++ b/Tests/MicroyaTests/Supporting/PostmanEchoEndpoint.swift @@ -1,99 +1,99 @@ #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif @testable import Microya import XCTest let sampleApiProvider = ApiProvider( - plugins: [ - HttpBasicAuthPlugin(tokenClosure: { "abc123" }), - RequestLoggerPlugin(logClosure: { TestDataStore.request = $0 }), - ResponseLoggerPlugin(logClosure: { TestDataStore.urlSessionResult = $0 }), - ProgressIndicatorPlugin( - showIndicator: { TestDataStore.showingProgressIndicator = true }, - hideIndicator: { TestDataStore.showingProgressIndicator = false } - ) - ] + plugins: [ + HttpBasicAuthPlugin(tokenClosure: { "abc123" }), + RequestLoggerPlugin(logClosure: { TestDataStore.request = $0 }), + ResponseLoggerPlugin(logClosure: { TestDataStore.urlSessionResult = $0 }), + ProgressIndicatorPlugin( + showIndicator: { TestDataStore.showingProgressIndicator = true }, + hideIndicator: { TestDataStore.showingProgressIndicator = false } + ), + ] ) enum PostmanEchoEndpoint { - // Endpoints - case index(sortedBy: String) - case post(fooBar: FooBar) - case get(fooBarID: String) - case patch(fooBarID: String, fooBar: FooBar) - case delete + // Endpoints + case index(sortedBy: String) + case post(fooBar: FooBar) + case get(fooBarID: String) + case patch(fooBarID: String, fooBar: FooBar) + case delete } extension PostmanEchoEndpoint: Endpoint { - typealias ClientErrorType = PostmanEchoError - - var decoder: JSONDecoder { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return decoder - } - - var encoder: JSONEncoder { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - return encoder - } - - var baseUrl: URL { - URL(string: "https://postman-echo.com")! - } - - var headers: [String: String] { - [ - "Content-Type": "application/json", - "Accept": "application/json", - "Accept-Language": Locale.current.languageCode ?? "en" - ] - } + typealias ClientErrorType = PostmanEchoError + + var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } + + var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + } + + var baseUrl: URL { + URL(string: "https://postman-echo.com")! + } + + var headers: [String: String] { + [ + "Content-Type": "application/json", + "Accept": "application/json", + "Accept-Language": Locale.current.languageCode ?? "en", + ] + } - var subpath: String { - switch self { - case .index: - return "get" + var subpath: String { + switch self { + case .index: + return "get" - case let .get(fooBarID): - return "get/\(fooBarID)" + case let .get(fooBarID): + return "get/\(fooBarID)" - case .post: - return "post" + case .post: + return "post" - case let .patch(fooBarID, _): - return "patch/\(fooBarID)" + case let .patch(fooBarID, _): + return "patch/\(fooBarID)" - case .delete: - return "delete" - } + case .delete: + return "delete" } + } - var method: HttpMethod { - switch self { - case .index, .get: - return .get + var method: HttpMethod { + switch self { + case .index, .get: + return .get - case let .post(fooBar): - return .post(body: try! encoder.encode(fooBar)) + case let .post(fooBar): + return .post(body: try! encoder.encode(fooBar)) - case let .patch(_, fooBar): - return .patch(body: try! encoder.encode(fooBar)) + case let .patch(_, fooBar): + return .patch(body: try! encoder.encode(fooBar)) - case .delete: - return .delete - } + case .delete: + return .delete } + } - var queryParameters: [String: String] { - switch self { - case let .index(sortedBy): - return ["sortedBy": sortedBy] + var queryParameters: [String: QueryParameterValue] { + switch self { + case let .index(sortedBy): + return ["sortedBy": .string(sortedBy)] - default: - return [:] - } + default: + return [:] } + } } diff --git a/Tests/MicroyaTests/Supporting/PostmanEchoError.swift b/Tests/MicroyaTests/Supporting/PostmanEchoError.swift index b79907a..0c3076b 100644 --- a/Tests/MicroyaTests/Supporting/PostmanEchoError.swift +++ b/Tests/MicroyaTests/Supporting/PostmanEchoError.swift @@ -1,6 +1,6 @@ import Foundation struct PostmanEchoError: Decodable { - let code: Int - let message: String + let code: Int + let message: String } diff --git a/Tests/MicroyaTests/Supporting/PostmanEchoResponse.swift b/Tests/MicroyaTests/Supporting/PostmanEchoResponse.swift index e2882a2..784f236 100644 --- a/Tests/MicroyaTests/Supporting/PostmanEchoResponse.swift +++ b/Tests/MicroyaTests/Supporting/PostmanEchoResponse.swift @@ -1,7 +1,7 @@ import Foundation struct PostmanEchoResponse: Decodable { - let args: [String: String] - let headers: [String: String] - let url: String + let args: [String: String] + let headers: [String: String] + let url: String } diff --git a/Tests/MicroyaTests/Supporting/TestDataStore.swift b/Tests/MicroyaTests/Supporting/TestDataStore.swift index 46a1fcc..7b937de 100644 --- a/Tests/MicroyaTests/Supporting/TestDataStore.swift +++ b/Tests/MicroyaTests/Supporting/TestDataStore.swift @@ -1,17 +1,17 @@ import Foundation #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif @testable import Microya enum TestDataStore { - static var request: URLRequest? - static var urlSessionResult: (data: Data?, response: URLResponse?, error: Error?)? - static var showingProgressIndicator: Bool = false + static var request: URLRequest? + static var urlSessionResult: (data: Data?, response: URLResponse?, error: Error?)? + static var showingProgressIndicator: Bool = false - static func reset() { - request = nil - urlSessionResult = nil - showingProgressIndicator = false - } + static func reset() { + request = nil + urlSessionResult = nil + showingProgressIndicator = false + } }