From 6d5a6ef233563fc203a8ae75e9755b9db319d77e Mon Sep 17 00:00:00 2001 From: Timur Shafigullin Date: Thu, 27 Feb 2020 18:52:51 +0300 Subject: [PATCH] Initial Commit --- .gitignore | 5 + .../contents.xcworkspacedata | 7 + Package.resolved | 70 +++++ Package.swift | 41 +++ README.md | 3 + .../Coders/Decodable/DecodableCoder.swift | 44 +++ .../Decodable/DefualtDecodableCoder.swift | 59 ++++ .../Coders/Tracker/DefaultTrackerCoder.swift | 29 ++ .../Coders/Tracker/TrackerCoder.swift | 15 + .../Commands/AsyncExecutableCommand.swift | 45 +++ .../GenerationConfigurableCommand.swift | 30 ++ .../Commands/TrackersCommand.swift | 62 +++++ Sources/AnalyticsGen/Dependencies.swift | 43 +++ .../GenerationParametersResolving.swift | 55 ++++ .../DefaultTrackersGenerator.swift | 62 +++++ .../TrackersGenerator/TrackersGenerator.swift | 16 ++ .../GenerationConfiguration.swift | 46 ++++ Sources/AnalyticsGen/Models/Event.swift | 18 ++ Sources/AnalyticsGen/Models/Parameter.swift | 19 ++ .../Parameters/GenerationParameters.swift | 15 + .../Models/Parameters/RenderParameters.swift | 16 ++ Sources/AnalyticsGen/Models/Tracker.swift | 18 ++ .../AnalyticsGenAPIEmptyParameters.swift | 15 + .../AnalyticsGenAPIProvider.swift | 16 ++ .../AnalyticsGenAPIRoute.swift | 68 +++++ .../AnalyticsGenAPIVersion.swift | 24 ++ .../AnalyticsGenHTTPService.swift | 20 ++ .../DefaultAnalyticsGenAPIProvider.swift | 81 ++++++ .../Routes/AnalyticsGenAPITrackerRoute.swift | 23 ++ .../AnalyticsTrackersProvider.swift | 16 ++ .../DefaultAnalyticsTrackersProvider.swift | 34 +++ .../Render/DefaultTemplateRenderer.swift | 95 +++++++ .../Render/RenderDestination.swift | 16 ++ .../AnalyticsGen/Render/RenderTemplate.swift | 16 ++ .../Render/RenderTemplateType.swift | 16 ++ .../Render/TemplateRenderer.swift | 15 + Sources/AnalyticsGen/main.swift | 20 ++ .../Extensions/Bundle+Extensions.swift | 40 +++ .../Extensions/CLI+Extensions.swift | 15 + .../Extensions/DispatchQueue+Extensions.swift | 14 + .../Extensions/FloatingPoint+Extensions.swift | 12 + .../Extensions/JSONDecoder+Extensions.swift | 28 ++ .../Extensions/JSONEncoder+Extensions.swift | 24 ++ ...DecodingContainerProtocol+Extensions.swift | 14 + .../OperatingSystemVersion+Extensions.swift | 14 + .../Extensions/Optional+Extensions.swift | 19 ++ .../Extensions/Path+Extensions.swift | 19 ++ .../Extensions/ProcessInfo+Extensions.swift | 10 + .../Extensions/Promise+Extensions.swift | 51 ++++ ...angeReplaceableCollection+Extensions.swift | 30 ++ .../Extensions/Sequence+Extensions.swift | 10 + ...gleValueDecodingContainer+Extensions.swift | 10 + .../Extensions/String+Extensions.swift | 28 ++ .../UnkeyedDecodingContainer+Extensions.swift | 14 + .../BodyEncoders/HTTPBodyEncoder.swift | 12 + .../BodyEncoders/HTTPBodyJSONEncoder.swift | 46 ++++ .../BodyEncoders/HTTPBodyURLEncoder.swift | 44 +++ .../HTTPService/HTTPActivityIndicator.swift | 13 + .../HTTPService/HTTPAnyEncodable.swift | 20 ++ .../HTTPService/HTTPError.swift | 184 +++++++++++++ .../HTTPErrorStringConvertible.swift | 23 ++ .../HTTPService/HTTPHeader.swift | 129 +++++++++ .../HTTPService/HTTPMethod.swift | 13 + .../HTTPService/HTTPResponse.swift | 70 +++++ .../HTTPService/HTTPRoute.swift | 80 ++++++ .../HTTPService/HTTPService.swift | 68 +++++ .../HTTPService/HTTPServiceTask.swift | 226 +++++++++++++++ .../HTTPService/HTTPStatusCode.swift | 56 ++++ .../HTTPService/HTTPTask.swift | 110 ++++++++ .../HTTPService/HTTPUIActivityIndicator.swift | 40 +++ .../QueryEncoders/HTTPQueryEncoder.swift | 8 + .../QueryEncoders/HTTPQueryURLEncoder.swift | 48 ++++ .../HTTPDataResponseSerializer.swift | 34 +++ .../HTTPDecodableResponseSerializer.swift | 32 +++ .../HTTPEmptyResponse.swift | 8 + .../HTTPJSONResponseSerializer.swift | 31 +++ .../HTTPResponseDecoder.swift | 12 + .../HTTPResponseSerializer.swift | 49 ++++ .../HTTPStringResponseSerializer.swift | 44 +++ .../AnalyticsGenTools/Shared/AnyCodable.swift | 207 ++++++++++++++ .../Shared/AnyCodingKey.swift | 29 ++ Sources/AnalyticsGenTools/Shared/Cache.swift | 66 +++++ .../Shared/MessageError.swift | 22 ++ .../URLEncoder/URLArrayEncodingStrategy.swift | 9 + .../URLEncoder/URLBoolEncodingStrategy.swift | 9 + .../URLEncoder/URLDateEncodingStrategy.swift | 17 ++ .../URLEncoder/URLEncodedForm.swift | 3 + .../URLEncoder/URLEncodedFormComponent.swift | 32 +++ .../URLEncoder/URLEncodedFormContext.swift | 14 + .../URLEncoder/URLEncodedFormEncoder.swift | 63 +++++ ...URLEncodedFormKeyedEncodingContainer.swift | 91 +++++++ .../URLEncoder/URLEncodedFormSerializer.swift | 108 ++++++++ ...odedFormSingleValueEncodingContainer.swift | 257 ++++++++++++++++++ ...LEncodedFormUnkeyedEncodingContainer.swift | 98 +++++++ .../URLEncoder/URLEncoder.swift | 65 +++++ .../URLEncoder/URLSpaceEncodingStrategy.swift | 9 + Templates/ColorStyles.stencil | 82 ++++++ Templates/Events.stencil | 39 +++ Templates/FileHeader.stencil | 2 + Templates/Flurry.stencil | 0 Templates/Trackers.stencil | 84 ++++++ .../AnalyticsGenTests/AnalyticsGenTests.swift | 47 ++++ Tests/AnalyticsGenTests/XCTestManifests.swift | 9 + Tests/LinuxMain.swift | 7 + 104 files changed, 4314 insertions(+) create mode 100644 .gitignore create mode 100644 .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/AnalyticsGen/Coders/Decodable/DecodableCoder.swift create mode 100644 Sources/AnalyticsGen/Coders/Decodable/DefualtDecodableCoder.swift create mode 100644 Sources/AnalyticsGen/Coders/Tracker/DefaultTrackerCoder.swift create mode 100644 Sources/AnalyticsGen/Coders/Tracker/TrackerCoder.swift create mode 100644 Sources/AnalyticsGen/Commands/AsyncExecutableCommand.swift create mode 100644 Sources/AnalyticsGen/Commands/GenerationConfigurableCommand.swift create mode 100644 Sources/AnalyticsGen/Commands/TrackersCommand.swift create mode 100644 Sources/AnalyticsGen/Dependencies.swift create mode 100644 Sources/AnalyticsGen/Generators/GenerationParametersResolving.swift create mode 100644 Sources/AnalyticsGen/Generators/TrackersGenerator/DefaultTrackersGenerator.swift create mode 100644 Sources/AnalyticsGen/Generators/TrackersGenerator/TrackersGenerator.swift create mode 100644 Sources/AnalyticsGen/Models/Configuration/GenerationConfiguration.swift create mode 100644 Sources/AnalyticsGen/Models/Event.swift create mode 100644 Sources/AnalyticsGen/Models/Parameter.swift create mode 100644 Sources/AnalyticsGen/Models/Parameters/GenerationParameters.swift create mode 100644 Sources/AnalyticsGen/Models/Parameters/RenderParameters.swift create mode 100644 Sources/AnalyticsGen/Models/Tracker.swift create mode 100644 Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenAPIEmptyParameters.swift create mode 100644 Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenAPIProvider.swift create mode 100644 Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenAPIRoute.swift create mode 100644 Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenAPIVersion.swift create mode 100644 Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenHTTPService.swift create mode 100644 Sources/AnalyticsGen/Providers/AnalyticsGenAPI/DefaultAnalyticsGenAPIProvider.swift create mode 100644 Sources/AnalyticsGen/Providers/AnalyticsGenAPI/Routes/AnalyticsGenAPITrackerRoute.swift create mode 100644 Sources/AnalyticsGen/Providers/AnalyticsTrackers/AnalyticsTrackersProvider.swift create mode 100644 Sources/AnalyticsGen/Providers/AnalyticsTrackers/DefaultAnalyticsTrackersProvider.swift create mode 100644 Sources/AnalyticsGen/Render/DefaultTemplateRenderer.swift create mode 100644 Sources/AnalyticsGen/Render/RenderDestination.swift create mode 100644 Sources/AnalyticsGen/Render/RenderTemplate.swift create mode 100644 Sources/AnalyticsGen/Render/RenderTemplateType.swift create mode 100644 Sources/AnalyticsGen/Render/TemplateRenderer.swift create mode 100644 Sources/AnalyticsGen/main.swift create mode 100644 Sources/AnalyticsGenTools/Extensions/Bundle+Extensions.swift create mode 100644 Sources/AnalyticsGenTools/Extensions/CLI+Extensions.swift create mode 100644 Sources/AnalyticsGenTools/Extensions/DispatchQueue+Extensions.swift create mode 100644 Sources/AnalyticsGenTools/Extensions/FloatingPoint+Extensions.swift create mode 100644 Sources/AnalyticsGenTools/Extensions/JSONDecoder+Extensions.swift create mode 100644 Sources/AnalyticsGenTools/Extensions/JSONEncoder+Extensions.swift create mode 100644 Sources/AnalyticsGenTools/Extensions/KeyedDecodingContainerProtocol+Extensions.swift create mode 100644 Sources/AnalyticsGenTools/Extensions/OperatingSystemVersion+Extensions.swift create mode 100644 Sources/AnalyticsGenTools/Extensions/Optional+Extensions.swift create mode 100644 Sources/AnalyticsGenTools/Extensions/Path+Extensions.swift create mode 100644 Sources/AnalyticsGenTools/Extensions/ProcessInfo+Extensions.swift create mode 100644 Sources/AnalyticsGenTools/Extensions/Promise+Extensions.swift create mode 100644 Sources/AnalyticsGenTools/Extensions/RangeReplaceableCollection+Extensions.swift create mode 100644 Sources/AnalyticsGenTools/Extensions/Sequence+Extensions.swift create mode 100644 Sources/AnalyticsGenTools/Extensions/SingleValueDecodingContainer+Extensions.swift create mode 100644 Sources/AnalyticsGenTools/Extensions/String+Extensions.swift create mode 100644 Sources/AnalyticsGenTools/Extensions/UnkeyedDecodingContainer+Extensions.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/BodyEncoders/HTTPBodyEncoder.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/BodyEncoders/HTTPBodyJSONEncoder.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/BodyEncoders/HTTPBodyURLEncoder.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/HTTPActivityIndicator.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/HTTPAnyEncodable.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/HTTPError.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/HTTPErrorStringConvertible.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/HTTPHeader.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/HTTPMethod.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/HTTPResponse.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/HTTPRoute.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/HTTPService.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/HTTPServiceTask.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/HTTPStatusCode.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/HTTPTask.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/HTTPUIActivityIndicator.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/QueryEncoders/HTTPQueryEncoder.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/QueryEncoders/HTTPQueryURLEncoder.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPDataResponseSerializer.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPDecodableResponseSerializer.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPEmptyResponse.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPJSONResponseSerializer.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPResponseDecoder.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPResponseSerializer.swift create mode 100644 Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPStringResponseSerializer.swift create mode 100644 Sources/AnalyticsGenTools/Shared/AnyCodable.swift create mode 100644 Sources/AnalyticsGenTools/Shared/AnyCodingKey.swift create mode 100644 Sources/AnalyticsGenTools/Shared/Cache.swift create mode 100644 Sources/AnalyticsGenTools/Shared/MessageError.swift create mode 100644 Sources/AnalyticsGenTools/URLEncoder/URLArrayEncodingStrategy.swift create mode 100644 Sources/AnalyticsGenTools/URLEncoder/URLBoolEncodingStrategy.swift create mode 100644 Sources/AnalyticsGenTools/URLEncoder/URLDateEncodingStrategy.swift create mode 100644 Sources/AnalyticsGenTools/URLEncoder/URLEncodedForm.swift create mode 100644 Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormComponent.swift create mode 100644 Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormContext.swift create mode 100644 Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormEncoder.swift create mode 100644 Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormKeyedEncodingContainer.swift create mode 100644 Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormSerializer.swift create mode 100644 Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormSingleValueEncodingContainer.swift create mode 100644 Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormUnkeyedEncodingContainer.swift create mode 100644 Sources/AnalyticsGenTools/URLEncoder/URLEncoder.swift create mode 100644 Sources/AnalyticsGenTools/URLEncoder/URLSpaceEncodingStrategy.swift create mode 100644 Templates/ColorStyles.stencil create mode 100644 Templates/Events.stencil create mode 100644 Templates/FileHeader.stencil create mode 100644 Templates/Flurry.stencil create mode 100644 Templates/Trackers.stencil create mode 100644 Tests/AnalyticsGenTests/AnalyticsGenTests.swift create mode 100644 Tests/AnalyticsGenTests/XCTestManifests.swift create mode 100644 Tests/LinuxMain.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95c4320 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..0930c84 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,70 @@ +{ + "object": { + "pins": [ + { + "package": "PathKit", + "repositoryURL": "https://github.com/kylef/PathKit.git", + "state": { + "branch": null, + "revision": "e2f5be30e4c8f531c9c1e8765aa7b71c0a45d7a0", + "version": "0.9.2" + } + }, + { + "package": "PromiseKit", + "repositoryURL": "https://github.com/mxcl/PromiseKit", + "state": { + "branch": null, + "revision": "f14f16cc2602afec1030e4f492100d6d43dca544", + "version": "6.13.1" + } + }, + { + "package": "Rainbow", + "repositoryURL": "https://github.com/onevcat/Rainbow", + "state": { + "branch": null, + "revision": "9c52c1952e9b2305d4507cf473392ac2d7c9b155", + "version": "3.1.5" + } + }, + { + "package": "Spectre", + "repositoryURL": "https://github.com/kylef/Spectre.git", + "state": { + "branch": null, + "revision": "f14ff47f45642aa5703900980b014c2e9394b6e5", + "version": "0.9.0" + } + }, + { + "package": "Stencil", + "repositoryURL": "https://github.com/kylef/Stencil.git", + "state": { + "branch": null, + "revision": "0e9a78d6584e3812cd9c09494d5c7b483e8f533c", + "version": "0.13.1" + } + }, + { + "package": "StencilSwiftKit", + "repositoryURL": "https://github.com/SwiftGen/StencilSwiftKit.git", + "state": { + "branch": null, + "revision": "dbf02bd6afe71b65ead2bd250aaf974abc688094", + "version": "2.7.2" + } + }, + { + "package": "SwiftCLI", + "repositoryURL": "https://github.com/jakeheis/SwiftCLI", + "state": { + "branch": null, + "revision": "c72c4564f8c0a24700a59824880536aca45a4cae", + "version": "6.0.1" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..b6d2131 --- /dev/null +++ b/Package.swift @@ -0,0 +1,41 @@ +// swift-tools-version:5.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "AnalyticsGen", + platforms: [ + .macOS(.v10_12) + ], + dependencies: [ + .package(url: "https://github.com/jakeheis/SwiftCLI", from: "6.0.0"), + .package(url: "https://github.com/kylef/PathKit.git", from: "0.9.2"), + .package(url: "https://github.com/onevcat/Rainbow", from: "3.0.0"), + .package(url: "https://github.com/mxcl/PromiseKit", from: "6.8.0"), + .package(url: "https://github.com/kylef/Stencil.git", from: "0.13.0"), + .package(url: "https://github.com/SwiftGen/StencilSwiftKit.git", from: "2.7.2") + ], + targets: [ + .target( + name: "AnalyticsGen", + dependencies: [ + "AnalyticsGenTools", + "SwiftCLI", + "PathKit", + "Rainbow", + "PromiseKit", + "Stencil", + "StencilSwiftKit" + ] + ), + .target( + name: "AnalyticsGenTools", + dependencies: ["SwiftCLI", "PathKit"] + ), + .testTarget( + name: "AnalyticsGenTests", + dependencies: ["AnalyticsGen"] + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..cfc70dd --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# AnalyticsGen + +A description of this package. diff --git a/Sources/AnalyticsGen/Coders/Decodable/DecodableCoder.swift b/Sources/AnalyticsGen/Coders/Decodable/DecodableCoder.swift new file mode 100644 index 0000000..ee47bf9 --- /dev/null +++ b/Sources/AnalyticsGen/Coders/Decodable/DecodableCoder.swift @@ -0,0 +1,44 @@ +// +// DecodableCoder.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 19/01/2020. +// + +import Foundation + +protocol DecodableCoder { + + // MARK: - Instance Methods + + func encode(_ object: T, encoder: JSONEncoder) throws -> [String: Any] + func encode(_ array: [T], encoder: JSONEncoder) throws -> [[String: Any]] + + func decode(from json: [String: Any], decoder: JSONDecoder) throws -> T + func decode(from json: [[String: Any]], decoder: JSONDecoder) throws -> [T] +} + +// MARK: - + +extension DecodableCoder { + + // MARK: - Instance Methods + + func encode(_ object: T, encoder: JSONEncoder = JSONEncoder()) throws -> [String: Any] { + return try self.encode(object, encoder: encoder) + } + + func encode(_ array: [T], encoder: JSONEncoder = JSONEncoder()) throws -> [[String: Any]] { + return try self.encode(array, encoder: encoder) + } + + // MARK: - + + func decode(from json: [String: Any], decoder: JSONDecoder = JSONDecoder()) throws -> T { + return try self.decode(from: json, decoder: decoder) + } + + func decode(from json: [[String: Any]], decoder: JSONDecoder = JSONDecoder()) throws -> [T] { + return try self.decode(from: json, decoder: decoder) + } +} diff --git a/Sources/AnalyticsGen/Coders/Decodable/DefualtDecodableCoder.swift b/Sources/AnalyticsGen/Coders/Decodable/DefualtDecodableCoder.swift new file mode 100644 index 0000000..2487ee6 --- /dev/null +++ b/Sources/AnalyticsGen/Coders/Decodable/DefualtDecodableCoder.swift @@ -0,0 +1,59 @@ +// +// DefualtDecodableCoder.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 19/01/2020. +// + +import Foundation + +final class DefaultDecodableCoder: DecodableCoder { + + // MARK: - DecodableCoder + + func encode(_ object: T, encoder: JSONEncoder) throws -> [String: Any] { + let data = try encoder.encode(object) + let object = try JSONSerialization.jsonObject(with: data) + + guard let json = object as? [String: Any] else { + let context = DecodingError.Context( + codingPath: [], + debugDescription: "Deserialized object is not a dictionary" + ) + + throw DecodingError.typeMismatch(type(of: object), context) + } + + return json + } + + func encode(_ array: [T], encoder: JSONEncoder) throws -> [[String: Any]] { + let data = try encoder.encode(array) + let object = try JSONSerialization.jsonObject(with: data) + + guard let json = object as? [[String: Any]] else { + let context = DecodingError.Context( + codingPath: [], + debugDescription: "Deserialized object is not a array" + ) + + throw DecodingError.typeMismatch(type(of: object), context) + } + + return json + } + + // MARK: - + + func decode(from json: [String: Any], decoder: JSONDecoder) throws -> T { + let data = try JSONSerialization.data(withJSONObject: json) + + return try decoder.decode(from: data) + } + + func decode(from json: [[String: Any]], decoder: JSONDecoder) throws -> [T] { + let data = try JSONSerialization.data(withJSONObject: json) + + return try decoder.decode(from: data) + } +} diff --git a/Sources/AnalyticsGen/Coders/Tracker/DefaultTrackerCoder.swift b/Sources/AnalyticsGen/Coders/Tracker/DefaultTrackerCoder.swift new file mode 100644 index 0000000..7bfedf1 --- /dev/null +++ b/Sources/AnalyticsGen/Coders/Tracker/DefaultTrackerCoder.swift @@ -0,0 +1,29 @@ +// +// DefaultTrackerCoder.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 19/01/2020. +// + +import Foundation + +final class DefaultTrackerCoder: TrackerCoder { + + // MARK: - Instance Properties + + let decodableCoder: DecodableCoder + + // MARK: - Initializers + + init(decodableCoder: DecodableCoder) { + self.decodableCoder = decodableCoder + } + + // MARK: - TrackerCoder + + func encode(trackers: [Tracker]) -> [String: Any] { + let trackersJSON = (try? self.decodableCoder.encode(trackers)) ?? [] + + return ["trackers": trackersJSON] + } +} diff --git a/Sources/AnalyticsGen/Coders/Tracker/TrackerCoder.swift b/Sources/AnalyticsGen/Coders/Tracker/TrackerCoder.swift new file mode 100644 index 0000000..8e102c1 --- /dev/null +++ b/Sources/AnalyticsGen/Coders/Tracker/TrackerCoder.swift @@ -0,0 +1,15 @@ +// +// TrackerCoder.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 19/01/2020. +// + +import Foundation + +protocol TrackerCoder { + + // MARK: - Instance Methods + + func encode(trackers: [Tracker]) -> [String: Any] +} diff --git a/Sources/AnalyticsGen/Commands/AsyncExecutableCommand.swift b/Sources/AnalyticsGen/Commands/AsyncExecutableCommand.swift new file mode 100644 index 0000000..155b286 --- /dev/null +++ b/Sources/AnalyticsGen/Commands/AsyncExecutableCommand.swift @@ -0,0 +1,45 @@ +// +// AsyncExecutableCommand.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 12/01/2020. +// + +import Foundation +import SwiftCLI +import Rainbow + +protocol AsyncExecutableCommand: Command { + + // MARK: - Instance Methods + + func executeAsyncAndExit() throws + + func fail(message: String) -> Never + func succeed(message: String) -> Never +} + +// MARK: - + +extension AsyncExecutableCommand { + + // MARK: - Instance Methods + + func execute() throws { + try self.executeAsyncAndExit() + + RunLoop.main.run() + } + + func fail(message: String) -> Never { + self.stderr <<< message.red + + exit(EXIT_FAILURE) + } + + func succeed(message: String) -> Never { + self.stdout <<< message.green + + exit(EXIT_SUCCESS) + } +} diff --git a/Sources/AnalyticsGen/Commands/GenerationConfigurableCommand.swift b/Sources/AnalyticsGen/Commands/GenerationConfigurableCommand.swift new file mode 100644 index 0000000..e43c509 --- /dev/null +++ b/Sources/AnalyticsGen/Commands/GenerationConfigurableCommand.swift @@ -0,0 +1,30 @@ +// +// GenerationConfigurableCommand.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 19/01/2020. +// + +import Foundation +import SwiftCLI + +protocol GenerationConfigurableCommand: Command { + + // MARK: - Instance Properties + + var template: Key { get } + var destination: Key { get } + + var generationConfiguration: GenerationConfiguration { get } +} + +// MARK: - + +extension GenerationConfigurableCommand { + + // MARK: - Instance Properties + + var generationConfiguration: GenerationConfiguration { + GenerationConfiguration(template: template.value, templateOptions: nil, destination: self.destination.value) + } +} diff --git a/Sources/AnalyticsGen/Commands/TrackersCommand.swift b/Sources/AnalyticsGen/Commands/TrackersCommand.swift new file mode 100644 index 0000000..15c5099 --- /dev/null +++ b/Sources/AnalyticsGen/Commands/TrackersCommand.swift @@ -0,0 +1,62 @@ +// +// TrackersCommand.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 19/01/2020. +// + +import Foundation +import SwiftCLI +import PromiseKit + +final class TrackersCommand: AsyncExecutableCommand, GenerationConfigurableCommand { + + // MARK: - Instance Properties + + let generator: TrackersGenerator + + // MARK: - + + let name = "trackers" + let shortDescription = "Generate code for analytics trackers from server" + + // MARK: - + + var template = Key( + "--template", + "-t", + description: """ + Path to the template file. + If no template is passed a default template will be used. + """ + ) + + let destination = Key( + "--destination", + "-d", + description: """ + The path to the file to generate. + By default, generated code will be printed on stdout. + """ + ) + + // MARK: - Initializers + + init(generator: TrackersGenerator) { + self.generator = generator + } + + // MARK: - AsyncExecutableCommand + + func executeAsyncAndExit() throws { + firstly { + self.generator.generate(configuration: self.generationConfiguration) + }.done { + self.succeed(message: "Analytics trackers generated successfully!") + }.catch { error in + self.fail(message: "Failed to generate analytics trackers: \(error)") + } + } +} + + diff --git a/Sources/AnalyticsGen/Dependencies.swift b/Sources/AnalyticsGen/Dependencies.swift new file mode 100644 index 0000000..7577137 --- /dev/null +++ b/Sources/AnalyticsGen/Dependencies.swift @@ -0,0 +1,43 @@ +// +// Dependencies.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 19/01/2020. +// + +import Foundation +import AnalyticsGenTools + +enum Dependencies { + + // MARK: - Type Properties + + static let analyticsGenHTTPService: AnalyticsGenHTTPService = HTTPService() + + // MARK: - + + static let analyticsGenAPIProvider: AnalyticsGenAPIProvider = DefaultAnalyticsGenAPIProvider( + httpService: Dependencies.analyticsGenHTTPService + ) + + static let analyticsTrackersProvider: AnalyticsTrackersProvider = DefaultAnalyticsTrackersProvider( + apiProvider: Dependencies.analyticsGenAPIProvider + ) + + // MARK: - + + static let decodableTracker: DecodableCoder = DefaultDecodableCoder() + static let trackerCoder: TrackerCoder = DefaultTrackerCoder(decodableCoder: Dependencies.decodableTracker) + + // MARK: - + + static let templateRenderer: TemplateRenderer = DefaultTemplateRenderer() + + // MARK: - + + static let trackersGenerator: TrackersGenerator = DefaultTrackersGenerator( + analyticsTrackersProvider: Dependencies.analyticsTrackersProvider, + trackerCoder: Dependencies.trackerCoder, + templateRenderer: Dependencies.templateRenderer + ) +} diff --git a/Sources/AnalyticsGen/Generators/GenerationParametersResolving.swift b/Sources/AnalyticsGen/Generators/GenerationParametersResolving.swift new file mode 100644 index 0000000..de24f39 --- /dev/null +++ b/Sources/AnalyticsGen/Generators/GenerationParametersResolving.swift @@ -0,0 +1,55 @@ +// +// GenerationParametersResolving.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 19/01/2020. +// + +import Foundation + +protocol GenerationParametersResolving { + + // MARK: - Instance Properties + + var defaultTemplateType: RenderTemplateType { get } + var defaultDestination: RenderDestination { get } + + // MARK: - Instance Methods + + func resolveGenerationParameters(from configuration: GenerationConfiguration) throws -> GenerationParameters +} + +// MARK: - + +extension GenerationParametersResolving { + + // MARK: - Instance Methods + + func resolveTemplateType(from configuration: GenerationConfiguration) -> RenderTemplateType { + if let templatePath = configuration.template { + return .custom(path: templatePath) + } else { + return self.defaultTemplateType + } + } + + func resolveDestination(from configuration: GenerationConfiguration) -> RenderDestination { + if let destinationPath = configuration.destination { + return .file(path: destinationPath) + } else { + return self.defaultDestination + } + } + + // MARK: - + + func resolveGenerationParameters(from configuration: GenerationConfiguration) throws -> GenerationParameters { + let templateType = self.resolveTemplateType(from: configuration) + let destination = self.resolveDestination(from: configuration) + + let template = RenderTemplate(type: templateType, options: configuration.templateOptions ?? [:]) + let render = RenderParameters(template: template, destination: destination) + + return GenerationParameters(render: render) + } +} diff --git a/Sources/AnalyticsGen/Generators/TrackersGenerator/DefaultTrackersGenerator.swift b/Sources/AnalyticsGen/Generators/TrackersGenerator/DefaultTrackersGenerator.swift new file mode 100644 index 0000000..a510beb --- /dev/null +++ b/Sources/AnalyticsGen/Generators/TrackersGenerator/DefaultTrackersGenerator.swift @@ -0,0 +1,62 @@ +// +// DefaultTrackersGenerator.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 12/01/2020. +// + +import Foundation +import AnalyticsGenTools +import PromiseKit + +final class DefaultTrackersGenerator: TrackersGenerator, GenerationParametersResolving { + + // MARK: - Instance Properties + + let analyticsTrackersProvider: AnalyticsTrackersProvider + let trackerCoder: TrackerCoder + let templateRenderer: TemplateRenderer + + // MARK: - + + let defaultTemplateType: RenderTemplateType = .native(name: "Trackers") + let defaultDestination: RenderDestination = .console + + // MARK: - Initializers + + init( + analyticsTrackersProvider: AnalyticsTrackersProvider, + trackerCoder: TrackerCoder, + templateRenderer: TemplateRenderer + ) { + self.analyticsTrackersProvider = analyticsTrackersProvider + self.trackerCoder = trackerCoder + self.templateRenderer = templateRenderer + } + + // MARK: - Instance Methods + + private func generate(parameters: GenerationParameters) -> Promise { + return firstly { + self.analyticsTrackersProvider.fetchAnalyticsTrackers() + }.done { trackers in + let context = self.trackerCoder.encode(trackers: trackers) + + try self.templateRenderer.render( + template: parameters.render.template, + to: parameters.render.destination, + context: context + ) + } + } + + // MARK: - TrackersGenerator + + func generate(configuration: GenerationConfiguration) -> Promise { + return perform(on: DispatchQueue.global(qos: .userInitiated)) { + try self.resolveGenerationParameters(from: configuration) + }.then { parameters in + self.generate(parameters: parameters) + } + } +} diff --git a/Sources/AnalyticsGen/Generators/TrackersGenerator/TrackersGenerator.swift b/Sources/AnalyticsGen/Generators/TrackersGenerator/TrackersGenerator.swift new file mode 100644 index 0000000..b47998e --- /dev/null +++ b/Sources/AnalyticsGen/Generators/TrackersGenerator/TrackersGenerator.swift @@ -0,0 +1,16 @@ +// +// TrackersGenerator.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 12/01/2020. +// + +import Foundation +import PromiseKit + +protocol TrackersGenerator { + + // MARK: - Instance Methods + + func generate(configuration: GenerationConfiguration) -> Promise +} diff --git a/Sources/AnalyticsGen/Models/Configuration/GenerationConfiguration.swift b/Sources/AnalyticsGen/Models/Configuration/GenerationConfiguration.swift new file mode 100644 index 0000000..f56b29c --- /dev/null +++ b/Sources/AnalyticsGen/Models/Configuration/GenerationConfiguration.swift @@ -0,0 +1,46 @@ +// +// GenerationConfiguration.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 18/01/2020. +// + +import Foundation +import AnalyticsGenTools + +struct GenerationConfiguration: Decodable { + + // MARK: - Nested Types + + private enum CodingKeys: String, CodingKey { + case template + case templateOptions + case destination + } + + // MARK: - Instance Properties + + let template: String? + let templateOptions: [String: Any]? + let destination: String? + + // MARK: - Initializers + + init(template: String?, templateOptions: [String: Any]?, destination: String?) { + self.template = template + self.templateOptions = templateOptions + self.destination = destination + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.template = try container.decodeIfPresent(forKey: .template) + + self.templateOptions = try container + .decodeIfPresent([String: AnyCodable].self, forKey: .templateOptions)? + .mapValues { $0.value } + + self.destination = try container.decodeIfPresent(forKey: .destination) + } +} diff --git a/Sources/AnalyticsGen/Models/Event.swift b/Sources/AnalyticsGen/Models/Event.swift new file mode 100644 index 0000000..dcd7407 --- /dev/null +++ b/Sources/AnalyticsGen/Models/Event.swift @@ -0,0 +1,18 @@ +// +// Event.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 12/01/2020. +// + +import Foundation + +struct Event: Codable { + + // MARK: - Instance Properties + + let id: Int + let name: String + let description: String + let parameters: [Parameter] +} diff --git a/Sources/AnalyticsGen/Models/Parameter.swift b/Sources/AnalyticsGen/Models/Parameter.swift new file mode 100644 index 0000000..c7ffe26 --- /dev/null +++ b/Sources/AnalyticsGen/Models/Parameter.swift @@ -0,0 +1,19 @@ +// +// Parameter.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 12/01/2020. +// + +import Foundation + +struct Parameter: Codable { + + // MARK: - Instance Properties + + let id: Int + let name: String + let description: String + let isOptional: Bool + let type: String +} diff --git a/Sources/AnalyticsGen/Models/Parameters/GenerationParameters.swift b/Sources/AnalyticsGen/Models/Parameters/GenerationParameters.swift new file mode 100644 index 0000000..49dfb80 --- /dev/null +++ b/Sources/AnalyticsGen/Models/Parameters/GenerationParameters.swift @@ -0,0 +1,15 @@ +// +// GenerationParameters.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 19/01/2020. +// + +import Foundation + +struct GenerationParameters { + + // MARK: - Instance Properties + + let render: RenderParameters +} diff --git a/Sources/AnalyticsGen/Models/Parameters/RenderParameters.swift b/Sources/AnalyticsGen/Models/Parameters/RenderParameters.swift new file mode 100644 index 0000000..e87cdb1 --- /dev/null +++ b/Sources/AnalyticsGen/Models/Parameters/RenderParameters.swift @@ -0,0 +1,16 @@ +// +// RenderParameters.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 19/01/2020. +// + +import Foundation + +struct RenderParameters { + + // MARK: - Instance Properties + + let template: RenderTemplate + let destination: RenderDestination +} diff --git a/Sources/AnalyticsGen/Models/Tracker.swift b/Sources/AnalyticsGen/Models/Tracker.swift new file mode 100644 index 0000000..ff41ba9 --- /dev/null +++ b/Sources/AnalyticsGen/Models/Tracker.swift @@ -0,0 +1,18 @@ +// +// Tracker.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 12/01/2020. +// + +import Foundation + +struct Tracker: Codable { + + // MARK: - Instance Properties + + let id: Int + let name: String + let `import`: String + let events: [Event] +} diff --git a/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenAPIEmptyParameters.swift b/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenAPIEmptyParameters.swift new file mode 100644 index 0000000..017e9b9 --- /dev/null +++ b/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenAPIEmptyParameters.swift @@ -0,0 +1,15 @@ +// +// AnalyticsGenAPIEmptyParameters.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 18/01/2020. +// + +import Foundation + +struct AnalyticsGenAPIEmptyParameters: Encodable { + + // MARK: - Encodable + + func encode(to encoder: Encoder) throws { } +} diff --git a/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenAPIProvider.swift b/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenAPIProvider.swift new file mode 100644 index 0000000..6c4e4b0 --- /dev/null +++ b/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenAPIProvider.swift @@ -0,0 +1,16 @@ +// +// AnalyticsGenAPIProvider.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 12/01/2020. +// + +import Foundation +import PromiseKit + +protocol AnalyticsGenAPIProvider { + + // MARK: - Instance Methods + + func request(route: Route) -> Promise +} diff --git a/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenAPIRoute.swift b/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenAPIRoute.swift new file mode 100644 index 0000000..bc8b040 --- /dev/null +++ b/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenAPIRoute.swift @@ -0,0 +1,68 @@ +// +// AnalyticsGenAPIRoute.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 12/01/2020. +// + +import Foundation +import AnalyticsGenTools + +protocol AnalyticsGenAPIRoute { + + // MARK: - Nested Types + + associatedtype QueryParameters: Encodable + associatedtype BodyParameters: Encodable + associatedtype Response: Decodable + + // MARK: - Instance Properties + + var apiVersion: AnalyticsGenAPIVersion { get } + var httpMethod: HTTPMethod { get } + var urlPath: String { get } + var accessToken: String? { get } + var queryParameters: QueryParameters? { get } + var bodyParameters: BodyParameters? { get } +} + +// MARK: - + +extension AnalyticsGenAPIRoute { + + // MARK: - Instance Properties + + var apiVersion: AnalyticsGenAPIVersion { + .v1 + } + + var httpMethod: HTTPMethod { + .get + } + + var accessToken: String? { + nil + } +} + +// MARK: - + +extension AnalyticsGenAPIRoute where QueryParameters == AnalyticsGenAPIEmptyParameters { + + // MARK: - Instance Properties + + var queryParameters: QueryParameters? { + nil + } +} + +// MARK: - + +extension AnalyticsGenAPIRoute where BodyParameters == AnalyticsGenAPIEmptyParameters { + + // MARK: - Instance Properties + + var bodyParameters: BodyParameters? { + nil + } +} diff --git a/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenAPIVersion.swift b/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenAPIVersion.swift new file mode 100644 index 0000000..db6e75e --- /dev/null +++ b/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenAPIVersion.swift @@ -0,0 +1,24 @@ +// +// AnalyticsGenAPIVersion.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 12/01/2020. +// + +import Foundation + +enum AnalyticsGenAPIVersion { + + // MARK: - Enumeration Cases + + case v1 + + // MARK: - Instance Properties + + var urlPath: String { + switch self { + case .v1: + return "v1" + } + } +} diff --git a/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenHTTPService.swift b/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenHTTPService.swift new file mode 100644 index 0000000..7aeee4f --- /dev/null +++ b/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/AnalyticsGenHTTPService.swift @@ -0,0 +1,20 @@ +// +// AnalyticsGenHTTPService.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 12/01/2020. +// + +import Foundation +import AnalyticsGenTools + +protocol AnalyticsGenHTTPService { + + // MARK: - Instance Methods + + func request(route: HTTPRoute) -> HTTPTask +} + +// MARK: - HTTPService + +extension HTTPService: AnalyticsGenHTTPService { } diff --git a/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/DefaultAnalyticsGenAPIProvider.swift b/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/DefaultAnalyticsGenAPIProvider.swift new file mode 100644 index 0000000..705c7b5 --- /dev/null +++ b/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/DefaultAnalyticsGenAPIProvider.swift @@ -0,0 +1,81 @@ +// +// DefaultAnalyticsGenAPIProvider.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 12/01/2020. +// + +import Foundation +import PromiseKit +import AnalyticsGenTools + +final class DefaultAnalyticsGenAPIProvider: AnalyticsGenAPIProvider { + + // MARK: - Instance Properties + + private let queryEncoder: HTTPQueryEncoder + private let bodyEncoder: HTTPBodyEncoder + private let responseDecoder: HTTPResponseDecoder + + // MARK: - + + let httpService: AnalyticsGenHTTPService + + // MARK: - Initializers + + init(httpService: AnalyticsGenHTTPService) { + self.httpService = httpService + + let urlEncoder = URLEncoder() + let jsonEncoder = JSONEncoder() + let jsonDecoder = JSONDecoder() + + self.queryEncoder = HTTPQueryURLEncoder(urlEncoder: urlEncoder) + self.bodyEncoder = HTTPBodyJSONEncoder(jsonEncoder: jsonEncoder) + self.responseDecoder = jsonDecoder + } + + // MARK: - Instance Methods + + private func makeHTTPRoute(for route: Route) -> HTTPRoute { + let url = URL.analyticsGenURL + .appendingPathComponent(route.apiVersion.urlPath) + .appendingPathComponent(route.urlPath) + + return HTTPRoute( + method: route.httpMethod, + url: url, + queryParameters: route.queryParameters, + queryEncoder: self.queryEncoder, + bodyParameters: route.bodyParameters, + bodyEncoder: self.bodyEncoder + ) + } + + // MARK: - AnalyticsGenAPIProvider + + func request(route: Route) -> Promise where Route: AnalyticsGenAPIRoute { + return Promise(resolver: { seal in + let task = self.httpService.request(route: self.makeHTTPRoute(for: route)) + + task.responseDecodable(type: Route.Response.self, decoder: self.responseDecoder, completion: { response in + switch response.result { + case let .failure(error): + seal.reject(error) + + case let .success(value): + seal.fulfill(value) + } + }) + }) + } +} + +// MARK: - URL + +private extension URL { + + // MARK: - Type Properties + + static let analyticsGenURL = URL(string: "http://localhost:8080")! +} diff --git a/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/Routes/AnalyticsGenAPITrackerRoute.swift b/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/Routes/AnalyticsGenAPITrackerRoute.swift new file mode 100644 index 0000000..766f0a6 --- /dev/null +++ b/Sources/AnalyticsGen/Providers/AnalyticsGenAPI/Routes/AnalyticsGenAPITrackerRoute.swift @@ -0,0 +1,23 @@ +// +// AnalyticsGenAPITrackerRoute.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 18/01/2020. +// + +import Foundation + +struct AnalyticsGenAPITrackerRoute: AnalyticsGenAPIRoute { + + // MARK: - Nested Types + + typealias Response = [Tracker] + typealias QueryParameters = AnalyticsGenAPIEmptyParameters + typealias BodyParameters = AnalyticsGenAPIEmptyParameters + + // MARK: - Instance Properties + + var urlPath: String { + "tracker" + } +} diff --git a/Sources/AnalyticsGen/Providers/AnalyticsTrackers/AnalyticsTrackersProvider.swift b/Sources/AnalyticsGen/Providers/AnalyticsTrackers/AnalyticsTrackersProvider.swift new file mode 100644 index 0000000..a4815e5 --- /dev/null +++ b/Sources/AnalyticsGen/Providers/AnalyticsTrackers/AnalyticsTrackersProvider.swift @@ -0,0 +1,16 @@ +// +// AnalyticsTrackersProvider.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 12/01/2020. +// + +import Foundation +import PromiseKit + +protocol AnalyticsTrackersProvider { + + // MARK: - Instance Methods + + func fetchAnalyticsTrackers() -> Promise<[Tracker]> +} diff --git a/Sources/AnalyticsGen/Providers/AnalyticsTrackers/DefaultAnalyticsTrackersProvider.swift b/Sources/AnalyticsGen/Providers/AnalyticsTrackers/DefaultAnalyticsTrackersProvider.swift new file mode 100644 index 0000000..fdef617 --- /dev/null +++ b/Sources/AnalyticsGen/Providers/AnalyticsTrackers/DefaultAnalyticsTrackersProvider.swift @@ -0,0 +1,34 @@ +// +// DefaultAnalyticsTrackersProvider.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 12/01/2020. +// + +import Foundation +import PromiseKit + +final class DefaultAnalyticsTrackersProvider: AnalyticsTrackersProvider { + + // MARK: - Instance Properties + + private let apiProvider: AnalyticsGenAPIProvider + + // MARK: - Initializers + + init(apiProvider: AnalyticsGenAPIProvider) { + self.apiProvider = apiProvider + } + + // MARK: - AnalyticsTrackersProvider + + func fetchAnalyticsTrackers() -> Promise<[Tracker]> { + let route = AnalyticsGenAPITrackerRoute() + + let promise = firstly { + self.apiProvider.request(route: route) + } + + return promise + } +} diff --git a/Sources/AnalyticsGen/Render/DefaultTemplateRenderer.swift b/Sources/AnalyticsGen/Render/DefaultTemplateRenderer.swift new file mode 100644 index 0000000..ae1386a --- /dev/null +++ b/Sources/AnalyticsGen/Render/DefaultTemplateRenderer.swift @@ -0,0 +1,95 @@ +// +// DefaultTemplateRenderer.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 19/01/2020. +// + +import Foundation +import PathKit +import Stencil +import StencilSwiftKit + +final class DefaultTemplateRenderer: TemplateRenderer { + + // MARK: - Nested Types + + private enum Constants { + + // MARK: - Type Properties + + static let templatesFileExtension = ".stencil" + static let templatesXcodeRelativePath = "../Templates" + static let templatesPodsRelativePath = "../Templates" + static let templatesShareRelativePath = "../../share/fugen" + static let templateOptionsKey = "options" + } + + // MARK: - Instance Methods + + private func resolveTemplatePath(of templateType: RenderTemplateType) throws -> Path { + switch templateType { + case let .native(name: templateName): + let templateFileName = templateName.appending(Constants.templatesFileExtension) + + #if DEBUG + let xcodeTemplatesPath = Path.current.appending(Constants.templatesXcodeRelativePath) + + if xcodeTemplatesPath.exists { + return xcodeTemplatesPath.appending(templateFileName) + } + #endif + + var executablePath = Path(ProcessInfo.processInfo.executablePath) + + while executablePath.isSymlink { + executablePath = try executablePath.symlinkDestination() + } + + let podsTemplatesPath = executablePath.appending(Constants.templatesPodsRelativePath) + + if podsTemplatesPath.exists { + return podsTemplatesPath.appending(templateFileName) + } + + return executablePath.appending(Constants.templatesShareRelativePath).appending(templateFileName) + + case let .custom(path: templatePath): + return Path(templatePath) + } + } + + private func write(output: String, to destination: RenderDestination) throws { + switch destination { + case let .file(path: filePath): + let filePath = Path(filePath) + + try filePath.parent().mkpath() + try filePath.write(output) + + case .console: + print(output) + } + } + + // MARK: - TemplateRenderer + + func render(template: RenderTemplate, to destination: RenderDestination, context: [String: Any]) throws { + let templatePath = try self.resolveTemplatePath(of: template.type) + + let stencilEnvironment = Environment( + loader: FileSystemLoader(paths: [templatePath.parent()]), + templateClass: StencilSwiftTemplate.self + ) + + let stencilTemplate = StencilSwiftTemplate( + templateString: try templatePath.read(), + environment: stencilEnvironment + ) + + let context = context.merging([Constants.templateOptionsKey: template.options], uniquingKeysWith: { $1 }) + let output = try stencilTemplate.render(context) + + try self.write(output: output, to: destination) + } +} diff --git a/Sources/AnalyticsGen/Render/RenderDestination.swift b/Sources/AnalyticsGen/Render/RenderDestination.swift new file mode 100644 index 0000000..b05fbaf --- /dev/null +++ b/Sources/AnalyticsGen/Render/RenderDestination.swift @@ -0,0 +1,16 @@ +// +// RenderDestination.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 19/01/2020. +// + +import Foundation + +enum RenderDestination { + + // MARK: - Enumeration Cases + + case file(path: String) + case console +} diff --git a/Sources/AnalyticsGen/Render/RenderTemplate.swift b/Sources/AnalyticsGen/Render/RenderTemplate.swift new file mode 100644 index 0000000..ec63034 --- /dev/null +++ b/Sources/AnalyticsGen/Render/RenderTemplate.swift @@ -0,0 +1,16 @@ +// +// RenderTemplate.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 18/01/2020. +// + +import Foundation + +struct RenderTemplate { + + // MARK: - Instance Properties + + let type: RenderTemplateType + let options: [String: Any] +} diff --git a/Sources/AnalyticsGen/Render/RenderTemplateType.swift b/Sources/AnalyticsGen/Render/RenderTemplateType.swift new file mode 100644 index 0000000..03e80d2 --- /dev/null +++ b/Sources/AnalyticsGen/Render/RenderTemplateType.swift @@ -0,0 +1,16 @@ +// +// RenderTemplateType.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 19/01/2020. +// + +import Foundation + +enum RenderTemplateType { + + // MARK: - Enumeration Cases + + case native(name: String) + case custom(path: String) +} diff --git a/Sources/AnalyticsGen/Render/TemplateRenderer.swift b/Sources/AnalyticsGen/Render/TemplateRenderer.swift new file mode 100644 index 0000000..d3ae6d9 --- /dev/null +++ b/Sources/AnalyticsGen/Render/TemplateRenderer.swift @@ -0,0 +1,15 @@ +// +// TemplateRenderer.swift +// AnalyticsGen +// +// Created by Timur Shafigullin on 18/01/2020. +// + +import Foundation + +protocol TemplateRenderer { + + // MARK: - Instance Methods + + func render(template: RenderTemplate, to destination: RenderDestination, context: [String: Any]) throws +} diff --git a/Sources/AnalyticsGen/main.swift b/Sources/AnalyticsGen/main.swift new file mode 100644 index 0000000..fbba041 --- /dev/null +++ b/Sources/AnalyticsGen/main.swift @@ -0,0 +1,20 @@ +import Foundation +import SwiftCLI + +// MARK: - Constants + +private enum Constants { + + // MARK: - Type Properties + + static let version = "0.1.0" + static let description = "Generate analytics code for you Swift iOS project" +} + +let analyticsGen = CLI(name: "AnalyticsGen", version: Constants.version, description: Constants.description) + +analyticsGen.commands = [ + TrackersCommand(generator: Dependencies.trackersGenerator) +] + +analyticsGen.goAndExitOnError() diff --git a/Sources/AnalyticsGenTools/Extensions/Bundle+Extensions.swift b/Sources/AnalyticsGenTools/Extensions/Bundle+Extensions.swift new file mode 100644 index 0000000..644d663 --- /dev/null +++ b/Sources/AnalyticsGenTools/Extensions/Bundle+Extensions.swift @@ -0,0 +1,40 @@ +import Foundation + +extension Bundle { + + // MARK: - Instance Properties + + public var bundleName: String? { + string(forInfoDictionaryKey: "CFBundleName") + } + + public var developmentRegion: String? { + string(forInfoDictionaryKey: "CFBundleDevelopmentRegion") + } + + public var displayName: String? { + string(forInfoDictionaryKey: "CFBundleDisplayName") + } + + public var executableName: String? { + string(forInfoDictionaryKey: "CFBundleExecutable") + } + + public var version: String? { + string(forInfoDictionaryKey: "CFBundleShortVersionString") + } + + public var build: String? { + string(forInfoDictionaryKey: "CFBundleVersion") + } + + // MARK: - Instance Methods + + public func string(forInfoDictionaryKey key: String) -> String? { + return object(forInfoDictionaryKey: key) as? String + } + + public func url(forInfoDictionaryKey key: String) -> URL? { + return string(forInfoDictionaryKey: key).flatMap(URL.init(string:)) + } +} diff --git a/Sources/AnalyticsGenTools/Extensions/CLI+Extensions.swift b/Sources/AnalyticsGenTools/Extensions/CLI+Extensions.swift new file mode 100644 index 0000000..5ae2b4d --- /dev/null +++ b/Sources/AnalyticsGenTools/Extensions/CLI+Extensions.swift @@ -0,0 +1,15 @@ +import Foundation +import SwiftCLI + +extension CLI { + + // MARK: - Instance Methods + + public func goAndExitOnError() { + let result = go() + + if result != EXIT_SUCCESS { + exit(result) + } + } +} diff --git a/Sources/AnalyticsGenTools/Extensions/DispatchQueue+Extensions.swift b/Sources/AnalyticsGenTools/Extensions/DispatchQueue+Extensions.swift new file mode 100644 index 0000000..4a60eee --- /dev/null +++ b/Sources/AnalyticsGenTools/Extensions/DispatchQueue+Extensions.swift @@ -0,0 +1,14 @@ +import Foundation + +extension DispatchQueue { + + // MARK: - Instance Methods + + public func async(flags: DispatchWorkItemFlags?, _ work: @escaping() -> Void) { + if let flags = flags { + async(flags: flags, execute: work) + } else { + async(execute: work) + } + } +} diff --git a/Sources/AnalyticsGenTools/Extensions/FloatingPoint+Extensions.swift b/Sources/AnalyticsGenTools/Extensions/FloatingPoint+Extensions.swift new file mode 100644 index 0000000..0e04acd --- /dev/null +++ b/Sources/AnalyticsGenTools/Extensions/FloatingPoint+Extensions.swift @@ -0,0 +1,12 @@ +import Foundation + +extension FloatingPoint { + + // MARK: - Instance Methods + + public func rounded(precision: Int, rule: FloatingPointRoundingRule = .toNearestOrAwayFromZero) -> Self { + let scale = Self(precision * 10) + + return (self * scale).rounded(rule) / scale + } +} diff --git a/Sources/AnalyticsGenTools/Extensions/JSONDecoder+Extensions.swift b/Sources/AnalyticsGenTools/Extensions/JSONDecoder+Extensions.swift new file mode 100644 index 0000000..48ff60e --- /dev/null +++ b/Sources/AnalyticsGenTools/Extensions/JSONDecoder+Extensions.swift @@ -0,0 +1,28 @@ +import Foundation + +extension JSONDecoder { + + // MARK: - Initializers + + public convenience init( + dateDecodingStrategy: DateDecodingStrategy = .deferredToDate, + dataDecodingStrategy: DataDecodingStrategy = .base64, + nonConformingFloatDecodingStrategy: NonConformingFloatDecodingStrategy = .throw, + keyDecodingStrategy: KeyDecodingStrategy = .useDefaultKeys, + userInfo: [CodingUserInfoKey: Any] = [:] + ) { + self.init() + + self.dateDecodingStrategy = dateDecodingStrategy + self.dataDecodingStrategy = dataDecodingStrategy + self.nonConformingFloatDecodingStrategy = nonConformingFloatDecodingStrategy + self.keyDecodingStrategy = keyDecodingStrategy + self.userInfo = userInfo + } + + // MARK: - Instance Methods + + public func decode(from data: Data) throws -> T { + try decode(T.self, from: data) + } +} diff --git a/Sources/AnalyticsGenTools/Extensions/JSONEncoder+Extensions.swift b/Sources/AnalyticsGenTools/Extensions/JSONEncoder+Extensions.swift new file mode 100644 index 0000000..47fc8d7 --- /dev/null +++ b/Sources/AnalyticsGenTools/Extensions/JSONEncoder+Extensions.swift @@ -0,0 +1,24 @@ +import Foundation + +extension JSONEncoder { + + // MARK: - Initializers + + public convenience init( + outputFormatting: OutputFormatting = [], + dateEncodingStrategy: DateEncodingStrategy = .deferredToDate, + dataEncodingStrategy: DataEncodingStrategy = .base64, + nonConformingFloatEncodingStrategy: NonConformingFloatEncodingStrategy = .throw, + keyEncodingStrategy: KeyEncodingStrategy = .useDefaultKeys, + userInfo: [CodingUserInfoKey: Any] = [:] + ) { + self.init() + + self.outputFormatting = outputFormatting + self.dateEncodingStrategy = dateEncodingStrategy + self.dataEncodingStrategy = dataEncodingStrategy + self.nonConformingFloatEncodingStrategy = nonConformingFloatEncodingStrategy + self.keyEncodingStrategy = keyEncodingStrategy + self.userInfo = userInfo + } +} diff --git a/Sources/AnalyticsGenTools/Extensions/KeyedDecodingContainerProtocol+Extensions.swift b/Sources/AnalyticsGenTools/Extensions/KeyedDecodingContainerProtocol+Extensions.swift new file mode 100644 index 0000000..07ad067 --- /dev/null +++ b/Sources/AnalyticsGenTools/Extensions/KeyedDecodingContainerProtocol+Extensions.swift @@ -0,0 +1,14 @@ +import Foundation + +extension KeyedDecodingContainerProtocol { + + // MARK: - Instance Methods + + public func decode(forKey key: Key) throws -> T { + return try decode(T.self, forKey: key) + } + + public func decodeIfPresent(forKey key: Self.Key) throws -> T? { + return try decodeIfPresent(T.self, forKey: key) + } +} diff --git a/Sources/AnalyticsGenTools/Extensions/OperatingSystemVersion+Extensions.swift b/Sources/AnalyticsGenTools/Extensions/OperatingSystemVersion+Extensions.swift new file mode 100644 index 0000000..261a946 --- /dev/null +++ b/Sources/AnalyticsGenTools/Extensions/OperatingSystemVersion+Extensions.swift @@ -0,0 +1,14 @@ +import Foundation + +extension OperatingSystemVersion { + + // MARK: - Instance Properties + + public var fullVersion: String { + guard patchVersion > 0 else { + return "\(majorVersion).\(minorVersion)" + } + + return "\(majorVersion).\(minorVersion).\(patchVersion)" + } +} diff --git a/Sources/AnalyticsGenTools/Extensions/Optional+Extensions.swift b/Sources/AnalyticsGenTools/Extensions/Optional+Extensions.swift new file mode 100644 index 0000000..32e93cf --- /dev/null +++ b/Sources/AnalyticsGenTools/Extensions/Optional+Extensions.swift @@ -0,0 +1,19 @@ +import Foundation + +extension Optional { + + // MARK: - Instance Properties + + public var isNil: Bool { + self == nil + } +} + +extension Optional where Wrapped: Collection { + + // MARK: - Instance Properties + + public var isEmptyOrNil: Bool { + self?.isEmpty ?? true + } +} diff --git a/Sources/AnalyticsGenTools/Extensions/Path+Extensions.swift b/Sources/AnalyticsGenTools/Extensions/Path+Extensions.swift new file mode 100644 index 0000000..0ea7849 --- /dev/null +++ b/Sources/AnalyticsGenTools/Extensions/Path+Extensions.swift @@ -0,0 +1,19 @@ +import Foundation +import PathKit + +extension Path { + + // MARK: - Instance Methods + + public func appending(_ path: Path) -> Path { + return self + path + } + + public func appending(_ path: String) -> Path { + return self + path + } + + public func appending(fileName: String, `extension`: String) -> Path { + return self + "\(fileName).\(`extension`)" + } +} diff --git a/Sources/AnalyticsGenTools/Extensions/ProcessInfo+Extensions.swift b/Sources/AnalyticsGenTools/Extensions/ProcessInfo+Extensions.swift new file mode 100644 index 0000000..23dc4da --- /dev/null +++ b/Sources/AnalyticsGenTools/Extensions/ProcessInfo+Extensions.swift @@ -0,0 +1,10 @@ +import Foundation + +extension ProcessInfo { + + // MARK: - Instance Properties + + public var executablePath: String { + arguments[0] + } +} diff --git a/Sources/AnalyticsGenTools/Extensions/Promise+Extensions.swift b/Sources/AnalyticsGenTools/Extensions/Promise+Extensions.swift new file mode 100644 index 0000000..8d51720 --- /dev/null +++ b/Sources/AnalyticsGenTools/Extensions/Promise+Extensions.swift @@ -0,0 +1,51 @@ +import Foundation +import PromiseKit + +extension Promise { + + // MARK: - Type Methods + + public static func error(_ error: Error) -> Promise { + return Promise(error: error) + } + + // MARK: - Instance Methods + + public func nest( + on queue: DispatchQueue? = conf.Q.map, + flags: DispatchWorkItemFlags? = nil, + _ body: @escaping(T) throws -> U + ) -> Promise { + then(on: queue, flags: flags) { value in + try body(value).map(on: nil) { _ in + value + } + } + } + + public func asOptional() -> Promise { + return map(on: nil) { $0 as T? } + } +} + +public func perform( + on queue: DispatchQueue? = conf.Q.map, + flags: DispatchWorkItemFlags? = nil, + _ body: @escaping () throws -> T +) -> Promise { + return Promise { seal in + let work = { + do { + seal.fulfill(try body()) + } catch { + seal.reject(error) + } + } + + if let queue = queue { + queue.async(flags: flags, work) + } else { + work() + } + } +} diff --git a/Sources/AnalyticsGenTools/Extensions/RangeReplaceableCollection+Extensions.swift b/Sources/AnalyticsGenTools/Extensions/RangeReplaceableCollection+Extensions.swift new file mode 100644 index 0000000..799cc85 --- /dev/null +++ b/Sources/AnalyticsGenTools/Extensions/RangeReplaceableCollection+Extensions.swift @@ -0,0 +1,30 @@ +import Foundation + +extension RangeReplaceableCollection { + + // MARK: - Instance Methods + + public mutating func prepend(contentsOf collection: T) where Self.Element == T.Element { + insert(contentsOf: collection, at: startIndex) + } + + public mutating func prepend(_ element: Element) { + insert(element, at: startIndex) + } + + public func prepending(contentsOf collection: T) -> Self where Self.Element == T.Element { + return collection + self + } + + public func prepending(_ element: Element) -> Self { + return prepending(contentsOf: [element]) + } + + public func appending(contentsOf collection: T) -> Self where Self.Element == T.Element { + return self + collection + } + + public func appending(_ element: Element) -> Self { + return appending(contentsOf: [element]) + } +} diff --git a/Sources/AnalyticsGenTools/Extensions/Sequence+Extensions.swift b/Sources/AnalyticsGenTools/Extensions/Sequence+Extensions.swift new file mode 100644 index 0000000..13dd679 --- /dev/null +++ b/Sources/AnalyticsGenTools/Extensions/Sequence+Extensions.swift @@ -0,0 +1,10 @@ +import Foundation + +extension Sequence { + + // MARK: - Instance Properties + + public var lazyFirst: Element? { + first { _ in true } + } +} diff --git a/Sources/AnalyticsGenTools/Extensions/SingleValueDecodingContainer+Extensions.swift b/Sources/AnalyticsGenTools/Extensions/SingleValueDecodingContainer+Extensions.swift new file mode 100644 index 0000000..4f7a8ce --- /dev/null +++ b/Sources/AnalyticsGenTools/Extensions/SingleValueDecodingContainer+Extensions.swift @@ -0,0 +1,10 @@ +import Foundation + +extension SingleValueDecodingContainer { + + // MARK: - Instance Methods + + public func decode() throws -> T { + return try decode(T.self) + } +} diff --git a/Sources/AnalyticsGenTools/Extensions/String+Extensions.swift b/Sources/AnalyticsGenTools/Extensions/String+Extensions.swift new file mode 100644 index 0000000..d2edba7 --- /dev/null +++ b/Sources/AnalyticsGenTools/Extensions/String+Extensions.swift @@ -0,0 +1,28 @@ +import Foundation + +extension String { + + // MARK: - Type Properties + + public static let empty = "" + + // MARK: - Instance Properties + + public var firstUppercased: String { + prefix(1).uppercased().appending(dropFirst()) + } + + public var firstLowercased: String { + prefix(1).lowercased().appending(dropFirst()) + } + + public var firstCapitalized: String { + prefix(1).capitalized.appending(dropFirst()) + } + + public var camelized: String { + components(separatedBy: CharacterSet.alphanumerics.inverted) + .map { $0.firstUppercased } + .joined() + } +} diff --git a/Sources/AnalyticsGenTools/Extensions/UnkeyedDecodingContainer+Extensions.swift b/Sources/AnalyticsGenTools/Extensions/UnkeyedDecodingContainer+Extensions.swift new file mode 100644 index 0000000..2faf581 --- /dev/null +++ b/Sources/AnalyticsGenTools/Extensions/UnkeyedDecodingContainer+Extensions.swift @@ -0,0 +1,14 @@ +import Foundation + +extension UnkeyedDecodingContainer { + + // MARK: - Instance Methods + + public mutating func decode() throws -> T { + return try decode(T.self) + } + + public mutating func decodeIfPresent() throws -> T? { + return try decodeIfPresent(T.self) + } +} diff --git a/Sources/AnalyticsGenTools/HTTPService/BodyEncoders/HTTPBodyEncoder.swift b/Sources/AnalyticsGenTools/HTTPService/BodyEncoders/HTTPBodyEncoder.swift new file mode 100644 index 0000000..08ee92c --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/BodyEncoders/HTTPBodyEncoder.swift @@ -0,0 +1,12 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public protocol HTTPBodyEncoder { + + // MARK: - Instance Methods + + func encode(request: URLRequest, parameters: T) throws -> URLRequest +} diff --git a/Sources/AnalyticsGenTools/HTTPService/BodyEncoders/HTTPBodyJSONEncoder.swift b/Sources/AnalyticsGenTools/HTTPService/BodyEncoders/HTTPBodyJSONEncoder.swift new file mode 100644 index 0000000..c61b817 --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/BodyEncoders/HTTPBodyJSONEncoder.swift @@ -0,0 +1,46 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public final class HTTPBodyJSONEncoder: HTTPBodyEncoder { + + // MARK: - Type Properties + + public static let `default` = HTTPBodyJSONEncoder(jsonEncoder: JSONEncoder()) + + // MARK: - Instance Properties + + public let jsonEncoder: JSONEncoder + + // MARK: - Initializers + + public init(jsonEncoder: JSONEncoder) { + self.jsonEncoder = jsonEncoder + } + + // MARK: - Instance Methods + + public func encode(request: URLRequest, parameters: T) throws -> URLRequest { + var request = request + + request.httpBody = try jsonEncoder.encode(parameters) + + if !request.httpBody.isEmptyOrNil { + if request.value(forHTTPHeaderField: .contentTypeHeaderField) == nil { + request.setValue(.contentTypeHeaderValue, forHTTPHeaderField: .contentTypeHeaderField) + } + } + + return request + } +} + +private extension String { + + // MARK: - Type Properties + + static let contentTypeHeaderField = "Content-Type" + static let contentTypeHeaderValue = "application/json" +} diff --git a/Sources/AnalyticsGenTools/HTTPService/BodyEncoders/HTTPBodyURLEncoder.swift b/Sources/AnalyticsGenTools/HTTPService/BodyEncoders/HTTPBodyURLEncoder.swift new file mode 100644 index 0000000..5739a43 --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/BodyEncoders/HTTPBodyURLEncoder.swift @@ -0,0 +1,44 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public final class HTTPBodyURLEncoder: HTTPBodyEncoder { + + // MARK: - Type Properties + + public static let `default` = HTTPBodyURLEncoder(urlEncoder: URLEncoder()) + + // MARK: - Instance Properties + + public let urlEncoder: URLEncoder + + // MARK: - Initializers + + public init(urlEncoder: URLEncoder) { + self.urlEncoder = urlEncoder + } + + // MARK: - Instance Methods + + public func encode(request: URLRequest, parameters: T) throws -> URLRequest { + var request = request + + request.httpBody = try urlEncoder.encode(parameters) + + if !request.httpBody.isEmptyOrNil, request.value(forHTTPHeaderField: .contentTypeHeaderField) == nil { + request.setValue(.contentTypeHeaderValue, forHTTPHeaderField: .contentTypeHeaderField) + } + + return request + } +} + +private extension String { + + // MARK: - Type Properties + + static let contentTypeHeaderField = "Content-Type" + static let contentTypeHeaderValue = "application/x-www-form-urlencoded; charset=utf-8" +} diff --git a/Sources/AnalyticsGenTools/HTTPService/HTTPActivityIndicator.swift b/Sources/AnalyticsGenTools/HTTPService/HTTPActivityIndicator.swift new file mode 100644 index 0000000..50fa279 --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/HTTPActivityIndicator.swift @@ -0,0 +1,13 @@ +import Foundation + +public protocol HTTPActivityIndicator: AnyObject { + + // MARK: - Instance Properties + + var activityCount: Int { get } + + // MARK: - Instance Methods + + func incrementActivityCount() + func decrementActivityCount() +} diff --git a/Sources/AnalyticsGenTools/HTTPService/HTTPAnyEncodable.swift b/Sources/AnalyticsGenTools/HTTPService/HTTPAnyEncodable.swift new file mode 100644 index 0000000..fa3f9b3 --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/HTTPAnyEncodable.swift @@ -0,0 +1,20 @@ +import Foundation + +internal struct HTTPAnyEncodable: Encodable { + + // MARK: - Instance Properties + + internal let value: Encodable + + // MARK: - Initializers + + internal init(_ value: Encodable) { + self.value = value + } + + // MARK: - Instance Methods + + internal func encode(to encoder: Encoder) throws { + try value.encode(to: encoder) + } +} diff --git a/Sources/AnalyticsGenTools/HTTPService/HTTPError.swift b/Sources/AnalyticsGenTools/HTTPService/HTTPError.swift new file mode 100644 index 0000000..eed9472 --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/HTTPError.swift @@ -0,0 +1,184 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct HTTPError: Error, CustomStringConvertible { + + // MARK: - Nested Types + + public enum Code { + case unknown + case cancelled + case fileSystem + case tooManyRequests + case networkConnection + case secureConnection + case timedOut + case badRequest + case badResponse + case resource + case server + case access + } + + // MARK: - Instance Properties + + public let code: Code + public let reason: Any? + public let data: Data? + + public var statusCode: HTTPStatusCode? { + reason as? HTTPStatusCode + } + + // MARK: - CustomStringConvertible + + public var description: String { + var description = "\(type(of: self)).\(code)" + + if let reason = reason { + let reasonDescription: String + + if let reason = reason as? HTTPErrorStringConvertible { + reasonDescription = reason.httpErrorDescription + } else { + reasonDescription = String(reflecting: reason) + } + + description.append("(\(reasonDescription))") + } + + return description + } + + // MARK: - Initializers + + public init(code: Code, reason: Any? = nil, data: Data? = nil) { + self.code = code + self.reason = reason + self.data = data + } + + public init(urlError error: URLError, data: Data? = nil) { + // swiftlint:disable:previous function_body_length + + let code: Code + + switch error.code { + case .cancelled: + code = .cancelled + + case .fileDoesNotExist, + .fileIsDirectory, + .cannotCreateFile, + .cannotOpenFile, + .cannotCloseFile, + .cannotWriteToFile, + .cannotRemoveFile, + .cannotMoveFile: + code = .fileSystem + + case .backgroundSessionInUseByAnotherProcess, + .backgroundSessionRequiresSharedContainer, + .backgroundSessionWasDisconnected, + .cannotLoadFromNetwork, + .cannotFindHost, + .cannotConnectToHost, + .dnsLookupFailed, + .internationalRoamingOff, + .networkConnectionLost, + .notConnectedToInternet, + .secureConnectionFailed, + .callIsActive, + .dataNotAllowed: + code = .networkConnection + + case .clientCertificateRejected, + .clientCertificateRequired, + .serverCertificateHasBadDate, + .serverCertificateHasUnknownRoot, + .serverCertificateNotYetValid, + .serverCertificateUntrusted: + code = .secureConnection + + case .timedOut: + code = .timedOut + + case .badURL, + .requestBodyStreamExhausted, + .unsupportedURL: + code = .badRequest + + case .badServerResponse, + .cannotDecodeContentData, + .cannotDecodeRawData, + .cannotParseResponse, + .downloadDecodingFailedMidStream, + .downloadDecodingFailedToComplete: + code = .badResponse + + case .httpTooManyRedirects, + .redirectToNonExistentLocation, + .resourceUnavailable, + .zeroByteResource: + code = .resource + + case .noPermissionsToReadFile, + .userAuthenticationRequired, + .userCancelledAuthentication: + code = .access + + #if !os(Linux) + case .appTransportSecurityRequiresSecureConnection: + // swiftlint:disable:previous vertical_whitespace_between_cases + code = .secureConnection + + case .dataLengthExceedsMaximum: + code = .resource + #endif + + default: + code = .unknown + } + + self.init(code: code, reason: error, data: data) + } + + public init?(statusCode: HTTPStatusCode, data: Data? = nil) { + guard statusCode.state != .success else { + return nil + } + + let code: Code + + switch statusCode { + case 429: + code = .tooManyRequests + + case 511: + code = .networkConnection + + case 408, 504: + code = .timedOut + + case 400, 406, 411, 412, 413, 414, 415, 416, 417, 422, 424, 425, 426, 428, 431, 449: + code = .badRequest + + case 404, 405, 409, 410, 423, 434, 451: + code = .resource + + case 500, 501, 502, 503, 505, 506, 507, 508, 509, 510: + code = .server + + case 401, 402, 403, 407, 444: + code = .access + + default: + code = .unknown + } + + self.init(code: code, reason: statusCode, data: data) + } +} diff --git a/Sources/AnalyticsGenTools/HTTPService/HTTPErrorStringConvertible.swift b/Sources/AnalyticsGenTools/HTTPService/HTTPErrorStringConvertible.swift new file mode 100644 index 0000000..16c05ed --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/HTTPErrorStringConvertible.swift @@ -0,0 +1,23 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public protocol HTTPErrorStringConvertible { + + // MARK: - Instance Properties + + var httpErrorDescription: String { get } +} + +// MARK: - + +extension URLError: HTTPErrorStringConvertible { + + // MARK: - Instance Properties + + public var httpErrorDescription: String { + return "\(type(of: self)).\(self.code.rawValue)" + } +} diff --git a/Sources/AnalyticsGenTools/HTTPService/HTTPHeader.swift b/Sources/AnalyticsGenTools/HTTPService/HTTPHeader.swift new file mode 100644 index 0000000..e52c899 --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/HTTPHeader.swift @@ -0,0 +1,129 @@ +import Foundation + +public struct HTTPHeader: Equatable, CustomStringConvertible { + + // MARK: - Type Methods + + private static func qualityEncoded (_ collection: T) -> String where T.Element == String { + return collection + .enumerated() + .map { "\($1);q=\(1.0 - (Double($0) * 0.1))" } + .joined(separator: ", ") + } + + // MARK: - + + public static func acceptCharset(_ value: String) -> HTTPHeader { + return HTTPHeader(name: "Accept-Charset", value: value) + } + + public static func acceptLanguage(_ value: String) -> HTTPHeader { + return HTTPHeader(name: "Accept-Language", value: value) + } + + public static func acceptEncoding(_ value: String) -> HTTPHeader { + return HTTPHeader(name: "Accept-Encoding", value: value) + } + + public static func authorization(username: String, password: String) -> HTTPHeader { + let credential = Data("\(username):\(password)".utf8).base64EncodedString() + + return authorization("Basic \(credential)") + } + + public static func authorization(bearerToken: String) -> HTTPHeader { + return authorization("Bearer \(bearerToken)") + } + + public static func authorization(_ value: String) -> HTTPHeader { + return HTTPHeader(name: "Authorization", value: value) + } + + public static func contentDisposition(_ value: String) -> HTTPHeader { + return HTTPHeader(name: "Content-Disposition", value: value) + } + + public static func contentType(_ value: String) -> HTTPHeader { + return HTTPHeader(name: "Content-Type", value: value) + } + + public static func userAgent(_ value: String) -> HTTPHeader { + return HTTPHeader(name: "User-Agent", value: value) + } + + public static func cookie(_ value: String) -> HTTPHeader { + return HTTPHeader(name: "Cookie", value: value) + } + + // MARK: - + + public static func defaultAcceptLanguage() -> HTTPHeader { + return acceptLanguage(qualityEncoded(Locale.preferredLanguages.prefix(6))) + } + + public static func defaultAcceptEncoding() -> HTTPHeader { + let encodings: [String] + + if #available(iOS 11.0, macOS 10.13, tvOS 11.0, watchOS 4.0, *) { + encodings = ["br", "gzip", "deflate"] + } else { + encodings = ["gzip", "deflate"] + } + + return acceptEncoding(qualityEncoded(encodings)) + } + + public static func defaultUserAgent(bundle: Bundle = Bundle.main) -> HTTPHeader { + let systemName: String + + #if os(iOS) + systemName = "iOS" + #elseif os(watchOS) + systemName = "watchOS" + #elseif os(tvOS) + systemName = "tvOS" + #elseif os(macOS) + systemName = "macOS" + #elseif os(Linux) + systemName = "Linux" + #else + systemName = "Unknown" + #endif + + let system = "\(systemName) \(ProcessInfo.processInfo.operatingSystemVersion.fullVersion)" + + let appBundleIdentifier = bundle.bundleIdentifier ?? "Unknown" + let appExecutableName = bundle.executableName ?? "Unknown" + let appVersion = bundle.version ?? "Unknown" + let appBuild = bundle.build ?? "Unknown" + + return userAgent("\(appExecutableName)/\(appVersion) (\(appBundleIdentifier); build:\(appBuild); \(system))") + } + + // MARK: - Instance Properties + + public let name: String + public let value: String + + // MARK: - CustomStringConvertible + + public var description: String { + return "\(name): \(value)" + } + + // MARK: - Initializers + + public init(name: String, value: String) { + self.name = name + self.value = value + } +} + +extension Collection where Element == HTTPHeader { + + // MARK: - Instance Properties + + public var rawHTTPHeaders: [String: String] { + return Dictionary(map { ($0.name, $0.value) }) { $1 } + } +} diff --git a/Sources/AnalyticsGenTools/HTTPService/HTTPMethod.swift b/Sources/AnalyticsGenTools/HTTPService/HTTPMethod.swift new file mode 100644 index 0000000..3f19b1c --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/HTTPMethod.swift @@ -0,0 +1,13 @@ +import Foundation + +public enum HTTPMethod: String { + + // MARK: - Enumeration Cases + + case head = "HEAD" + case get = "GET" + case delete = "DELETE" + case patch = "PATCH" + case post = "POST" + case put = "PUT" +} diff --git a/Sources/AnalyticsGenTools/HTTPService/HTTPResponse.swift b/Sources/AnalyticsGenTools/HTTPService/HTTPResponse.swift new file mode 100644 index 0000000..4bceff4 --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/HTTPResponse.swift @@ -0,0 +1,70 @@ +import Foundation + +public struct HTTPResponse: CustomStringConvertible { + + // MARK: - Instance Properties + + public let result: Result + public let statusCode: HTTPStatusCode? + public let headers: [HTTPHeader]? + + // MARK: - + + public var value: T? { + switch result { + case let .success(value): + return value + + case .failure: + return nil + } + } + + public var error: HTTPError? { + switch result { + case .success: + return nil + + case let .failure(error): + return error + } + } + + public var isSuccess: Bool { + switch result { + case .success: + return true + + case .failure: + return false + } + } + + public var isFailure: Bool { + return !isSuccess + } + + // MARK: - CustomStringConvertible + + public var description: String { + switch result { + case .success: + return "\(type(of: self)).success" + + case let .failure(error): + return "\(type(of: self)).failure(\(error))" + } + } + + // MARK: - Initializers + + public init( + _ result: Result, + statusCode: HTTPStatusCode? = nil, + headers: [HTTPHeader]? = nil + ) { + self.result = result + self.statusCode = statusCode + self.headers = headers + } +} diff --git a/Sources/AnalyticsGenTools/HTTPService/HTTPRoute.swift b/Sources/AnalyticsGenTools/HTTPService/HTTPRoute.swift new file mode 100644 index 0000000..db96156 --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/HTTPRoute.swift @@ -0,0 +1,80 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct HTTPRoute: CustomStringConvertible { + + // MARK: - Instance Properties + + public let method: HTTPMethod + public let url: URL + public let headers: [HTTPHeader] + + public let queryParameters: Encodable? + public let queryEncoder: HTTPQueryEncoder + + public let bodyParameters: Encodable? + public let bodyEncoder: HTTPBodyEncoder + + // MARK: - CustomStringConvertible + + public var description: String { + return "\(type(of: self)).\(method)(\(url.absoluteString))" + } + + // MARK: - Initializers + + public init( + method: HTTPMethod, + url: URL, + headers: [HTTPHeader] = [], + queryParameters: Encodable? = nil, + queryEncoder: HTTPQueryEncoder = HTTPQueryURLEncoder.default, + bodyParameters: Encodable? = nil, + bodyEncoder: HTTPBodyEncoder = HTTPBodyJSONEncoder.default + ) { + self.method = method + self.url = url + self.headers = headers + + self.queryParameters = queryParameters + self.queryEncoder = queryEncoder + + self.bodyParameters = bodyParameters + self.bodyEncoder = bodyEncoder + } + + // MARK: - Instance Methods + + public func asRequest() throws -> URLRequest { + var request: URLRequest + + if let queryParameters = queryParameters { + let encodedURL = try queryEncoder.encode( + url: url, + parameters: HTTPAnyEncodable(queryParameters) + ) + + request = URLRequest(url: encodedURL) + } else { + request = URLRequest(url: url) + } + + request.httpMethod = method.rawValue + + for header in headers { + request.setValue(header.value, forHTTPHeaderField: header.name) + } + + if let bodyParameters = bodyParameters { + request = try bodyEncoder.encode( + request: request, + parameters: HTTPAnyEncodable(bodyParameters) + ) + } + + return request + } +} diff --git a/Sources/AnalyticsGenTools/HTTPService/HTTPService.swift b/Sources/AnalyticsGenTools/HTTPService/HTTPService.swift new file mode 100644 index 0000000..1edd85f --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/HTTPService.swift @@ -0,0 +1,68 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public final class HTTPService { + + // MARK: - Instance Properties + + private let session: URLSession + + // MARK: - + + public var sessionConfiguration: URLSessionConfiguration { + return session.configuration + } + + public var activityIndicator: HTTPActivityIndicator? + + // MARK: - Initializers + + public init( + sessionConfiguration: URLSessionConfiguration = .httpServiceDefault, + activityIndicator: HTTPActivityIndicator? = nil + ) { + self.session = URLSession(configuration: sessionConfiguration) + self.activityIndicator = activityIndicator + } + + // MARK: - Instance Methods + + public func request(route: HTTPRoute) -> HTTPTask { + DispatchQueue.main.async { + self.activityIndicator?.incrementActivityCount() + } + + return HTTPServiceTask(session: session, route: route) + .launch() + .response { _ in + DispatchQueue.main.async { + self.activityIndicator?.decrementActivityCount() + } + } + } +} + +extension URLSessionConfiguration { + + // MARK: - Type Properties + + public static var httpServiceDefault: URLSessionConfiguration { + let configuration = URLSessionConfiguration.default + + let headers = [ + HTTPHeader.defaultAcceptLanguage(), + HTTPHeader.defaultAcceptEncoding(), + HTTPHeader.defaultUserAgent() + ] + + let rawHeaders = Dictionary(uniqueKeysWithValues: headers.map { ($0.name, $0.value) }) + + configuration.httpAdditionalHeaders = rawHeaders + configuration.timeoutIntervalForRequest = 45 + + return configuration + } +} diff --git a/Sources/AnalyticsGenTools/HTTPService/HTTPServiceTask.swift b/Sources/AnalyticsGenTools/HTTPService/HTTPServiceTask.swift new file mode 100644 index 0000000..280c342 --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/HTTPServiceTask.swift @@ -0,0 +1,226 @@ +import Foundation + +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public final class HTTPServiceTask { + + // MARK: - Instance Properties + + private var responseQueue: OperationQueue + + // MARK: - + + internal var sessionTask: SessionTask? + internal let session: URLSession + + // MARK: - + + public let route: HTTPRoute + + public private(set) var response: HTTPResponse? + + // MARK: - Initializers + + internal init(session: URLSession, route: HTTPRoute) { + self.session = session + self.route = route + + responseQueue = OperationQueue() + + responseQueue.maxConcurrentOperationCount = 1 + responseQueue.qualityOfService = .userInitiated + responseQueue.isSuspended = true + } + + // MARK: - Instance Methods + + internal func addResponseOperation(_ operation: @escaping (_ response: HTTPResponse) -> Void) { + responseQueue.addOperation { + operation(self.response ?? HTTPResponse(.failure(HTTPError(code: .unknown)))) + } + } + + internal func handleResponse(_ response: HTTPResponse) { + self.response = response + + responseQueue.isSuspended = false + } + + internal func handleResponse(_ response: URLResponse?, data: Data?, error: Error?) { + var statusCode: HTTPStatusCode? + var headers: [HTTPHeader]? + + if let response = response as? HTTPURLResponse { + statusCode = HTTPStatusCode(rawValue: response.statusCode) + + headers = response.allHeaderFields.compactMap { (name, value) in + guard let name = name as? String, let value = value as? String else { + return nil + } + + return HTTPHeader(name: name, value: value) + } + } + + let result: Result + + switch error { + case let error as URLError: + result = .failure(HTTPError(urlError: error, data: data)) + + case let error?: + result = .failure(HTTPError(code: .unknown, reason: error, data: data)) + + case nil: + if let error = HTTPError(statusCode: statusCode ?? 0, data: data) { + result = .failure(error) + } else { + result = .success(data) + } + } + + handleResponse(HTTPResponse(result, statusCode: statusCode, headers: headers)) + } + + internal func serializeResponse( + _ response: HTTPResponse, + serializer: Serializer + ) -> HTTPResponse { + let result: Result = response.result.flatMap { data in + do { + let statusCode = response.statusCode ?? 0 + let object: Serializer.SerializedObject + + if let data = data, !data.isEmpty { + object = try serializer.serialize( + data: data, + statusCode: statusCode, + method: self.route.method + ) + } else { + object = try serializer.serializeEmptyResponse( + statusCode: statusCode, + method: self.route.method + ) + } + + return .success(object) + } catch { + return .failure(HTTPError(code: .badResponse, reason: error, data: data)) + } + } + + return HTTPResponse(result, statusCode: response.statusCode, headers: response.headers) + } + + // MARK: - + + @discardableResult + public func response( + on queue: DispatchQueue, + completion: @escaping (_ response: HTTPResponse) -> Void + ) -> Self { + addResponseOperation { response in + queue.async { + completion(response) + } + } + + return self + } + + @discardableResult + public func response( + on queue: DispatchQueue, + serializer: Serializer, + completion: @escaping (_ response: HTTPResponse) -> Void + ) -> Self { + addResponseOperation { response in + let serializedResponse = self.serializeResponse(response, serializer: serializer) + + queue.async { + completion(serializedResponse) + } + } + + return self + } + + @discardableResult + public func responseData( + on queue: DispatchQueue, + completion: @escaping (_ response: HTTPResponse) -> Void + ) -> Self { + return response( + on: queue, + serializer: HTTPDataResponseSerializer(), + completion: completion + ) + } + + @discardableResult + public func responseString( + on queue: DispatchQueue, + encoding: String.Encoding, + completion: @escaping (_ response: HTTPResponse) -> Void + ) -> Self { + return response( + on: queue, + serializer: HTTPStringResponseSerializer(encoding: encoding), + completion: completion + ) + } + + @discardableResult + public func responseJSON( + on queue: DispatchQueue, + options: JSONSerialization.ReadingOptions, + completion: @escaping (_ response: HTTPResponse) -> Void + ) -> Self { + return response( + on: queue, + serializer: HTTPJSONResponseSerializer(options: options), + completion: completion + ) + } + + @discardableResult + public func responseDecodable( + type: T.Type, + on queue: DispatchQueue, + decoder: HTTPResponseDecoder, + completion: @escaping (_ response: HTTPResponse) -> Void + ) -> Self { + return response( + on: queue, + serializer: HTTPDecodableResponseSerializer(decoder: decoder), + completion: completion + ) + } + + public func cancel() { + sessionTask?.cancel() + } +} + +extension HTTPServiceTask: HTTPTask where SessionTask == URLSessionDataTask { + + // MARK: - Instance Methods + + @discardableResult + internal func launch() -> Self { + do { + sessionTask = session.dataTask(with: try route.asRequest()) { data, response, error in + self.handleResponse(response, data: data, error: error) + } + + sessionTask?.resume() + } catch { + handleResponse(HTTPResponse(.failure(HTTPError(code: .badRequest, reason: error)))) + } + + return self + } +} diff --git a/Sources/AnalyticsGenTools/HTTPService/HTTPStatusCode.swift b/Sources/AnalyticsGenTools/HTTPService/HTTPStatusCode.swift new file mode 100644 index 0000000..1bc50e3 --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/HTTPStatusCode.swift @@ -0,0 +1,56 @@ +import Foundation + +public struct HTTPStatusCode: HTTPErrorStringConvertible, Hashable, ExpressibleByIntegerLiteral { + + // MARK: - Nested Types + + public enum State { + + // MARK: - Enumeration Cases + + case unknown + case information + case success + case redirection + case failure + } + + // MARK: - Instance Properties + + public let rawValue: Int + + public var state: State { + switch rawValue { + case 100...199: + return .information + + case 200...299: + return .success + + case 300...399: + return .redirection + + case 400...599: + return .failure + + default: + return .unknown + } + } + + // MARK: - HTTPErrorStringConvertible + + public var httpErrorDescription: String { + "\(type(of: self)).\(rawValue)" + } + + // MARK: - Initializers + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public init(integerLiteral: Int) { + self.init(rawValue: integerLiteral) + } +} diff --git a/Sources/AnalyticsGenTools/HTTPService/HTTPTask.swift b/Sources/AnalyticsGenTools/HTTPService/HTTPTask.swift new file mode 100644 index 0000000..2bb8e9e --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/HTTPTask.swift @@ -0,0 +1,110 @@ +import Foundation + +public protocol HTTPTask: AnyObject { + + // MARK: - Instance Properties + + var route: HTTPRoute { get } + var response: HTTPResponse? { get } + + var isFinished: Bool { get } + + // MARK: - Instance Methods + + @discardableResult + func response( + on queue: DispatchQueue, + completion: @escaping (_ response: HTTPResponse) -> Void + ) -> Self + + @discardableResult + func response( + on queue: DispatchQueue, + serializer: Serializer, + completion: @escaping (_ response: HTTPResponse) -> Void + ) -> Self + + @discardableResult + func responseData( + on queue: DispatchQueue, + completion: @escaping (_ response: HTTPResponse) -> Void + ) -> Self + + @discardableResult + func responseString( + on queue: DispatchQueue, + encoding: String.Encoding, + completion: @escaping (_ response: HTTPResponse) -> Void + ) -> Self + + @discardableResult + func responseJSON( + on queue: DispatchQueue, + options: JSONSerialization.ReadingOptions, + completion: @escaping (_ response: HTTPResponse) -> Void + ) -> Self + + @discardableResult + func responseDecodable( + type: T.Type, + on queue: DispatchQueue, + decoder: HTTPResponseDecoder, + completion: @escaping (_ response: HTTPResponse) -> Void + ) -> Self + + func cancel() +} + +extension HTTPTask { + + // MARK: - Instance Properties + + public var isFinished: Bool { + return response != nil + } + + // MARK: - Instance Methods + + @discardableResult + public func response(completion: @escaping (_ response: HTTPResponse) -> Void) -> Self { + return response(on: .main, completion: completion) + } + + @discardableResult + public func response( + serializer: Serializer, + completion: @escaping (_ response: HTTPResponse) -> Void + ) -> Self { + return response(on: .main, serializer: serializer, completion: completion) + } + + @discardableResult + public func responseData(completion: @escaping (_ response: HTTPResponse) -> Void) -> Self { + return responseData(on: .main, completion: completion) + } + + @discardableResult + public func responseString( + encoding: String.Encoding = .utf8, + completion: @escaping (_ response: HTTPResponse) -> Void + ) -> Self { + return responseString(on: .main, encoding: encoding, completion: completion) + } + + @discardableResult + public func responseJSON( + options: JSONSerialization.ReadingOptions = .allowFragments, + completion: @escaping (_ response: HTTPResponse) -> Void + ) -> Self { + return responseJSON(on: .main, options: options, completion: completion) + } + + @discardableResult + public func responseDecodable( + type: T.Type, + decoder: HTTPResponseDecoder = JSONDecoder(), + completion: @escaping (_ response: HTTPResponse) -> Void + ) -> Self { + return responseDecodable(type: type, on: .main, decoder: decoder, completion: completion) + } +} diff --git a/Sources/AnalyticsGenTools/HTTPService/HTTPUIActivityIndicator.swift b/Sources/AnalyticsGenTools/HTTPService/HTTPUIActivityIndicator.swift new file mode 100644 index 0000000..d928ab5 --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/HTTPUIActivityIndicator.swift @@ -0,0 +1,40 @@ +#if canImport(UIKit) +import UIKit + +public final class HTTPUIActivityIndicator: WebActivityIndicator { + + // MARK: - Type Properties + + public static let shared = WebUIActivityIndicator() + + // MARK: - Instance Properties + + public private(set) var activityCount = 0 { + didSet { + UIApplication.shared.isNetworkActivityIndicatorVisible = activityCount > 0 + } + } + + // MARK: - Initializers + + private init() { } + + // MARK: - Instance Methods + + public func resetActivityCount() { + activityCount = 0 + } + + // MARK: - WebActivityIndicator + + public func incrementActivityCount() { + activityCount += 1 + } + + public func decrementActivityCount() { + if activityCount > 0 { + activityCount -= 1 + } + } +} +#endif diff --git a/Sources/AnalyticsGenTools/HTTPService/QueryEncoders/HTTPQueryEncoder.swift b/Sources/AnalyticsGenTools/HTTPService/QueryEncoders/HTTPQueryEncoder.swift new file mode 100644 index 0000000..8a13df9 --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/QueryEncoders/HTTPQueryEncoder.swift @@ -0,0 +1,8 @@ +import Foundation + +public protocol HTTPQueryEncoder { + + // MARK: - Instance Methods + + func encode(url: URL, parameters: T) throws -> URL +} diff --git a/Sources/AnalyticsGenTools/HTTPService/QueryEncoders/HTTPQueryURLEncoder.swift b/Sources/AnalyticsGenTools/HTTPService/QueryEncoders/HTTPQueryURLEncoder.swift new file mode 100644 index 0000000..f26e487 --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/QueryEncoders/HTTPQueryURLEncoder.swift @@ -0,0 +1,48 @@ +import Foundation + +public final class HTTPQueryURLEncoder: HTTPQueryEncoder { + + // MARK: - Type Properties + + public static let `default` = HTTPQueryURLEncoder(urlEncoder: URLEncoder()) + + // MARK: - Instance Properties + + public let urlEncoder: URLEncoder + + // MARK: - Initializers + + public init(urlEncoder: URLEncoder) { + self.urlEncoder = urlEncoder + } + + // MARK: - Instance Methods + + public func encode(url: URL, parameters: T) throws -> URL { + guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw MessageError("Invalid URL") + } + + let query = [ + urlComponents.percentEncodedQuery, + try urlEncoder.encodeToQuery(parameters) + ] + + urlComponents.percentEncodedQuery = query + .compactMap { $0 } + .joined(separator: .querySeparator) + + guard let url = urlComponents.url else { + throw MessageError("Invalid query parameters") + } + + return url + } +} + +private extension String { + + // MARK: - Type Properties + + static let querySeparator = "&" +} diff --git a/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPDataResponseSerializer.swift b/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPDataResponseSerializer.swift new file mode 100644 index 0000000..abe44fe --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPDataResponseSerializer.swift @@ -0,0 +1,34 @@ +import Foundation + +public final class HTTPDataResponseSerializer: HTTPResponseSerializer { + + // MARK: - Instance Properties + + public let emptyResponseStatusCodes: Set + public let emptyResponseMethods: Set + + // MARK: - Initializers + + public init( + emptyResponseStatusCodes: Set = defaultEmptyResponseStatusCodes, + emptyResponseMethods: Set = defaultEmptyResponseMethods + ) { + self.emptyResponseStatusCodes = emptyResponseStatusCodes + self.emptyResponseMethods = emptyResponseMethods + } + + // MARK: - Instance Methods + + public func serialize(data: Data, statusCode: HTTPStatusCode, method: HTTPMethod) throws -> Data { + return data + } +} + +extension Data: HTTPEmptyResponse { + + // MARK: - Type Methods + + public static func emptyResponseInstance() -> Data { + return Data() + } +} diff --git a/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPDecodableResponseSerializer.swift b/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPDecodableResponseSerializer.swift new file mode 100644 index 0000000..5c2f692 --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPDecodableResponseSerializer.swift @@ -0,0 +1,32 @@ +import Foundation + +public final class HTTPDecodableResponseSerializer: HTTPResponseSerializer { + + // MARK: - Instance Properties + + public let decoder: HTTPResponseDecoder + + // MARK: - HTTPResponseSerializer + + public let emptyResponseStatusCodes: Set + public let emptyResponseMethods: Set + + // MARK: - Initializers + + public init( + decoder: HTTPResponseDecoder, + emptyResponseStatusCodes: Set = defaultEmptyResponseStatusCodes, + emptyResponseMethods: Set = defaultEmptyResponseMethods + ) { + self.decoder = decoder + + self.emptyResponseStatusCodes = emptyResponseStatusCodes + self.emptyResponseMethods = emptyResponseMethods + } + + // MARK: - Instance Methods + + public func serialize(data: Data, statusCode: HTTPStatusCode, method: HTTPMethod) throws -> T { + return try decoder.decode(T.self, from: data) + } +} diff --git a/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPEmptyResponse.swift b/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPEmptyResponse.swift new file mode 100644 index 0000000..95d500c --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPEmptyResponse.swift @@ -0,0 +1,8 @@ +import Foundation + +public protocol HTTPEmptyResponse { + + // MARK: - Type Methods + + static func emptyResponseInstance() -> Self +} diff --git a/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPJSONResponseSerializer.swift b/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPJSONResponseSerializer.swift new file mode 100644 index 0000000..934f9af --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPJSONResponseSerializer.swift @@ -0,0 +1,31 @@ +import Foundation + +public final class HTTPJSONResponseSerializer: HTTPResponseSerializer { + + // MARK: - Instance Properties + + public let options: JSONSerialization.ReadingOptions + + // MARK: - HTTPResponseSerializer + + public let emptyResponseStatusCodes: Set + public let emptyResponseMethods: Set + + // MARK: - Initializers + + public init( + options: JSONSerialization.ReadingOptions, + emptyResponseStatusCodes: Set = defaultEmptyResponseStatusCodes, + emptyResponseMethods: Set = defaultEmptyResponseMethods + ) { + self.options = options + self.emptyResponseStatusCodes = emptyResponseStatusCodes + self.emptyResponseMethods = emptyResponseMethods + } + + // MARK: - Instance Methods + + public func serialize(data: Data, statusCode: HTTPStatusCode, method: HTTPMethod) throws -> Any { + return try JSONSerialization.jsonObject(with: data, options: options) + } +} diff --git a/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPResponseDecoder.swift b/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPResponseDecoder.swift new file mode 100644 index 0000000..0a6f7e8 --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPResponseDecoder.swift @@ -0,0 +1,12 @@ +import Foundation + +public protocol HTTPResponseDecoder { + + // MARK: - Instance Methods + + func decode(_ type: T.Type, from data: Data) throws -> T +} + +// MARK: - + +extension JSONDecoder: HTTPResponseDecoder { } diff --git a/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPResponseSerializer.swift b/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPResponseSerializer.swift new file mode 100644 index 0000000..51fed09 --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPResponseSerializer.swift @@ -0,0 +1,49 @@ +import Foundation + +public protocol HTTPResponseSerializer { + + // MARK: - Nested Types + + associatedtype SerializedObject + + // MARK: - Instance Properties + + var emptyResponseStatusCodes: Set { get } + var emptyResponseMethods: Set { get } + + // MARK: - Instance Methods + + func serialize(data: Data, statusCode: HTTPStatusCode, method: HTTPMethod) throws -> SerializedObject + func serializeEmptyResponse(statusCode: HTTPStatusCode, method: HTTPMethod) throws -> SerializedObject +} + +extension HTTPResponseSerializer { + + // MARK: - Type Properties + + public static var defaultEmptyResponseStatusCodes: Set { + [204, 205] + } + + public static var defaultEmptyResponseMethods: Set { + [.head] + } + + // MARK: - Instance Methods + + public func serializeEmptyResponse(statusCode: HTTPStatusCode, method: HTTPMethod) throws -> SerializedObject { + guard emptyResponseStatusCodes.contains(statusCode) || emptyResponseMethods.contains(method) else { + throw MessageError("Empty response is unacceptable") + } + + guard let emptyResponseType = SerializedObject.self as? HTTPEmptyResponse.Type else { + throw MessageError("\(SerializedObject.self) cannot have an empty instance") + } + + guard let emptyResponseInstance = emptyResponseType.emptyResponseInstance() as? SerializedObject else { + throw MessageError("Empty instance of \(SerializedObject.self) has an invalid type") + } + + return emptyResponseInstance + } +} diff --git a/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPStringResponseSerializer.swift b/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPStringResponseSerializer.swift new file mode 100644 index 0000000..58c8853 --- /dev/null +++ b/Sources/AnalyticsGenTools/HTTPService/ResponseSerializers/HTTPStringResponseSerializer.swift @@ -0,0 +1,44 @@ +import Foundation + +public final class HTTPStringResponseSerializer: HTTPResponseSerializer { + + // MARK: - Instance Properties + + public let encoding: String.Encoding + + // MARK: - HTTPResponseSerializer + + public let emptyResponseStatusCodes: Set + public let emptyResponseMethods: Set + + // MARK: - Initializers + + public init( + encoding: String.Encoding, + emptyResponseStatusCodes: Set = defaultEmptyResponseStatusCodes, + emptyResponseMethods: Set = defaultEmptyResponseMethods + ) { + self.encoding = encoding + self.emptyResponseStatusCodes = emptyResponseStatusCodes + self.emptyResponseMethods = emptyResponseMethods + } + + // MARK: - Instance Methods + + public func serialize(data: Data, statusCode: HTTPStatusCode, method: HTTPMethod) throws -> String { + guard let string = String(data: data, encoding: encoding) else { + throw MessageError("String serialization failed with encoding: \(encoding)") + } + + return string + } +} + +extension String: HTTPEmptyResponse { + + // MARK: - Type Methods + + public static func emptyResponseInstance() -> String { + return "" + } +} diff --git a/Sources/AnalyticsGenTools/Shared/AnyCodable.swift b/Sources/AnalyticsGenTools/Shared/AnyCodable.swift new file mode 100644 index 0000000..616ddc4 --- /dev/null +++ b/Sources/AnalyticsGenTools/Shared/AnyCodable.swift @@ -0,0 +1,207 @@ +import Foundation + +public struct AnyCodable: Codable { + + // MARK: - Instance Properties + + public let value: Any + + // MARK: - Initializers + + public init(_ value: T?) { + self.value = value as Any + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self.init(nil as Any?) + } else { + let string = try? container.decode(String.self) + + if let bool = try? container.decode(Bool.self) { + if let string = string, string != String(describing: bool) { + self.init(string) + } else { + self.init(bool) + } + } else if let int = try? container.decode(Int.self) { + if let string = string, string != String(describing: int) { + self.init(string) + } else { + self.init(int) + } + } else if let uint = try? container.decode(UInt.self) { + if let string = string, string != String(describing: uint) { + self.init(string) + } else { + self.init(uint) + } + } else if let double = try? container.decode(Double.self) { + if let string = string, string != String(describing: double) { + self.init(string) + } else { + self.init(double) + } + } else if let string = string { + self.init(string) + } else if let array = try? container.decode([AnyCodable].self) { + self.init(array.map { $0.value }) + } else if let dictionary = try? container.decode([String: AnyCodable].self) { + self.init(dictionary.mapValues { $0.value }) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "AnyCodable value cannot be decoded" + ) + } + } + } + + // MARK: - Instance Methods + + public func encode(to encoder: Encoder) throws { + // swiftlint:disable:previous function_body_length + + var container = encoder.singleValueContainer() + + switch value { + case nil as Any?: + try container.encodeNil() + + case let bool as Bool: + try container.encode(bool) + + case let int as Int: + try container.encode(int) + + case let int8 as Int8: + try container.encode(int8) + + case let int16 as Int16: + try container.encode(int16) + + case let int32 as Int32: + try container.encode(int32) + + case let int64 as Int64: + try container.encode(int64) + + case let uint as UInt: + try container.encode(uint) + + case let uint8 as UInt8: + try container.encode(uint8) + + case let uint16 as UInt16: + try container.encode(uint16) + + case let uint32 as UInt32: + try container.encode(uint32) + + case let uint64 as UInt64: + try container.encode(uint64) + + case let float as Float: + try container.encode(float) + + case let double as Double: + try container.encode(double) + + case let string as String: + try container.encode(string) + + case let date as Date: + try container.encode(date) + + case let url as URL: + try container.encode(url.absoluteString) + + case let array as [Any?]: + try container.encode(array.map(AnyCodable.init)) + + case let dictionary as [String: Any?]: + try container.encode(dictionary.mapValues(AnyCodable.init)) + + default: + let context = EncodingError.Context( + codingPath: container.codingPath, + debugDescription: "AnyCodable value cannot be encoded" + ) + + throw EncodingError.invalidValue(value, context) + } + } +} + +extension AnyCodable: Equatable { + + // MARK: - Type Methods + + public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { + // swiftlint:disable:previous function_body_length + + switch (lhs.value, rhs.value) { + case (nil as Any?, nil as Any?): + return true + + case let (lhs as Bool, rhs as Bool): + return lhs == rhs + + case let (lhs as Int, rhs as Int): + return lhs == rhs + + case let (lhs as Int8, rhs as Int8): + return lhs == rhs + + case let (lhs as Int16, rhs as Int16): + return lhs == rhs + + case let (lhs as Int32, rhs as Int32): + return lhs == rhs + + case let (lhs as Int64, rhs as Int64): + return lhs == rhs + + case let (lhs as UInt, rhs as UInt): + return lhs == rhs + + case let (lhs as UInt8, rhs as UInt8): + return lhs == rhs + + case let (lhs as UInt16, rhs as UInt16): + return lhs == rhs + + case let (lhs as UInt32, rhs as UInt32): + return lhs == rhs + + case let (lhs as UInt64, rhs as UInt64): + return lhs == rhs + + case let (lhs as Float, rhs as Float): + return lhs == rhs + + case let (lhs as Double, rhs as Double): + return lhs == rhs + + case let (lhs as String, rhs as String): + return lhs == rhs + + case let (lhs as Date, rhs as Date): + return lhs == rhs + + case let (lhs as URL, rhs as URL): + return lhs == rhs + + case let (lhs as [String: Any], rhs as [String: Any]): + return lhs.mapValues(AnyCodable.init) == rhs.mapValues(AnyCodable.init) + + case let (lhs as [Any], rhs as [Any]): + return lhs.map(AnyCodable.init) == rhs.map(AnyCodable.init) + + default: + return false + } + } +} diff --git a/Sources/AnalyticsGenTools/Shared/AnyCodingKey.swift b/Sources/AnalyticsGenTools/Shared/AnyCodingKey.swift new file mode 100644 index 0000000..6e2cfd7 --- /dev/null +++ b/Sources/AnalyticsGenTools/Shared/AnyCodingKey.swift @@ -0,0 +1,29 @@ +import Foundation + +public struct AnyCodingKey: CodingKey { + + // MARK: - Instance Properties + + public let stringValue: String + public let intValue: Int? + + // MARK: - Initializers + + public init(_ stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + public init(_ intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } + + public init?(stringValue: String) { + self.init(stringValue) + } + + public init?(intValue: Int) { + self.init(intValue) + } +} diff --git a/Sources/AnalyticsGenTools/Shared/Cache.swift b/Sources/AnalyticsGenTools/Shared/Cache.swift new file mode 100644 index 0000000..f65bd00 --- /dev/null +++ b/Sources/AnalyticsGenTools/Shared/Cache.swift @@ -0,0 +1,66 @@ +import Foundation + +public final class Cache { + + // MARK: - Instance Properties + + private let wrapped = NSCache, CacheValue>() + + // MARK: - Initializers + + public init() { } + + // MARK: - Instance Methods + + public func value(forKey key: Key) -> Value? { + return wrapped.object(forKey: CacheKey(key))?.value + } + + public func setValue(_ value: Value, forKey key: Key) { + wrapped.setObject(CacheValue(value), forKey: CacheKey(key)) + } + + public func removeValue(forKey key: Key) { + wrapped.removeObject(forKey: CacheKey(key)) + } +} + +private final class CacheKey: NSObject { + + // MARK: - Instance Properties + + let key: T + + override var hash: Int { + return key.hashValue + } + + // MARK: - Initializers + + init(_ key: T) { + self.key = key + } + + // MARK: - Instance Methods + + override func isEqual(_ object: Any?) -> Bool { + guard let object = object as? CacheKey else { + return false + } + + return key == object.key + } +} + +private final class CacheValue { + + // MARK: - Instance Properties + + let value: T + + // MARK: - Initializers + + init(_ value: T) { + self.value = value + } +} diff --git a/Sources/AnalyticsGenTools/Shared/MessageError.swift b/Sources/AnalyticsGenTools/Shared/MessageError.swift new file mode 100644 index 0000000..23295ad --- /dev/null +++ b/Sources/AnalyticsGenTools/Shared/MessageError.swift @@ -0,0 +1,22 @@ +import Foundation + +public struct MessageError: Error, CustomStringConvertible, Hashable { + + // MARK: - Instance Properties + + public let message: String + + public var localizedDescription: String { + message + } + + public var description: String { + "\(type(of: self))(\"\(message)\")" + } + + // MARK: - Initializers + + public init(_ message: String) { + self.message = message + } +} diff --git a/Sources/AnalyticsGenTools/URLEncoder/URLArrayEncodingStrategy.swift b/Sources/AnalyticsGenTools/URLEncoder/URLArrayEncodingStrategy.swift new file mode 100644 index 0000000..a56a408 --- /dev/null +++ b/Sources/AnalyticsGenTools/URLEncoder/URLArrayEncodingStrategy.swift @@ -0,0 +1,9 @@ +import Foundation + +public enum URLArrayEncodingStrategy { + + // MARK: - Enumeration Case + + case brackets + case noBrackets +} diff --git a/Sources/AnalyticsGenTools/URLEncoder/URLBoolEncodingStrategy.swift b/Sources/AnalyticsGenTools/URLEncoder/URLBoolEncodingStrategy.swift new file mode 100644 index 0000000..5d578a4 --- /dev/null +++ b/Sources/AnalyticsGenTools/URLEncoder/URLBoolEncodingStrategy.swift @@ -0,0 +1,9 @@ +import Foundation + +public enum URLBoolEncodingStrategy { + + // MARK: - Enumeration Case + + case numeric + case literal +} diff --git a/Sources/AnalyticsGenTools/URLEncoder/URLDateEncodingStrategy.swift b/Sources/AnalyticsGenTools/URLEncoder/URLDateEncodingStrategy.swift new file mode 100644 index 0000000..6a4bafb --- /dev/null +++ b/Sources/AnalyticsGenTools/URLEncoder/URLDateEncodingStrategy.swift @@ -0,0 +1,17 @@ +import Foundation + +public enum URLDateEncodingStrategy { + + // MARK: - Enumeration Cases + + case deferredToDate + + case millisecondsSince1970 + case secondsSince1970 + + @available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) + case iso8601 + + case formatted(DateFormatter) + case custom((Date) throws -> String) +} diff --git a/Sources/AnalyticsGenTools/URLEncoder/URLEncodedForm.swift b/Sources/AnalyticsGenTools/URLEncoder/URLEncodedForm.swift new file mode 100644 index 0000000..4680d85 --- /dev/null +++ b/Sources/AnalyticsGenTools/URLEncoder/URLEncodedForm.swift @@ -0,0 +1,3 @@ +import Foundation + +internal typealias URLEncodedForm = [String: URLEncodedFormComponent] diff --git a/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormComponent.swift b/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormComponent.swift new file mode 100644 index 0000000..538ebe4 --- /dev/null +++ b/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormComponent.swift @@ -0,0 +1,32 @@ +import Foundation + +internal enum URLEncodedFormComponent { + + // MARK: - Enumeration Cases + + case string(String) + case array([URLEncodedFormComponent]) + case dictionary([String: URLEncodedFormComponent]) + + // MARK: - Instance Properties + + internal var array: [URLEncodedFormComponent]? { + switch self { + case let .array(array): + return array + + default: + return nil + } + } + + internal var dictionary: [String: URLEncodedFormComponent]? { + switch self { + case let .dictionary(object): + return object + + default: + return nil + } + } +} diff --git a/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormContext.swift b/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormContext.swift new file mode 100644 index 0000000..758b7be --- /dev/null +++ b/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormContext.swift @@ -0,0 +1,14 @@ +import Foundation + +internal final class URLEncodedFormContext { + + // MARK: - Instance Properties + + internal var component: URLEncodedFormComponent + + // MARK: - Initializers + + internal init(component: URLEncodedFormComponent) { + self.component = component + } +} diff --git a/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormEncoder.swift b/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormEncoder.swift new file mode 100644 index 0000000..03cea55 --- /dev/null +++ b/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormEncoder.swift @@ -0,0 +1,63 @@ +import Foundation + +internal final class URLEncodedFormEncoder: Encoder { + + // MARK: - Instance Properties + + internal let context: URLEncodedFormContext + internal let boolEncodingStrategy: URLBoolEncodingStrategy + internal let dateEncodingStrategy: URLDateEncodingStrategy + + // MARK: - Encoder + + internal var codingPath: [CodingKey] + + internal var userInfo: [CodingUserInfoKey: Any] { + return [:] + } + + // MARK: - Initializers + + internal init( + context: URLEncodedFormContext, + boolEncodingStrategy: URLBoolEncodingStrategy, + dateEncodingStrategy: URLDateEncodingStrategy, + codingPath: [CodingKey] = [] + ) { + self.context = context + self.boolEncodingStrategy = boolEncodingStrategy + self.dateEncodingStrategy = dateEncodingStrategy + self.codingPath = codingPath + } + + // MARK: - Instance Methods + + internal func container(keyedBy type: Key.Type) -> KeyedEncodingContainer where Key: CodingKey { + let container = URLEncodedFormKeyedEncodingContainer( + context: context, + boolEncodingStrategy: boolEncodingStrategy, + dateEncodingStrategy: dateEncodingStrategy, + codingPath: codingPath + ) + + return KeyedEncodingContainer(container) + } + + internal func unkeyedContainer() -> UnkeyedEncodingContainer { + return URLEncodedFormUnkeyedEncodingContainer( + context: context, + boolEncodingStrategy: boolEncodingStrategy, + dateEncodingStrategy: dateEncodingStrategy, + codingPath: codingPath + ) + } + + internal func singleValueContainer() -> SingleValueEncodingContainer { + return URLEncodedFormSingleValueEncodingContainer( + context: context, + boolEncodingStrategy: boolEncodingStrategy, + dateEncodingStrategy: dateEncodingStrategy, + codingPath: codingPath + ) + } +} diff --git a/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormKeyedEncodingContainer.swift b/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormKeyedEncodingContainer.swift new file mode 100644 index 0000000..a4115e9 --- /dev/null +++ b/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormKeyedEncodingContainer.swift @@ -0,0 +1,91 @@ +import Foundation + +internal final class URLEncodedFormKeyedEncodingContainer: KeyedEncodingContainerProtocol { + + // MARK: - Instance Properties + + internal let context: URLEncodedFormContext + internal let boolEncodingStrategy: URLBoolEncodingStrategy + internal let dateEncodingStrategy: URLDateEncodingStrategy + + // MARK: - KeyedEncodingContainerProtocol + + internal var codingPath: [CodingKey] + + // MARK: - Initializers + + internal init( + context: URLEncodedFormContext, + boolEncodingStrategy: URLBoolEncodingStrategy, + dateEncodingStrategy: URLDateEncodingStrategy, + codingPath: [CodingKey] + ) { + self.context = context + self.boolEncodingStrategy = boolEncodingStrategy + self.dateEncodingStrategy = dateEncodingStrategy + self.codingPath = codingPath + } + + // MARK: - Instance Methods + + internal func encodeNil(forKey key: Key) throws { + let errorContext = EncodingError.Context( + codingPath: codingPath, + debugDescription: "Nil values cannot be encoded in URL" + ) + + throw EncodingError.invalidValue("nil", errorContext) + } + + internal func encode(_ value: T, forKey key: Key) throws { + let container = URLEncodedFormSingleValueEncodingContainer( + context: context, + boolEncodingStrategy: boolEncodingStrategy, + dateEncodingStrategy: dateEncodingStrategy, + codingPath: codingPath.appending(key) + ) + + try container.encode(value) + } + + internal func nestedContainer( + keyedBy keyType: NestedKey.Type, + forKey key: Key + ) -> KeyedEncodingContainer { + let container = URLEncodedFormKeyedEncodingContainer( + context: context, + boolEncodingStrategy: boolEncodingStrategy, + dateEncodingStrategy: dateEncodingStrategy, + codingPath: codingPath.appending(key) + ) + + return KeyedEncodingContainer(container) + } + + internal func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { + return URLEncodedFormUnkeyedEncodingContainer( + context: context, + boolEncodingStrategy: boolEncodingStrategy, + dateEncodingStrategy: dateEncodingStrategy, + codingPath: codingPath.appending(key) + ) + } + + internal func superEncoder() -> Encoder { + return URLEncodedFormEncoder( + context: context, + boolEncodingStrategy: boolEncodingStrategy, + dateEncodingStrategy: dateEncodingStrategy, + codingPath: codingPath + ) + } + + internal func superEncoder(forKey key: Key) -> Encoder { + return URLEncodedFormEncoder( + context: context, + boolEncodingStrategy: boolEncodingStrategy, + dateEncodingStrategy: dateEncodingStrategy, + codingPath: codingPath.appending(key) + ) + } +} diff --git a/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormSerializer.swift b/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormSerializer.swift new file mode 100644 index 0000000..f711cc1 --- /dev/null +++ b/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormSerializer.swift @@ -0,0 +1,108 @@ +import Foundation + +internal final class URLEncodedFormSerializer { + + // MARK: - Instance Properties + + internal let arrayEncodingStrategy: URLArrayEncodingStrategy + internal let spaceEncodingStrategy: URLSpaceEncodingStrategy + + // MARK: - Initializers + + internal init( + arrayEncodingStrategy: URLArrayEncodingStrategy, + spaceEncodingStrategy: URLSpaceEncodingStrategy + ) { + self.arrayEncodingStrategy = arrayEncodingStrategy + self.spaceEncodingStrategy = spaceEncodingStrategy + } + + // MARK: - Instance Methods + + private func escapeString(_ string: String) -> String { + var allowedCharacters = CharacterSet.urlQueryAllowed + + allowedCharacters.remove(charactersIn: .urlDelimiters) + allowedCharacters.insert(charactersIn: .urlUnescapedSpace) + + let escapedString = string.addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? string + + switch spaceEncodingStrategy { + case .percentEscaped: + return escapedString.replacingOccurrences( + of: String.urlUnescapedSpace, + with: String.urlPercentEscapedSpace + ) + + case .plusReplaced: + return escapedString.replacingOccurrences( + of: String.urlUnescapedSpace, + with: String.urlPlusReplacedSpace + ) + } + } + + private func serializeComponent(_ component: URLEncodedFormComponent, key: String) -> String { + switch component { + case let .string(value): + return .urlStringComponent(key: escapeString(key), value: escapeString(value)) + + case let .array(value): + return value + .map { element in + switch arrayEncodingStrategy { + case .brackets: + return serializeComponent(element, key: .urlBracketsArrayComponentKey(key)) + + case .noBrackets: + return serializeComponent(element, key: .urlNoBracketsArrayComponentKey(key)) + } + } + .joined(separator: .urlComponentSeparator) + + case let .dictionary(value): + return value + .map { serializeComponent($0.value, key: .urlDictionaryComponentKey(key, elementKey: $0.key)) } + .joined(separator: .urlComponentSeparator) + } + } + + // MARK: - + + internal func serialize(_ form: URLEncodedForm) -> String { + return form + .map { serializeComponent($0.value, key: $0.key) } + .joined(separator: .urlComponentSeparator) + } +} + +private extension String { + + // MARK: - Type Properties + + static let urlDelimiters = ":#[]@!$&'()*+,;=" + static let urlUnescapedSpace = " " + + static let urlPercentEscapedSpace = "%20" + static let urlPlusReplacedSpace = "+" + + static let urlComponentSeparator = "&" + + // MARK: - Type Methods + + static func urlStringComponent(key: String, value: String) -> String { + return "\(key)=\(value)" + } + + static func urlBracketsArrayComponentKey(_ key: String) -> String { + return "\(key)[]" + } + + static func urlNoBracketsArrayComponentKey(_ key: String) -> String { + return key + } + + static func urlDictionaryComponentKey(_ key: String, elementKey: String) -> String { + return "\(key)[\(elementKey)]" + } +} diff --git a/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormSingleValueEncodingContainer.swift b/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormSingleValueEncodingContainer.swift new file mode 100644 index 0000000..34af99e --- /dev/null +++ b/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormSingleValueEncodingContainer.swift @@ -0,0 +1,257 @@ +import Foundation + +internal final class URLEncodedFormSingleValueEncodingContainer: SingleValueEncodingContainer { + + // MARK: - Instance Properties + + private var canEncodeNextValue = true + + // MARK: - + + internal let context: URLEncodedFormContext + internal let boolEncodingStrategy: URLBoolEncodingStrategy + internal let dateEncodingStrategy: URLDateEncodingStrategy + + // MARK: - SingleValueEncodingContainer + + internal var codingPath: [CodingKey] + + // MARK: - Initializers + + internal init( + context: URLEncodedFormContext, + boolEncodingStrategy: URLBoolEncodingStrategy, + dateEncodingStrategy: URLDateEncodingStrategy, + codingPath: [CodingKey] + ) { + self.context = context + self.boolEncodingStrategy = boolEncodingStrategy + self.dateEncodingStrategy = dateEncodingStrategy + self.codingPath = codingPath + } + + // MARK: - Instance Methods + + private func markAsEncoded(_ value: Any?) throws { + guard canEncodeNextValue else { + let errorContext = EncodingError.Context( + codingPath: codingPath, + debugDescription: "Single value container has already encoded value" + ) + + throw EncodingError.invalidValue(value as Any, errorContext) + } + + canEncodeNextValue = false + } + + private func updatedComponent( + _ component: URLEncodedFormComponent, + with value: URLEncodedFormComponent, + at path: [CodingKey] + ) -> URLEncodedFormComponent { + guard let pathKey = path.first else { + return value + } + + var child: URLEncodedFormComponent + + switch path.count { + case 1: + child = value + + default: + if let index = pathKey.intValue { + let array = component.array ?? [] + + if index < array.count { + child = updatedComponent(array[index], with: value, at: Array(path[1...])) + } else { + child = updatedComponent(.array([]), with: value, at: Array(path[1...])) + } + } else { + child = updatedComponent( + component.dictionary?[pathKey.stringValue] ?? .dictionary([:]), + with: value, + at: Array(path[1...]) + ) + } + } + + if let childIndex = pathKey.intValue { + guard var array = component.array else { + return .array([child]) + } + + if childIndex < array.count { + array[childIndex] = child + } else { + array.append(child) + } + + return .array(array) + } else { + guard var dictionary = component.dictionary else { + return .dictionary([pathKey.stringValue: child]) + } + + dictionary[pathKey.stringValue] = child + + return .dictionary(dictionary) + } + } + + private func encode(_ value: T, as string: String) throws { + try markAsEncoded(value) + + context.component = updatedComponent(context.component, with: .string(string), at: codingPath) + } + + private func encode(_ date: Date) throws { + switch dateEncodingStrategy { + case .deferredToDate: + try markAsEncoded(date) + + let encoder = URLEncodedFormEncoder( + context: context, + boolEncodingStrategy: boolEncodingStrategy, + dateEncodingStrategy: dateEncodingStrategy, + codingPath: codingPath + ) + + try date.encode(to: encoder) + + case .millisecondsSince1970: + try encode(date, as: String(date.timeIntervalSince1970 * 1000.0)) + + case .secondsSince1970: + try encode(date, as: String(date.timeIntervalSince1970)) + + case .iso8601: + guard #available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) else { + fatalError("ISO8601DateFormatter is unavailable on this platform.") + } + + let formattedDate = ISO8601DateFormatter.string( + from: date, + timeZone: Contants.iso8601TimeZone, + formatOptions: .withInternetDateTime + ) + + try encode(date, as: formattedDate) + + case let .formatted(dateFormatter): + try encode(date, as: dateFormatter.string(from: date)) + + case let .custom(closure): + try encode(date, as: try closure(date)) + } + } + + // MARK: - SingleValueEncodingContainer + + internal func encodeNil() throws { + try markAsEncoded(nil) + + let errorContext = EncodingError.Context( + codingPath: codingPath, + debugDescription: "Nil values cannot be encoded in URL" + ) + + throw EncodingError.invalidValue("nil", errorContext) + } + + internal func encode(_ value: Bool) throws { + switch boolEncodingStrategy { + case .numeric: + try encode(value, as: value ? Contants.numericTrue : Contants.numericFalse) + + case .literal: + try encode(value, as: value ? Contants.literalTrue : Contants.literalFalse) + } + } + + internal func encode(_ value: String) throws { + try encode(value, as: value) + } + + internal func encode(_ value: Double) throws { + try encode(value, as: String(value)) + } + + internal func encode(_ value: Float) throws { + try encode(value, as: String(value)) + } + + internal func encode(_ value: Int) throws { + try encode(value, as: String(value)) + } + + internal func encode(_ value: Int8) throws { + try encode(value, as: String(value)) + } + + internal func encode(_ value: Int16) throws { + try encode(value, as: String(value)) + } + + internal func encode(_ value: Int32) throws { + try encode(value, as: String(value)) + } + + internal func encode(_ value: Int64) throws { + try encode(value, as: String(value)) + } + + internal func encode(_ value: UInt) throws { + try encode(value, as: String(value)) + } + + internal func encode(_ value: UInt8) throws { + try encode(value, as: String(value)) + } + + internal func encode(_ value: UInt16) throws { + try encode(value, as: String(value)) + } + + internal func encode(_ value: UInt32) throws { + try encode(value, as: String(value)) + } + + internal func encode(_ value: UInt64) throws { + try encode(value, as: String(value)) + } + + internal func encode(_ value: T) throws { + switch value { + case let date as Date: + try encode(date) + + default: + try markAsEncoded(value) + + let encoder = URLEncodedFormEncoder( + context: context, + boolEncodingStrategy: boolEncodingStrategy, + dateEncodingStrategy: dateEncodingStrategy, + codingPath: codingPath + ) + + try value.encode(to: encoder) + } + } +} + +private enum Contants { + + // MARK: - Type Properties + + static let numericTrue = "1" + static let numericFalse = "0" + + static let literalTrue = "true" + static let literalFalse = "false" + + static let iso8601TimeZone = TimeZone(secondsFromGMT: 0)! +} diff --git a/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormUnkeyedEncodingContainer.swift b/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormUnkeyedEncodingContainer.swift new file mode 100644 index 0000000..d1d5c13 --- /dev/null +++ b/Sources/AnalyticsGenTools/URLEncoder/URLEncodedFormUnkeyedEncodingContainer.swift @@ -0,0 +1,98 @@ +import Foundation + +internal final class URLEncodedFormUnkeyedEncodingContainer: UnkeyedEncodingContainer { + + // MARK: - Instance Properties + + internal let context: URLEncodedFormContext + internal let boolEncodingStrategy: URLBoolEncodingStrategy + internal let dateEncodingStrategy: URLDateEncodingStrategy + + // MARK: - UnkeyedEncodingContainer + + internal var codingPath: [CodingKey] + internal var count = 0 + + // MARK: - Initializers + + internal init( + context: URLEncodedFormContext, + boolEncodingStrategy: URLBoolEncodingStrategy, + dateEncodingStrategy: URLDateEncodingStrategy, + codingPath: [CodingKey] + ) { + self.context = context + self.boolEncodingStrategy = boolEncodingStrategy + self.dateEncodingStrategy = dateEncodingStrategy + self.codingPath = codingPath + } + + // MARK: - Instance Methods + + internal func encodeNil() throws { + let errorContext = EncodingError.Context( + codingPath: codingPath, + debugDescription: "Nil values cannot be encoded in URL" + ) + + throw EncodingError.invalidValue("nil", errorContext) + } + + internal func encode(_ value: T) throws { + defer { + count += 1 + } + + let container = URLEncodedFormSingleValueEncodingContainer( + context: context, + boolEncodingStrategy: boolEncodingStrategy, + dateEncodingStrategy: dateEncodingStrategy, + codingPath: codingPath.appending(AnyCodingKey(count)) + ) + + try container.encode(value) + } + + internal func nestedContainer( + keyedBy keyType: NestedKey.Type + ) -> KeyedEncodingContainer { + defer { + count += 1 + } + + let container = URLEncodedFormKeyedEncodingContainer( + context: context, + boolEncodingStrategy: boolEncodingStrategy, + dateEncodingStrategy: dateEncodingStrategy, + codingPath: codingPath.appending(AnyCodingKey(count)) + ) + + return KeyedEncodingContainer(container) + } + + internal func nestedUnkeyedContainer() -> UnkeyedEncodingContainer { + defer { + count += 1 + } + + return URLEncodedFormUnkeyedEncodingContainer( + context: context, + boolEncodingStrategy: boolEncodingStrategy, + dateEncodingStrategy: dateEncodingStrategy, + codingPath: codingPath.appending(AnyCodingKey(count)) + ) + } + + internal func superEncoder() -> Encoder { + defer { + count += 1 + } + + return URLEncodedFormEncoder( + context: context, + boolEncodingStrategy: boolEncodingStrategy, + dateEncodingStrategy: dateEncodingStrategy, + codingPath: codingPath + ) + } +} diff --git a/Sources/AnalyticsGenTools/URLEncoder/URLEncoder.swift b/Sources/AnalyticsGenTools/URLEncoder/URLEncoder.swift new file mode 100644 index 0000000..42d9207 --- /dev/null +++ b/Sources/AnalyticsGenTools/URLEncoder/URLEncoder.swift @@ -0,0 +1,65 @@ +import Foundation + +public final class URLEncoder { + + // MARK: - Type Properties + + public static let `default` = URLEncoder() + + // MARK: - Instance Properties + + public var boolEncodingStrategy: URLBoolEncodingStrategy + public var dateEncodingStrategy: URLDateEncodingStrategy + public var arrayEncodingStrategy: URLArrayEncodingStrategy + public var spaceEncodingStrategy: URLSpaceEncodingStrategy + + // MARK: - Initializers + + public init( + boolEncodingStrategy: URLBoolEncodingStrategy = .numeric, + dateEncodingStrategy: URLDateEncodingStrategy = .deferredToDate, + arrayEncodingStrategy: URLArrayEncodingStrategy = .brackets, + spaceEncodingStrategy: URLSpaceEncodingStrategy = .percentEscaped + ) { + self.boolEncodingStrategy = boolEncodingStrategy + self.dateEncodingStrategy = dateEncodingStrategy + self.arrayEncodingStrategy = arrayEncodingStrategy + self.spaceEncodingStrategy = spaceEncodingStrategy + } + + // MARK: - Instance Methods + + public func encodeToQuery(_ value: T) throws -> String { + let urlEncodedFormContext = URLEncodedFormContext(component: .dictionary([:])) + + let urlEncodedFormEncoder = URLEncodedFormEncoder( + context: urlEncodedFormContext, + boolEncodingStrategy: boolEncodingStrategy, + dateEncodingStrategy: dateEncodingStrategy + ) + + try value.encode(to: urlEncodedFormEncoder) + + guard case let .dictionary(urlEncodedForm) = urlEncodedFormContext.component else { + let errorContext = EncodingError.Context( + codingPath: [], + debugDescription: "Root component cannot be encoded in URL" + ) + + throw EncodingError.invalidValue("\(value)", errorContext) + } + + let serializer = URLEncodedFormSerializer( + arrayEncodingStrategy: arrayEncodingStrategy, + spaceEncodingStrategy: spaceEncodingStrategy + ) + + return serializer.serialize(urlEncodedForm) + } + + public func encode(_ value: T) throws -> Data { + let query = try encodeToQuery(value) + + return Data(query.utf8) + } +} diff --git a/Sources/AnalyticsGenTools/URLEncoder/URLSpaceEncodingStrategy.swift b/Sources/AnalyticsGenTools/URLEncoder/URLSpaceEncodingStrategy.swift new file mode 100644 index 0000000..d9371bc --- /dev/null +++ b/Sources/AnalyticsGenTools/URLEncoder/URLSpaceEncodingStrategy.swift @@ -0,0 +1,9 @@ +import Foundation + +public enum URLSpaceEncodingStrategy { + + // MARK: - Enumeration Cases + + case percentEscaped + case plusReplaced +} diff --git a/Templates/ColorStyles.stencil b/Templates/ColorStyles.stencil new file mode 100644 index 0000000..a2c12b8 --- /dev/null +++ b/Templates/ColorStyles.stencil @@ -0,0 +1,82 @@ +{% include "FileHeader.stencil" %} +{% if colorStyles %} +{% set accessModifier %}{% if options.publicAccess %}public{% else %}internal{% endif %}{% endset %} +{% set styleTypeName %}{{ options.styleTypeName|default:"ColorStyle" }}{% endset %} +{% set colorTypeName %}{{ options.colorTypeName|default:"UIColor" }}{% endset %} +{% macro propertyName name %}{{ name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords }}{% endmacro %} +{% macro styleMutator propertyName propertyTypeName %} + {% set methodName %}with{{ propertyName|upperFirstLetter }}{% endset %} + {{ accessModifier }} func {{ methodName }}(_ {{propertyName}}: {{ propertyTypeName }}) -> {{ styleTypeName }} { + return {{ styleTypeName }}( + red: red, + green: green, + blue: blue, + alpha: alpha + ) + } +{% endmacro %} + +#if canImport(UIKit) +import UIKit +#else +import AppKit +#endif + +{{ accessModifier }} struct {{ styleTypeName }}: Equatable { + + // MARK: - Type Properties +{% for style in colorStyles %} + + /// {{ style.name }} + /// + /// {{ style.color|colorInfo }} + {{ accessModifier }} static let {% call propertyName style.name %} = {{ styleTypeName }}( + red: {{ style.color.red }}, + green: {{ style.color.green }}, + blue: {{ style.color.blue }}, + alpha: {{ style.color.alpha }} + ) +{% endfor %} + + // MARK: - Instance Properties + + {{ accessModifier }} let red: CGFloat + {{ accessModifier }} let green: CGFloat + {{ accessModifier }} let blue: CGFloat + {{ accessModifier }} let alpha: CGFloat + + {{ accessModifier }} var color: {{ colorTypeName }} { + return {{ colorTypeName }}(style: self) + } + + // MARK: - Initializers + + {{ accessModifier }} init(red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat = 1.0) { + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + } + + // MARK: - Instance Methods + + {% call styleMutator "red" "CGFloat" %} + + {% call styleMutator "green" "CGFloat" %} + + {% call styleMutator "blue" "CGFloat" %} + + {% call styleMutator "alpha" "CGFloat" %} +} + +{{ accessModifier }} extension {{ colorTypeName }} { + + // MARK: - Initializers + + convenience init(style: {{ styleTypeName }}) { + self.init(red: style.red, green: style.green, blue: style.blue, alpha: style.alpha) + } +} +{% else %} +// No color style found +{% endif %} diff --git a/Templates/Events.stencil b/Templates/Events.stencil new file mode 100644 index 0000000..d1870b6 --- /dev/null +++ b/Templates/Events.stencil @@ -0,0 +1,39 @@ +{% include "FileHeader.stencil" %} + +import Foundation + +{% for event in events %} + +// MARK: - {{ event.name }}Event + +/// {{ event.description }} +struct {{ event.name }}Event { + + // MARK: - Instance Properties + + let name = "{{ event.name }}" + + {% for parameter in {{ event.parameters }} %} + /// {{ parameter.description }} + let {{ parameter.name }}: {{ parameter.type }}{% if parameter.isOptional %}?{% endif %} + {% endfor %} +} + +// MARK: - EncodableEvent + +extension {{ event.name }}Event: EncodableEvent { + + // MARK: - Instance Methods + + func encode() -> EventData { + let parameters: [String: Any] = [ + {% for parameter in {{ event.parameters}} %} + "{{ parameter.name }}": self.{{ parameter.name }}, + {% endfor %} + ] + + return EventData(name: self.name, parameters: parameters) + } +} + +{% endfor %} diff --git a/Templates/FileHeader.stencil b/Templates/FileHeader.stencil new file mode 100644 index 0000000..e4820ae --- /dev/null +++ b/Templates/FileHeader.stencil @@ -0,0 +1,2 @@ +// swiftlint:disable all +// Generated using AnalyticsGen diff --git a/Templates/Flurry.stencil b/Templates/Flurry.stencil new file mode 100644 index 0000000..e69de29 diff --git a/Templates/Trackers.stencil b/Templates/Trackers.stencil new file mode 100644 index 0000000..d1c2358 --- /dev/null +++ b/Templates/Trackers.stencil @@ -0,0 +1,84 @@ +{% include "FileHeader.stencil" %} + +import Foundation + +// MARK: - EventData + +struct EventData { + + // MARK: - Instance Properties + + let name: String + let parameters: [String: Any] +} + +// MARK: - EncodableEvent + +protocol EncodableEvent: AnalyticsEvent { + + // MARK: - Instance Methods + + func encode() -> EventData +} + +{% for tracker in trackers %} + +// MARK: - {{ tracker.name }}EventTracker + +import {{ tracker.import }} + +class {{ tracker.name }}EventTracker: AnalyticsEventTracker { + + // MARK: - AnalyticsEventTracker + + func trackEvent(_ event: AnalyticsEvent) { + guard let event = event as? EncodableEvent else { + return + } + + let data = event.encode() + + {% if tracker.name == "Flurry" %} + Flurry.logEvent(data.name, withParameters: data.parameters) + {% elif tracker.name == "Firebase" %} + Analytics.logEvent(data.name, parameters: data.parameters) + {% endif %} + } +} + +{% for event in tracker.events %} + +// MARK: - {{ event.name }}Event + +/// {{ event.description }} +struct {{ event.name }}Event { + + // MARK: - Instance Properties + + let name = "{{ event.name }}" + {% for parameter in event.parameters %} + + /// {{ parameter.description }} + let {{ parameter.name }}: {{ parameter.type }}{% if parameter.isOptional %}?{% endif %} + {% endfor %} +} + +// MARK: - EncodableEvent + +extension {{ event.name }}Event: EncodableEvent { + + // MARK: - Instance Methods + + func encode() -> EventData { + let parameters: [String: Any] = [ + {% for parameter in event.parameters %} + "{{ parameter.name }}": self.{{ parameter.name }}, + {% endfor %} + ] + + return EventData(name: self.name, parameters: parameters) + } +} + +{% endfor %} +{% endfor %} \ No newline at end of file diff --git a/Tests/AnalyticsGenTests/AnalyticsGenTests.swift b/Tests/AnalyticsGenTests/AnalyticsGenTests.swift new file mode 100644 index 0000000..8120c60 --- /dev/null +++ b/Tests/AnalyticsGenTests/AnalyticsGenTests.swift @@ -0,0 +1,47 @@ +import XCTest +import class Foundation.Bundle + +final class AnalyticsGenTests: XCTestCase { + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct + // results. + + // Some of the APIs that we use below are available in macOS 10.13 and above. + guard #available(macOS 10.13, *) else { + return + } + + let fooBinary = productsDirectory.appendingPathComponent("AnalyticsGen") + + let process = Process() + process.executableURL = fooBinary + + let pipe = Pipe() + process.standardOutput = pipe + + try process.run() + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) + + XCTAssertEqual(output, "Hello, world!\n") + } + + /// Returns path to the built products directory. + var productsDirectory: URL { + #if os(macOS) + for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { + return bundle.bundleURL.deletingLastPathComponent() + } + fatalError("couldn't find the products directory") + #else + return Bundle.main.bundleURL + #endif + } + + static var allTests = [ + ("testExample", testExample), + ] +} diff --git a/Tests/AnalyticsGenTests/XCTestManifests.swift b/Tests/AnalyticsGenTests/XCTestManifests.swift new file mode 100644 index 0000000..be3f90e --- /dev/null +++ b/Tests/AnalyticsGenTests/XCTestManifests.swift @@ -0,0 +1,9 @@ +import XCTest + +#if !canImport(ObjectiveC) +public func allTests() -> [XCTestCaseEntry] { + return [ + testCase(AnalyticsGenTests.allTests), + ] +} +#endif diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift new file mode 100644 index 0000000..d9603bb --- /dev/null +++ b/Tests/LinuxMain.swift @@ -0,0 +1,7 @@ +import XCTest + +import AnalyticsGenTests + +var tests = [XCTestCaseEntry]() +tests += AnalyticsGenTests.allTests() +XCTMain(tests)