diff --git a/Apexy.podspec b/Apexy.podspec index dd24482..c89fe9d 100644 --- a/Apexy.podspec +++ b/Apexy.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "Apexy" - s.version = "1.2.1" + s.version = "1.3.0" s.summary = "HTTP transport library" s.homepage = "https://github.com/RedMadRobot/apexy-ios" s.license = { :type => "MIT"} @@ -35,6 +35,11 @@ Pod::Spec.new do |s| sp.dependency "RxSwift" end + s.subspec 'Loader' do |sp| + sp.source_files = "Sources/ApexyLoader/*.swift" + sp.dependency "Apexy/Core" + end + s.default_subspecs = ["Alamofire"] end \ No newline at end of file diff --git a/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.pbxproj b/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.pbxproj new file mode 100644 index 0000000..ae062d6 --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.pbxproj @@ -0,0 +1,497 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 52; + objects = { + +/* Begin PBXBuildFile section */ + 0F02AE3F25F770DF002F8128 /* OrganisationRepositories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F02AE3E25F770DF002F8128 /* OrganisationRepositories.swift */; }; + 0F02AE4425F777DA002F8128 /* Repository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F02AE4325F777DA002F8128 /* Repository.swift */; }; + 0F02AE4725F777E8002F8128 /* Organisation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F02AE4625F777E8002F8128 /* Organisation.swift */; }; + 0F2119BB25F0F9F100C9065C /* ResultViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2119BA25F0F9F100C9065C /* ResultViewController.swift */; }; + 0F2119CF25F0FC2900C9065C /* BaseEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2119CE25F0FC2900C9065C /* BaseEndpoint.swift */; }; + 0F2119D225F0FD1B00C9065C /* ServiceLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2119D125F0FD1B00C9065C /* ServiceLayer.swift */; }; + 0F2119DC25F1023100C9065C /* FetchViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 0F2119DB25F1023100C9065C /* FetchViewController.xib */; }; + 0F2119DF25F1025700C9065C /* ResultViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 0F2119DE25F1025700C9065C /* ResultViewController.xib */; }; + 0F211A0225F24B7700C9065C /* Apexy in Frameworks */ = {isa = PBXBuildFile; productRef = 0F211A0125F24B7700C9065C /* Apexy */; }; + 0F211A0425F24B7700C9065C /* ApexyLoader in Frameworks */ = {isa = PBXBuildFile; productRef = 0F211A0325F24B7700C9065C /* ApexyLoader */; }; + 0F211A1125F2772D00C9065C /* RepositoriesEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F211A1025F2772D00C9065C /* RepositoriesEndpoint.swift */; }; + 0F211A1625F2777500C9065C /* RepositoriesLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F211A1525F2777500C9065C /* RepositoriesLoader.swift */; }; + 0FB3403625F0F6B600BB089D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3403525F0F6B600BB089D /* AppDelegate.swift */; }; + 0FB3403A25F0F6B600BB089D /* FetchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FB3403925F0F6B600BB089D /* FetchViewController.swift */; }; + 0FB3403F25F0F6B700BB089D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0FB3403E25F0F6B700BB089D /* Assets.xcassets */; }; + 0FB3404225F0F6B700BB089D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0FB3404025F0F6B700BB089D /* LaunchScreen.storyboard */; }; + 0FE4A5A525F76FA300D6CED9 /* OrganisationEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FE4A5A425F76FA300D6CED9 /* OrganisationEndpoint.swift */; }; + 0FE4A5A925F76FC800D6CED9 /* OrganisationLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FE4A5A825F76FC800D6CED9 /* OrganisationLoader.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 0F02AE3E25F770DF002F8128 /* OrganisationRepositories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganisationRepositories.swift; sourceTree = ""; }; + 0F02AE4325F777DA002F8128 /* Repository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Repository.swift; sourceTree = ""; }; + 0F02AE4625F777E8002F8128 /* Organisation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Organisation.swift; sourceTree = ""; }; + 0F2119BA25F0F9F100C9065C /* ResultViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultViewController.swift; sourceTree = ""; }; + 0F2119CE25F0FC2900C9065C /* BaseEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseEndpoint.swift; sourceTree = ""; }; + 0F2119D125F0FD1B00C9065C /* ServiceLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceLayer.swift; sourceTree = ""; }; + 0F2119DB25F1023100C9065C /* FetchViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FetchViewController.xib; sourceTree = ""; }; + 0F2119DE25F1025700C9065C /* ResultViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ResultViewController.xib; sourceTree = ""; }; + 0F2119FF25F24B3D00C9065C /* apexy-ios */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "apexy-ios"; path = ..; sourceTree = ""; }; + 0F211A1025F2772D00C9065C /* RepositoriesEndpoint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RepositoriesEndpoint.swift; sourceTree = ""; }; + 0F211A1525F2777500C9065C /* RepositoriesLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RepositoriesLoader.swift; sourceTree = ""; }; + 0FB3403225F0F6B500BB089D /* ApexyLoaderExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ApexyLoaderExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 0FB3403525F0F6B600BB089D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 0FB3403925F0F6B600BB089D /* FetchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchViewController.swift; sourceTree = ""; }; + 0FB3403E25F0F6B700BB089D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 0FB3404125F0F6B700BB089D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 0FB3404325F0F6B700BB089D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 0FE4A5A425F76FA300D6CED9 /* OrganisationEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganisationEndpoint.swift; sourceTree = ""; }; + 0FE4A5A825F76FC800D6CED9 /* OrganisationLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrganisationLoader.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 0FB3402F25F0F6B500BB089D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0F211A0225F24B7700C9065C /* Apexy in Frameworks */, + 0F211A0425F24B7700C9065C /* ApexyLoader in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0F02AE3D25F770D4002F8128 /* Models */ = { + isa = PBXGroup; + children = ( + 0F02AE3E25F770DF002F8128 /* OrganisationRepositories.swift */, + 0F02AE4325F777DA002F8128 /* Repository.swift */, + 0F02AE4625F777E8002F8128 /* Organisation.swift */, + ); + path = Models; + sourceTree = ""; + }; + 0F2119B825F0F9DB00C9065C /* Fetch */ = { + isa = PBXGroup; + children = ( + 0FB3403925F0F6B600BB089D /* FetchViewController.swift */, + 0F2119DB25F1023100C9065C /* FetchViewController.xib */, + ); + path = Fetch; + sourceTree = ""; + }; + 0F2119B925F0F9E100C9065C /* Result */ = { + isa = PBXGroup; + children = ( + 0F2119BA25F0F9F100C9065C /* ResultViewController.swift */, + 0F2119DE25F1025700C9065C /* ResultViewController.xib */, + ); + path = Result; + sourceTree = ""; + }; + 0F2119BF25F0FAC200C9065C /* Frameworks */ = { + isa = PBXGroup; + children = ( + 0F2119FF25F24B3D00C9065C /* apexy-ios */, + ); + name = Frameworks; + sourceTree = ""; + }; + 0F2119C225F0FADE00C9065C /* Loaders */ = { + isa = PBXGroup; + children = ( + 0FE4A5A825F76FC800D6CED9 /* OrganisationLoader.swift */, + 0F211A1525F2777500C9065C /* RepositoriesLoader.swift */, + ); + path = Loaders; + sourceTree = ""; + }; + 0F2119CA25F0FB9800C9065C /* Endpoint */ = { + isa = PBXGroup; + children = ( + 0F211A1025F2772D00C9065C /* RepositoriesEndpoint.swift */, + 0FE4A5A425F76FA300D6CED9 /* OrganisationEndpoint.swift */, + 0F2119CE25F0FC2900C9065C /* BaseEndpoint.swift */, + ); + path = Endpoint; + sourceTree = ""; + }; + 0FB3402925F0F6B500BB089D = { + isa = PBXGroup; + children = ( + 0FB3403425F0F6B600BB089D /* ApexyLoaderExample */, + 0FB3403325F0F6B500BB089D /* Products */, + 0F2119BF25F0FAC200C9065C /* Frameworks */, + ); + sourceTree = ""; + }; + 0FB3403325F0F6B500BB089D /* Products */ = { + isa = PBXGroup; + children = ( + 0FB3403225F0F6B500BB089D /* ApexyLoaderExample.app */, + ); + name = Products; + sourceTree = ""; + }; + 0FB3403425F0F6B600BB089D /* ApexyLoaderExample */ = { + isa = PBXGroup; + children = ( + 0FB3405325F0F73100BB089D /* Supporting */, + 0FB3405225F0F72500BB089D /* Resources */, + 0FB3404A25F0F6F700BB089D /* Sources */, + ); + path = ApexyLoaderExample; + sourceTree = ""; + }; + 0FB3404A25F0F6F700BB089D /* Sources */ = { + isa = PBXGroup; + children = ( + 0FB3405025F0F71A00BB089D /* Application */, + 0FB3404C25F0F70000BB089D /* Business Logic */, + 0FB3404B25F0F6FC00BB089D /* Presentation */, + ); + path = Sources; + sourceTree = ""; + }; + 0FB3404B25F0F6FC00BB089D /* Presentation */ = { + isa = PBXGroup; + children = ( + 0F2119B825F0F9DB00C9065C /* Fetch */, + 0F2119B925F0F9E100C9065C /* Result */, + ); + path = Presentation; + sourceTree = ""; + }; + 0FB3404C25F0F70000BB089D /* Business Logic */ = { + isa = PBXGroup; + children = ( + 0F02AE3D25F770D4002F8128 /* Models */, + 0F2119CA25F0FB9800C9065C /* Endpoint */, + 0F2119C225F0FADE00C9065C /* Loaders */, + 0F2119D125F0FD1B00C9065C /* ServiceLayer.swift */, + ); + path = "Business Logic"; + sourceTree = ""; + }; + 0FB3405025F0F71A00BB089D /* Application */ = { + isa = PBXGroup; + children = ( + 0FB3403525F0F6B600BB089D /* AppDelegate.swift */, + ); + path = Application; + sourceTree = ""; + }; + 0FB3405225F0F72500BB089D /* Resources */ = { + isa = PBXGroup; + children = ( + 0FB3404025F0F6B700BB089D /* LaunchScreen.storyboard */, + 0FB3403E25F0F6B700BB089D /* Assets.xcassets */, + ); + path = Resources; + sourceTree = ""; + }; + 0FB3405325F0F73100BB089D /* Supporting */ = { + isa = PBXGroup; + children = ( + 0FB3404325F0F6B700BB089D /* Info.plist */, + ); + path = Supporting; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 0FB3403125F0F6B500BB089D /* ApexyLoaderExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0FB3404625F0F6B700BB089D /* Build configuration list for PBXNativeTarget "ApexyLoaderExample" */; + buildPhases = ( + 0FB3402E25F0F6B500BB089D /* Sources */, + 0FB3402F25F0F6B500BB089D /* Frameworks */, + 0FB3403025F0F6B500BB089D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ApexyLoaderExample; + packageProductDependencies = ( + 0F211A0125F24B7700C9065C /* Apexy */, + 0F211A0325F24B7700C9065C /* ApexyLoader */, + ); + productName = ApexyLoaderExample; + productReference = 0FB3403225F0F6B500BB089D /* ApexyLoaderExample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 0FB3402A25F0F6B500BB089D /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1240; + LastUpgradeCheck = 1240; + TargetAttributes = { + 0FB3403125F0F6B500BB089D = { + CreatedOnToolsVersion = 12.4; + }; + }; + }; + buildConfigurationList = 0FB3402D25F0F6B500BB089D /* Build configuration list for PBXProject "ApexyLoaderExample" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 0FB3402925F0F6B500BB089D; + productRefGroup = 0FB3403325F0F6B500BB089D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 0FB3403125F0F6B500BB089D /* ApexyLoaderExample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 0FB3403025F0F6B500BB089D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0FB3404225F0F6B700BB089D /* LaunchScreen.storyboard in Resources */, + 0FB3403F25F0F6B700BB089D /* Assets.xcassets in Resources */, + 0F2119DC25F1023100C9065C /* FetchViewController.xib in Resources */, + 0F2119DF25F1025700C9065C /* ResultViewController.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 0FB3402E25F0F6B500BB089D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0FB3403A25F0F6B600BB089D /* FetchViewController.swift in Sources */, + 0F211A1125F2772D00C9065C /* RepositoriesEndpoint.swift in Sources */, + 0FE4A5A925F76FC800D6CED9 /* OrganisationLoader.swift in Sources */, + 0F211A1625F2777500C9065C /* RepositoriesLoader.swift in Sources */, + 0F2119CF25F0FC2900C9065C /* BaseEndpoint.swift in Sources */, + 0F02AE4425F777DA002F8128 /* Repository.swift in Sources */, + 0FB3403625F0F6B600BB089D /* AppDelegate.swift in Sources */, + 0F02AE4725F777E8002F8128 /* Organisation.swift in Sources */, + 0F2119D225F0FD1B00C9065C /* ServiceLayer.swift in Sources */, + 0F02AE3F25F770DF002F8128 /* OrganisationRepositories.swift in Sources */, + 0FE4A5A525F76FA300D6CED9 /* OrganisationEndpoint.swift in Sources */, + 0F2119BB25F0F9F100C9065C /* ResultViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 0FB3404025F0F6B700BB089D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 0FB3404125F0F6B700BB089D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 0FB3404425F0F6B700BB089D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.1; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 0FB3404525F0F6B700BB089D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14.1; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 0FB3404725F0F6B700BB089D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 2VL6F59R75; + INFOPLIST_FILE = ApexyLoaderExample/Supporting/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = redmadrobot.ApexyLoaderExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 0FB3404825F0F6B700BB089D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 2VL6F59R75; + INFOPLIST_FILE = ApexyLoaderExample/Supporting/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 14.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = redmadrobot.ApexyLoaderExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 0FB3402D25F0F6B500BB089D /* Build configuration list for PBXProject "ApexyLoaderExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0FB3404425F0F6B700BB089D /* Debug */, + 0FB3404525F0F6B700BB089D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 0FB3404625F0F6B700BB089D /* Build configuration list for PBXNativeTarget "ApexyLoaderExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0FB3404725F0F6B700BB089D /* Debug */, + 0FB3404825F0F6B700BB089D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + 0F211A0125F24B7700C9065C /* Apexy */ = { + isa = XCSwiftPackageProductDependency; + productName = Apexy; + }; + 0F211A0325F24B7700C9065C /* ApexyLoader */ = { + isa = XCSwiftPackageProductDependency; + productName = ApexyLoader; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 0FB3402A25F0F6B500BB089D /* Project object */; +} diff --git a/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..8147284 --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,25 @@ +{ + "object": { + "pins": [ + { + "package": "Alamofire", + "repositoryURL": "https://github.com/Alamofire/Alamofire.git", + "state": { + "branch": null, + "revision": "eaf6e622dd41b07b251d8f01752eab31bc811493", + "version": "5.4.1" + } + }, + { + "package": "RxSwift", + "repositoryURL": "https://github.com/ReactiveX/RxSwift.git", + "state": { + "branch": null, + "revision": "254617dd7fae0c45319ba5fbea435bf4d0e15b5d", + "version": "5.1.2" + } + } + ] + }, + "version": 1 +} diff --git a/ApexyLoaderExample/ApexyLoaderExample/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ApexyLoaderExample/ApexyLoaderExample/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ApexyLoaderExample/ApexyLoaderExample/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ApexyLoaderExample/ApexyLoaderExample/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..9221b9b --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ApexyLoaderExample/ApexyLoaderExample/Resources/Assets.xcassets/Contents.json b/ApexyLoaderExample/ApexyLoaderExample/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ApexyLoaderExample/ApexyLoaderExample/Resources/Base.lproj/LaunchScreen.storyboard b/ApexyLoaderExample/ApexyLoaderExample/Resources/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Resources/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ApexyLoaderExample/ApexyLoaderExample/Sources/Application/AppDelegate.swift b/ApexyLoaderExample/ApexyLoaderExample/Sources/Application/AppDelegate.swift new file mode 100644 index 0000000..8d16484 --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Sources/Application/AppDelegate.swift @@ -0,0 +1,43 @@ +// +// AppDelegate.swift +// ApexyLoaderExample +// +// Created by Daniil Subbotin on 04.03.2021. +// + +import UIKit + +@UIApplicationMain +final class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + + let window = UIWindow() + self.window = window + + let fetchVC = FetchViewController() + fetchVC.tabBarItem = UITabBarItem( + title: "Fetch", + image: UIImage(systemName: "arrow.down.square.fill"), + tag: 0) + + let resultVC = ResultViewController() + resultVC.tabBarItem = UITabBarItem( + title: "Result", + image: UIImage(systemName: "list.bullet"), + tag: 1) + + let tbc = UITabBarController() + tbc.viewControllers = [fetchVC, resultVC] + + window.frame = UIScreen.main.bounds + window.rootViewController = tbc + window.makeKeyAndVisible() + + return true + } + +} + diff --git a/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Endpoint/BaseEndpoint.swift b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Endpoint/BaseEndpoint.swift new file mode 100644 index 0000000..e993cf0 --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Endpoint/BaseEndpoint.swift @@ -0,0 +1,33 @@ +import Apexy +import Foundation + +/// Base Endpoint for application remote resource. +/// +/// Contains shared logic for all endpoints in app. +protocol BaseEndpoint: Endpoint where Content: Decodable { + /// Content wrapper. + associatedtype Root: Decodable = Content + + /// Extract content from root. + func content(from root: Root) -> Content +} + +extension BaseEndpoint where Root == Content { + func content(from root: Root) -> Content { return root } +} + +extension BaseEndpoint { + + public func content(from response: URLResponse?, with body: Data) throws -> Content { + let resource = try JSONDecoder.default.decode(Root.self, from: body) + return content(from: resource) + } +} + +extension JSONDecoder { + internal static let `default`: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + }() +} diff --git a/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Endpoint/OrganisationEndpoint.swift b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Endpoint/OrganisationEndpoint.swift new file mode 100644 index 0000000..0ee8839 --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Endpoint/OrganisationEndpoint.swift @@ -0,0 +1,20 @@ +// +// OrganisationEndpoint.swift +// ApexyLoaderExample +// +// Created by Daniil Subbotin on 04.03.2021. +// + +import Apexy +import Foundation + +struct OrganisationEndpoint: BaseEndpoint { + + typealias Content = Organisation + + func makeRequest() -> URLRequest { + let url = URL(string: "orgs/RedMadRobot")! + return URLRequest(url: url) + } + +} diff --git a/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Endpoint/RepositoriesEndpoint.swift b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Endpoint/RepositoriesEndpoint.swift new file mode 100644 index 0000000..dec6c8d --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Endpoint/RepositoriesEndpoint.swift @@ -0,0 +1,21 @@ +// +// RepositoriesEndpoint.swift +// ApexyLoaderExample +// +// Created by Daniil Subbotin on 04.03.2021. +// + +import Apexy +import Foundation + +/// List of all Redmadrobot repositories on GitHub +struct RepositoriesEndpoint: BaseEndpoint { + + typealias Content = [Repository] + + func makeRequest() -> URLRequest { + let url = URL(string: "orgs/RedMadRobot/repos")! + return URLRequest(url: url) + } + +} diff --git a/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/OrganisationLoader.swift b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/OrganisationLoader.swift new file mode 100644 index 0000000..0c30f20 --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/OrganisationLoader.swift @@ -0,0 +1,25 @@ +// +// RepositoriesLoader.swift +// ApexyLoaderExample +// +// Created by Daniil Subbotin on 04.03.2021. +// + +import Foundation +import ApexyLoader + +protocol OrganisationLoading: ContentLoading { + var state: LoadingState { get } +} + +final class OrganisationLoader: WebLoader, OrganisationLoading { + func load() { + guard startLoading() else { return } + request(OrganisationEndpoint()) { result in + // imitation of waiting for the request for 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + self.finishLoading(result) + } + } + } +} diff --git a/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/RepositoriesLoader.swift b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/RepositoriesLoader.swift new file mode 100644 index 0000000..3fe519b --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Loaders/RepositoriesLoader.swift @@ -0,0 +1,25 @@ +// +// RepositoriesLoader.swift +// ApexyLoaderExample +// +// Created by Daniil Subbotin on 04.03.2021. +// + +import Foundation +import ApexyLoader + +protocol RepoLoading: ContentLoading { + var state: LoadingState<[Repository]> { get } +} + +final class RepositoriesLoader: WebLoader<[Repository]>, RepoLoading { + func load() { + guard startLoading() else { return } + request(RepositoriesEndpoint()) { result in + // imitation of waiting for the request for 5 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { + self.finishLoading(result) + } + } + } +} diff --git a/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Models/Organisation.swift b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Models/Organisation.swift new file mode 100644 index 0000000..f8a9dc8 --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Models/Organisation.swift @@ -0,0 +1,11 @@ +// +// Organisation.swift +// ApexyLoaderExample +// +// Created by Daniil Subbotin on 09.03.2021. +// + +struct Organisation: Decodable { + let name: String +} + diff --git a/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Models/OrganisationRepositories.swift b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Models/OrganisationRepositories.swift new file mode 100644 index 0000000..e625c26 --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Models/OrganisationRepositories.swift @@ -0,0 +1,13 @@ +// +// OrganisationRepositories.swift +// ApexyLoaderExample +// +// Created by Daniil Subbotin on 09.03.2021. +// + +import Foundation + +struct OrganisationRepositories { + let org: Organisation + let repos: [Repository] +} diff --git a/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Models/Repository.swift b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Models/Repository.swift new file mode 100644 index 0000000..1e7d303 --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/Models/Repository.swift @@ -0,0 +1,11 @@ +// +// Repository.swift +// ApexyLoaderExample +// +// Created by Daniil Subbotin on 09.03.2021. +// + +struct Repository: Decodable { + let name: String +} + diff --git a/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/ServiceLayer.swift b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/ServiceLayer.swift new file mode 100644 index 0000000..8503899 --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Sources/Business Logic/ServiceLayer.swift @@ -0,0 +1,25 @@ +// +// ServiceLayer.swift +// ApexyLoaderExample +// +// Created by Daniil Subbotin on 04.03.2021. +// + +import Apexy +import ApexyURLSession +import Foundation + +final class ServiceLayer { + static let shared = ServiceLayer() + private init() {} + + private(set) lazy var repoLoader: RepoLoading = RepositoriesLoader(apiClient: apiClient) + private(set) lazy var orgLoader: OrganisationLoading = OrganisationLoader(apiClient: apiClient) + + private lazy var apiClient: Client = { + URLSessionClient( + baseURL: URL(string: "https://api.github.com")!, + configuration: .ephemeral + ) + }() +} diff --git a/ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Fetch/FetchViewController.swift b/ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Fetch/FetchViewController.swift new file mode 100644 index 0000000..d91a09a --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Fetch/FetchViewController.swift @@ -0,0 +1,77 @@ +// +// FetchViewController.swift +// ApexyLoaderExample +// +// Created by Daniil Subbotin on 04.03.2021. +// + +import ApexyLoader +import UIKit + +final class FetchViewController: UIViewController { + + @IBOutlet private var downloadButton: UIButton! + @IBOutlet private var activityIndicatorView: UIActivityIndicatorView! + @IBOutlet private var repoTextView: UITextView! + + private let repoLoader: RepoLoading + private let orgLoader: OrganisationLoading + + private var observers = [LoaderObservation]() + + init( + repoLoader: RepoLoading = ServiceLayer.shared.repoLoader, + orgLoader: OrganisationLoading = ServiceLayer.shared.orgLoader) { + + self.repoLoader = repoLoader + self.orgLoader = orgLoader + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + observers.append(repoLoader.observe { [weak self] in + self?.stateDidChange() + }) + + observers.append(orgLoader.observe { [weak self] in + self?.stateDidChange() + }) + } + + private func stateDidChange() { + + let state = orgLoader.state.merge(repoLoader.state) { org, repos in + OrganisationRepositories(org: org, repos: repos) + } + + if state.isLoading { + activityIndicatorView.startAnimating() + } else { + activityIndicatorView.stopAnimating() + } + + switch state { + case .failure(_, let content?), + .loading(let content?), + .success(let content): + let repos = content.repos.map { $0.name }.joined(separator: "\n") + repoTextView.text = "Repositories of the \(content.org.name) organisation:\n\n\(repos)" + default: + break + } + } + + @IBAction private func fetchFileURL() { + repoLoader.load() + orgLoader.load() + } + +} + diff --git a/ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Fetch/FetchViewController.xib b/ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Fetch/FetchViewController.xib new file mode 100644 index 0000000..adcb372 --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Fetch/FetchViewController.xib @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Result/ResultViewController.swift b/ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Result/ResultViewController.swift new file mode 100644 index 0000000..becaf73 --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Result/ResultViewController.swift @@ -0,0 +1,55 @@ +// +// ResultViewController.swift +// ApexyLoaderExample +// +// Created by Daniil Subbotin on 04.03.2021. +// + +import ApexyLoader +import UIKit + +final class ResultViewController: UIViewController { + + @IBOutlet private var activityIndicatorView: UIActivityIndicatorView! + @IBOutlet private var repoTextView: UITextView! + + private let repoLoader: RepoLoading + private var observer: LoaderObservation? + + init(repoLoader: RepoLoading = ServiceLayer.shared.repoLoader) { + self.repoLoader = repoLoader + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + observer = repoLoader.observe { [weak self] in + self?.stateDidUpdate() + } + stateDidUpdate() + } + + private func stateDidUpdate() { + if repoLoader.state.isLoading { + activityIndicatorView.startAnimating() + } else { + activityIndicatorView.stopAnimating() + } + + switch repoLoader.state { + case .failure(_, let content?), + .loading(let content?), + .success(let content): + let repos = content.map { $0.name }.joined(separator: "\n") + repoTextView.text = "Repositories:\n\n\(repos)" + default: + break + } + } + +} diff --git a/ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Result/ResultViewController.xib b/ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Result/ResultViewController.xib new file mode 100644 index 0000000..e6d79fe --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Sources/Presentation/Result/ResultViewController.xib @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ApexyLoaderExample/ApexyLoaderExample/Supporting/Info.plist b/ApexyLoaderExample/ApexyLoaderExample/Supporting/Info.plist new file mode 100644 index 0000000..108ad6c --- /dev/null +++ b/ApexyLoaderExample/ApexyLoaderExample/Supporting/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Documentation/loader.md b/Documentation/loader.md new file mode 100644 index 0000000..2448d6a --- /dev/null +++ b/Documentation/loader.md @@ -0,0 +1,158 @@ +# ApexyLoader + +ApexyLoader is an add-on for Apexy that lets you store fetched data in memory and observe the loading state. + +The main concepts of ApexyLoader are loader and state. + +## Loader + +A loader is an object that fetches, stores data, and notifies subscribers about loading state changes. + +Loader inherits from `WebLoader`. When inheriting from this class you must specify the content type, which must be the same as the content type of `Endpoint`. For example `WebLoader`. + +In the example below a user profile loader is shown. + +`UserProfileEndpoint` returns `UserProfile` and `UserProfileLoader` also must returns `UserProfile`. + +```swift +import Foundation +import ApexyLoaders + +protocol UserProfileLoading: ContentLoading { + var state: LoadingState { get } +} + +final class UserProfileLoader: WebLoader, UserProfileLoading { + func load() { + guard startLoading() else { return } + request(UserProfileEndpoint()) + } +} +``` + +When you create a Loader, you must pass a class that conforms to the `Client` protocol from the Apexy library. + +Example of creating a loader using the Service Locator pattern: + +```swift +import Apexy +import ApexyURLSession +import Foundation + +final class ServiceLayer { + static let shared = ServiceLayer() + private init() {} + + private(set) lazy var userProfileLoader: UserProfileLoading = UserProfileLoader(apiClient: apiClient) + + private lazy var apiClient: Client = { + URLSessionClient(baseURL: URL(string: "https://api.server.com")!, configuration: .ephemeral) + }() +} +``` + +Example of passing a Loader to the `UIViewController`. + +```swift +final class ProfileViewController: UIViewController { + + private let profileLoader: UserProfileLoading + + init(profileLoader: UserProfileLoading = ServiceLayer.shared.userProfileLoader) { + self.profileLoader = profileLoader + super.init(nibName: nil, bundle: nil) + } +} +``` + +## Loading state + +The `enum LoadingState` represents a loading state. It may have the following states: +- `initial` — initial state when content loading has not yet started. +- `loading(cache: Content?)` — content is loading, and there may be cached (previously loaded) content. +- `success(content: Content)` — content successfully loaded. +- `failure(error: Error, cache: Content?)` — unable to load content, there may be cached (previously loaded) content. + +When you create a loader its initial state is `initial`. The loader has `startLoading()` method which must be called to change the state to `loading`. Immediately after the first call of this method the state of the loader becomes `loading(cache: nil)`. If an error occurs then the state becomes `failure(error: Error, cache: nil)`, otherwise `success(Content)`. If after successful content loading the loading content is repeated (e.g. by a pull to refresh), the `loading` and `failure` states will contain the previously loaded content in the `cache` argument. + + + +The state of multiple loaders can be combined using the `merge` method of `LoadingState`. This method takes a second state and closure which returns a new content based on the content of both states. + +In the example below there are two states: the state of loading user info and the state of loading service list. The `merge` method combines these two states into one. Instead of two model objects: `User` and `Service` there will be one `UserServices`. + +```swift +let userState = LoadingState.loading(cache: nil) +let servicesState = LoadingState<[Service]>.success(content: 3) + +let state = userState.merge(servicesState) { user, services in + UserServices(user: user, services: services) +} + +switch state { +case .initial: + // initial state +case .loading(let userServices): + // loading state with optional cache (info about user and list of services) +case .success(let userServices): + // successfull state with info about user and list of services +case .failure(let error, let userServices): + // failed state with optional cache (info about user and list of services) +} +``` + +## Observing loading state + +The `observe` method is used to keep track of the loader state. As with RxSwift and Combine, and in the case of ApexyLoader you need to save the reference to the observer. To do this, you need to declare a variable of `LoaderObservation` type in class properties. + +```swift +final class ProfileViewController: UIViewController { + private var observer: LoaderObservation? + ... + override func viewDidLoad() { + super.viewDidLoad() + observer = userProfileLoader.observe { [weak self] in + guard let self = self else { return } + + switch self.userProfileLoader.state { + case .initial: + // + case .loading(let cache): + // + case .success(let content): + // + case .failure(let error, let cache): + // + } + } + } +} +``` + +## Use cases + +ApexyLoader used in the following scenarios: +1. When you want to store the loaded data in memory. +For example, to use previously loaded data instead of loading it again each time you open a screen. +2. The fetch progress and the fetched data itself are displayed on different screens. +For example, one screen may have a button that initiates a long loading operation. Once the data is fetched, it may be displayed on different screens. The loading process itself may also be displayed on different screens. + +3. When you want to load data from multiple sources and show the loading process and the result as a whole. + +Example: + + + +In this app, the main screen loads a lot of data from different sources: a list of cameras, intercoms, barriers, notifications, user profile. Each loader has its own state. The states of all loaders can be combined into one state and show the result of loading as a whole. + +The camera list loader is reused on the camera list screen. When you go to the camera list screen, you can immediately display the previously loaded data. If you make pull-to-refresh on this screen, the camera list on the main screen will also be updated. + +## Example project + +In the `ApexyLoaderExample` folder, you can see an example of how to use the `ApexyLoader`. + +This app consists of two screens. On the first screen, you can start downloading data, see the download progress and the result (list of repositories and organization name). On the second screen, you can see the download progress and the result (list of repositories). + +This example demonstrates how to use a shared loader between multiple screens, how to observe the loading state, and to merge the states. + + diff --git a/Documentation/loader_ru.md b/Documentation/loader_ru.md new file mode 100644 index 0000000..5bb32b9 --- /dev/null +++ b/Documentation/loader_ru.md @@ -0,0 +1,156 @@ +# ApexyLoader + +ApexyLoader — дополнение для Apexy, которое позволяет хранить загруженные данные в памяти и следить за состоянием загрузки. + +Основными понятиями ApexyLoader являются: загрузчик и состояние. + +## Загрузчик + +Загрузчик — объект который занимается загрузкой, хранением данных и уведомляет подписчиков об изменении состояния загрузки. + +Загрузчик является наследником `WebLoader`. При наследовании от этого класса необходимо указать тип контента который должен быть таким же как и тип контента у `Endpoint`. Например `WebLoader`. + +В примере ниже показан загрузчик профиля пользователя. +`UserProfileEndpoint` возвращает `UserProfile` следовательно и `UserProfileLoader` тоже должен возвращать `UserProfile`. + +```swift +import Foundation +import ApexyLoaders + +protocol UserProfileLoading: ContentLoading { + var state: LoadingState { get } +} + +final class UserProfileLoader: WebLoader, UserProfileLoading { + func load() { + guard startLoading() else { return } + request(UserProfileEndpoint()) + } +} +``` + +При создании загрузчика необходимо передать класс который реализует протокол `Client` из библиотеки Apexy. + +Пример создания загрузчика используя паттерн Service Locator: + +```swift +import Apexy +import ApexyURLSession +import Foundation + +final class ServiceLayer { + static let shared = ServiceLayer() + private init() {} + + private(set) lazy var userProfileLoader: UserProfileLoading = UserProfileLoader(apiClient: apiClient) + + private lazy var apiClient: Client = { + URLSessionClient(baseURL: URL(string: "https://api.server.com")!, configuration: .ephemeral) + }() +} +``` + +Пример передачи зависимости в `UIViewController`. + +```swift +final class ProfileViewController: UIViewController { + + private let profileLoader: UserProfileLoading + + init(profileLoader: UserProfileLoading = ServiceLayer.shared.userProfileLoader) { + self.profileLoader = profileLoader + super.init(nibName: nil, bundle: nil) + } +} +``` + +## Состояния загрузки + +За состояние загрузки отвечает `enum LoadingState`. У него могут быть следующие состояния: +- `initial` — начальное состояние, когда загрузка данных ещё не начата. +- `loading(cache: Content?)` — данные загружаются, при этом может быть закэшированный (ранее загруженный) контент. +- `success(content: Content)` — данные успешно загружены. +- `failure(error: Error, cache: Content?)` — ошибка загрузки данных, при этом может быть закэшированный (ранее загруженный) контент. + +При создании загрузчика его начальное состояние будет `initial`. У загрузчика есть метод `startLoading()` который необходимо вызвать чтобы поменять состояние на `loading`. Сразу после первого вызова этого метода состояние загрузчика становится `loading(cache: nil)`. Если возникнет ошибка то состояние станет `failure(error: Error, cache: nil)`, иначе `success(Content)`. Если после успешной загрузки данных повторить загрузку данных (например при pull to refresh), то состояния `loading` и `failure` будут содеражать в аргументе `cache` ранее загруженные данные. + + + +Состояния нескольких загрузчиков можно объединить с помощью метода `merge` у `LoadingState`. Этот метод принимает второе состояние и замыкание которое возвращает новый контент на основе контента обоих состояний. + +В примере ниже есть два состояния: состояние загрузки информации о пользователе и состояние загрузки списка услуг. С помощью метода `merge` эти два состояния объединяются в одно. Вместо двух модельных объектов: `User` и `Service` будет один `UserServices`. + +```swift +let userState = LoadingState.loading(cache: nil) +let servicesState = LoadingState<[Service]>.success(content: 3) + +let state = userState.merge(servicesState) { user, services in + UserServices(user: user, services: services) +} + +switch state { +case .initial: + // initial state +case .loading(let userServices): + // loading state with optional cache (info about user and list of services) +case .success(let userServices): + // successfull state with info about user and list of services +case .failure(let error, let userServices): + // failed state with optional cache (info about user and list of services) +} +``` + +## Отслеживание состояния загрузки + +Чтобы следить за состоянием загрузчика используется метод `observe`. Как с RxSwift и Combine так и в случае ApexyLoader нужно сохранить ссылку на обсервер. Для этого нужно в свойствах класса объявить переменную типа `LoaderObservation`. + +```swift +final class ProfileViewController: UIViewController { + private var observer: LoaderObservation? + ... + override func viewDidLoad() { + super.viewDidLoad() + observer = userProfileLoader.observe { [weak self] in + guard let self = self else { return } + + switch self.userProfileLoader.state { + case .initial: + // + case .loading(let cache): + // + case .success(let content): + // + case .failure(let error, let cache): + // + } + } + } +} +``` + +## Сценарии использования + +ApexyLoader применяется когда: +1. Необходимо хранить загруженные данные в памяти. +Например, чтобы при каждом заходе на экран не загружать данные заново, а использовать уже загруженные данные. +2. Процесс загрузки и сами загруженные данные отображаются на разных экранах. +Например, на одном экране может быть кнопка которая инициирует долгую операцию загрузки. После загрузки данных они могут отображаться на разных экранах. Сам процесс загрузки также может отображаться на разных экранах. +3. Необходимо загрузить данные из нескольких источников и показать процесс загрузки и результат как одно целое. + +Пример: + + + +В этом приложении на главном экране загружается большое кол-во данных из разных источников: список камер, домофонов, шлагбаумов, уведомления, профиль пользователя. Каждый загрузчик имеет своё состояние. Состояния всех загрузчиков можно объединить в одно состояние и показывать результат загрузки как одно целое. + +Загрузчик списка камер переиспользуется на отдельном экране со списком камер. За счет этого, при переходе на экран со списком камер, можно сразу отобразить загруженные ранее данные. При этом, если на этом экране сделать pull-to-refresh, то список камер на главном экране тоже обновится. + +## Example проект + +Пример использования `ApexyLoader` смотри в папке `ApexyLoaderExample`. + +Это приложение состоит из двух экранов. На первом экране можно начать загрузку данных, видеть индикацию загрузки и результат (список репозиториев и название организации). На втором экране можно видеть индикацию загрузки и результат (список репозиториев). + +В этом примере демонстрируется шаринг загрузчика между экранами, отслеживание состояния загрузки и объединение состояний. + + diff --git a/Documentation/resources/demo.gif b/Documentation/resources/demo.gif new file mode 100644 index 0000000..5677f81 Binary files /dev/null and b/Documentation/resources/demo.gif differ diff --git a/Documentation/resources/img_1.png b/Documentation/resources/img_1.png new file mode 100644 index 0000000..58badad Binary files /dev/null and b/Documentation/resources/img_1.png differ diff --git a/Documentation/resources/uml_state.png b/Documentation/resources/uml_state.png new file mode 100644 index 0000000..d8e99a9 Binary files /dev/null and b/Documentation/resources/uml_state.png differ diff --git a/Package.swift b/Package.swift index 7658179..32024f1 100644 --- a/Package.swift +++ b/Package.swift @@ -14,18 +14,21 @@ let package = Package( products: [ .library(name: "Apexy", targets: ["ApexyURLSession"]), .library(name: "ApexyAlamofire", targets: ["ApexyAlamofire"]), - .library(name: "ApexyRxSwift", targets: ["ApexyRxSwift"]) + .library(name: "ApexyRxSwift", targets: ["ApexyRxSwift"]), + .library(name: "ApexyLoader", targets: ["ApexyLoader"]) ], dependencies: [ .package(url: "https://github.com/Alamofire/Alamofire.git", .upToNextMajor(from: "5.2.0")), .package(url: "https://github.com/ReactiveX/RxSwift.git", from: "5.0.0") ], targets: [ + .target(name: "ApexyLoader", dependencies: ["Apexy"]), .target(name: "ApexyRxSwift", dependencies: ["Apexy", "RxSwift"]), .target(name: "ApexyAlamofire", dependencies: ["Apexy", "Alamofire"]), .target(name: "ApexyURLSession", dependencies: ["Apexy"]), .target(name: "Apexy"), + .testTarget(name: "ApexyLoaderTests", dependencies: ["ApexyLoader"]), .testTarget(name: "ApexyAlamofireTests", dependencies: ["ApexyAlamofire"]), .testTarget(name: "ApexyURLSessionTests", dependencies: ["ApexyURLSession"]), .testTarget(name: "ApexyTests", dependencies: ["Apexy"]) diff --git a/README.md b/README.md index 23b86b2..ea0841d 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ If you want to use Apexy without Alamofire and RxSwift: `pod 'Apexy/URLSession'` +If you want to use [ApexyLoader](Documentation/loader.md): + +`pod 'Apexy/Loader'` + ### Swift Package Manager If you have Xcode project, open it and select **File → Swift Packages → Add package Dependency** and paste Apexy repository URL: @@ -47,6 +51,8 @@ ApexyAlamofire — Uses Alamofire under the hood If you want to use Apexy with RxSwift add ApexyRxSwift package product. +ApexyLoader — add-on for Apexy to store fetched data in memory and observe loading state. See the documentation for details [ApexyLoader](Documentation/loader.md): + If you have your own Swift package, add Apexy as a dependency to the dependencies value of your Package.swift. ```swift @@ -269,9 +275,16 @@ Split the network layer into folders: - `UpdateBookEndpointTests` - `DeleteBookEndpointTests` +## Requirements + +- iOS 11.0+ / macOS 10.13+ / tvOS 11.0+ / watchOS 4.0+ +- Xcode 12+ +- Swift 5.3+ + ## Additional resources - [Nested response](Documentation/nested_response.md) - [Testing](Documentation/tests.md) - [Error handling](Documentation/error_handling.md) - [Reactive programming](Documentation/reactive.md) +- [ApexyLoader](Documentation/loader.md) \ No newline at end of file diff --git a/README.ru.md b/README.ru.md index 7224b7b..3a90dcb 100644 --- a/README.ru.md +++ b/README.ru.md @@ -33,13 +33,18 @@ `pod 'Apexy/URLSession'` +Если вы хотите использовать [ApexyLoader](Documentation/loader_ru.md): + +`pod 'Apexy/Loader'` + + ### Swift Package Manager Если у вас есть Xcode проект, откройте его и выберите **File → Swift Packages → Add package Dependency** и вставьте адрес репозитория Apexy: `https://github.com/RedMadRobot/apexy-ios` -Будут достуны 3 продукта: Apexy, ApexyAlamofire, ApexyRxSwift. +Будут достуны 4 продукта: Apexy, ApexyAlamofire, ApexyRxSwift, ApexyLoader. Apexy — Под капотом использует URLSession @@ -47,6 +52,8 @@ ApexyAlamofire — Под капотом использует Alamofire Если хотите использовать Apexy с RxSwift, то дополнительно подключайте пакет ApexyRxSwift. +ApexyLoader — дополнение для Apexy, которое позволяет хранить загруженные данные в памяти и следить за состоянием загрузки. Подробности смотрите в документации [ApexyLoader](Documentation/loader_ru.md): + Если у вас есть Swift пакет, добавьте Apexy как зависимость в свойство dependencies файла Package.swift. ```swift @@ -269,9 +276,16 @@ public struct FileUploadEndpoint: UploadEndpoint { - `UpdateBookEndpointTests` - `DeleteBookEndpointTests` +## Требования + +- iOS 11.0+ / macOS 10.13+ / tvOS 11.0+ / watchOS 4.0+ +- Xcode 12+ +- Swift 5.3+ + ## Дополнительные материалы - [Вложенные ответы](Documentation/nested_response.ru.md) - [Тестирование](Documentation/tests.ru.md) - [Обработка ошибок](Documentation/error_handling.ru.md) - [Реактивное программирование](Documentation/reactive.ru.md) +- [ApexyLoader](Documentation/loader_ru.md) diff --git a/Sources/ApexyLoader/ContentLoader.swift b/Sources/ApexyLoader/ContentLoader.swift new file mode 100644 index 0000000..79e1643 --- /dev/null +++ b/Sources/ApexyLoader/ContentLoader.swift @@ -0,0 +1,90 @@ +import Foundation + +private final class StateChangeHandler { + let notify: () -> Void + + init(_ notify: @escaping () -> Void) { + self.notify = notify + } +} + +public protocol ContentLoading: ObservableLoader { + /// Starts loading data. + func load() +} + +/// A object that stores loaded content, loading state and allow to observing loading state. +open class ContentLoader: ObservableLoader { + + /// An array of the loader state change handlers + private var stateHandlers: [StateChangeHandler] = [] + + /// An array of the external loader observers. + final public var observations: [LoaderObservation] = [] + + /// Content loading status. The default value is `.initial`. + /// + /// - Remark: To change state use `update(_:)`. + public var state: LoadingState = .initial { + didSet { + stateHandlers.forEach { $0.notify() } + } + } + + // MARK: - ObservableLoader + + /// Starts state observing. + /// + /// - Parameter changeHandler: A closure to execute when the loader state changes. + /// - Returns: An instance of the `LoaderObservation`. + final public func observe(_ changeHandler: @escaping () -> Void) -> LoaderObservation { + let handler = StateChangeHandler(changeHandler) + stateHandlers.append(handler) + return LoaderObservation { [weak self] in + if let index = self?.stateHandlers.firstIndex(where: { $0 === handler }) { + self?.stateHandlers.remove(at: index) + } + } + } + + // MARK: - Loading + + /// Updates the loader state to `.loading`. + /// + /// Call this method before loading data to update the loader state. + /// - Returns: A boolean value indicating the possibility to start loading data. The metod return `false` if the current state is `loading`. + @discardableResult + final public func startLoading() -> Bool { + if state.isLoading { + return false + } + state = .loading(cache: state.content) + return true + } + + /// Updates the loader state using result. + /// + /// Call this method at the end of data loading to update the loader state. + /// - Parameter result: Data loading result. + final public func finishLoading(_ result: Result) { + switch result { + case .success(let content): + state = .success(content: content) + case .failure(let error): + state = .failure(error: error, cache: state.content) + } + } +} + +// MARK: - Content + Equatable + +public extension ContentLoader where Content: Equatable { + + /// Updates state of the loader. + /// - Parameter state: New state. + func update(_ state: LoadingState) { + if self.state != state { + self.state = state + } + } +} diff --git a/Sources/ApexyLoader/LoaderObservation.swift b/Sources/ApexyLoader/LoaderObservation.swift new file mode 100644 index 0000000..8f8ccc1 --- /dev/null +++ b/Sources/ApexyLoader/LoaderObservation.swift @@ -0,0 +1,16 @@ +/// Cancels observation for changes to `ContentLoader` on deinitialization. +/// +/// - Remark: Works like `NSKeyValueObservation`, `AnyCancellable` and `DisposeBag`. +public final class LoaderObservation { + typealias Cancel = () -> Void + + private let cancel: Cancel + + init(_ cancel: @escaping Cancel) { + self.cancel = cancel + } + + deinit { + cancel() + } +} diff --git a/Sources/ApexyLoader/LoadingState.swift b/Sources/ApexyLoader/LoadingState.swift new file mode 100644 index 0000000..0528d93 --- /dev/null +++ b/Sources/ApexyLoader/LoadingState.swift @@ -0,0 +1,127 @@ +/// Represents content loading state. +public enum LoadingState { + + /// Initial empty state. + case initial + + /// Content is loading. + /// + /// - `cache`: Cached content that was previously loaded. + case loading(cache: Content?) + + /// Content successfull loaded. + /// + /// - `content`: Actual loaded content. + case success(content: Content) + + /// Content failed to load. + /// + /// - `error`: An error that occurs while loading content. + /// - `cache`: Cached content that was previously loaded. + case failure(error: Error, cache: Content?) +} + +// MARK: - Properties + +extension LoadingState { + + public var content: Content? { + switch self { + case .loading(let content?), + .success(let content), + .failure(_, let content?): + return content + default: + return nil + } + } + + public var isLoading: Bool { + switch self { + case .loading: + return true + default: + return false + } + } + + public var error: Error? { + switch self { + case .failure(let error, _): + return error + default: + return nil + } + } +} + +// MARK: - Methods + +public extension LoadingState { + + /// Merges two states. + func merge(_ state: LoadingState, transform: (Content, C2) -> C3) -> LoadingState { + + switch (self, state) { + case (.loading(let cache1?), _): + let cache3 = state.content.map { transform(cache1, $0) } + return LoadingState.loading(cache: cache3) + case (_, .loading(let cache2?)): + let cache3 = content.map { transform($0, cache2) } + return LoadingState.loading(cache: cache3) + case (.loading, _), + (_, .loading): + return LoadingState.loading(cache: nil) + case (.failure(let error, let cache1?), _): + let cache3 = state.content.map { transform(cache1, $0) } + return LoadingState.failure(error: error, cache: cache3) + case (_, .failure(let error, let cache2?)): + let cache3 = content.map { transform($0, cache2) } + return LoadingState.failure(error: error, cache: cache3) + case (.failure(let error, _), _), + (_, .failure(let error, _)): + return LoadingState.failure(error: error, cache: nil) + case (.success(let lhs), .success(let rhs)): + return LoadingState.success(content: transform(lhs, rhs)) + case (.initial, .initial), + (.initial, .success), + (.success, .initial): + return LoadingState.initial + } + } +} + +// MARK: - Equatable + +extension LoadingState: Equatable where Content: Equatable { + static public func == (lhs: LoadingState, rhs: LoadingState) -> Bool { + switch (lhs, rhs) { + case (.initial, .initial): + return true + case (.failure(_, let cache1), .failure(_, let cache2)), + (.loading(let cache1), .loading(let cache2)): + return cache1 == cache2 + case (.success(let content1), .success(let content2)): + return content1 == content2 + default: + return false + } + } +} + +// MARK: - CustomStringConvertible + +extension LoadingState: CustomStringConvertible { + public var description: String { + switch self { + case .initial: + return "Initial" + case .loading(let cache): + return "Loading: cache \(String(describing: cache))" + case .success(let content): + return "Success: \(content)" + case .failure(let error, let cache): + return "Failure: \(error), cache \(String(describing: cache))" + } + } +} diff --git a/Sources/ApexyLoader/ObservableLoader.swift b/Sources/ApexyLoader/ObservableLoader.swift new file mode 100644 index 0000000..b365e37 --- /dev/null +++ b/Sources/ApexyLoader/ObservableLoader.swift @@ -0,0 +1,9 @@ +// Loader, which can be observed. +public protocol ObservableLoader: AnyObject { + + /// Starts observing the loader state change. + /// + /// - Parameter changeHandler: State change handler. + /// - Returns: An instance of `LoaderObservation` to cancel observation. + func observe(_ changeHandler: @escaping () -> Void) -> LoaderObservation +} diff --git a/Sources/ApexyLoader/WebLoader.swift b/Sources/ApexyLoader/WebLoader.swift new file mode 100644 index 0000000..8a8d08d --- /dev/null +++ b/Sources/ApexyLoader/WebLoader.swift @@ -0,0 +1,52 @@ +import Apexy +import Foundation + +/// Loads content by network. +open class WebLoader: ContentLoader { + private let apiClient: Client + private var progress: Progress? + + /// Creates an instance of `WebLoader` to load content by network using specified `Client`. + /// - Parameter apiClient: An instance of the `Client` protocol. Use `AlamofireClient` or `URLSessionClient`. + public init(apiClient: Client) { + self.apiClient = apiClient + } + + deinit { + progress?.cancel() + } + + /// Sends requests to the network. + /// + /// - Warning: You must call `startLoading` before calling this method! + /// - Parameter endpoint: An object representing request. + public func request(_ endpoint: T) where T: Endpoint, T.Content == Content { + progress = apiClient.request(endpoint) { [weak self] result in + self?.progress = nil + self?.finishLoading(result) + } + } + + /// Sends requests to the network and transform successfull result + /// + /// - Parameters: + /// - endpoint: An object representing request. + /// - transform: A closure that transforms successfull result. + public func request(_ endpoint: T, transform: @escaping (T.Content) -> Content) where T: Endpoint { + progress = apiClient.request(endpoint) { [weak self] result in + self?.progress = nil + self?.finishLoading(result.map(transform)) + } + } + + /// Sends requests to the network and calls completion handler. + /// - Parameters: + /// - endpoint: An object representing request. + /// - completion: A completion handler. + public func request(_ endpoint: T, completion: @escaping (Result) -> Void) where T: Endpoint { + progress = apiClient.request(endpoint) { [weak self] result in + self?.progress = nil + completion(result) + } + } +} diff --git a/Tests/ApexyLoaderTests/ContentLoaderTests.swift b/Tests/ApexyLoaderTests/ContentLoaderTests.swift new file mode 100644 index 0000000..ce5e94e --- /dev/null +++ b/Tests/ApexyLoaderTests/ContentLoaderTests.swift @@ -0,0 +1,93 @@ +@testable import ApexyLoader +import XCTest + +final class ContentLoaderTests: XCTestCase { + + private var contentLoader: ContentLoader! + private var numberOfChanges = 0 + private var observation: LoaderObservation! + + override func setUp() { + super.setUp() + + numberOfChanges = 0 + contentLoader = ContentLoader() + observation = contentLoader.observe { [weak self] in + self?.numberOfChanges += 1 + } + + XCTAssertTrue( + contentLoader.observations.isEmpty, + "No observation of other loaders") + XCTAssertEqual( + contentLoader.state, + .initial, + "Initial loader state") + } + + func testCancelObservation() { + observation = nil + contentLoader.state = .success(content: 10) + XCTAssertEqual( + numberOfChanges, 0, + "The change handler didn‘t triggered because the observation was canceled") + } + + func testStartLoading() { + XCTAssertTrue( + contentLoader.startLoading(), + "Loading has begun") + XCTAssertTrue( + contentLoader.state == .loading(cache: nil), + "State of the loader must be loading") + XCTAssertEqual( + numberOfChanges, 1, + "Change handler triggered") + + XCTAssertFalse( + contentLoader.startLoading(), + "The second loading didn‘t start before the end of the first one.") + XCTAssertTrue( + contentLoader.state == .loading(cache: nil), + "The load status has NOT changed") + XCTAssertEqual( + numberOfChanges, 1, + "The change handler did NOT triggered") + } + + func testFinishLoading() { + contentLoader.finishLoading(.success(12)) + XCTAssertTrue( + contentLoader.state == .success(content: 12), + "Succesfull loading state") + XCTAssertEqual( + numberOfChanges, 1, + "The change handler triggered") + + let error = URLError(.networkConnectionLost) + contentLoader.finishLoading(.failure(error)) + XCTAssertTrue( + contentLoader.state == .failure(error: error, cache: 12), + "The state must me failure with cache") + XCTAssertEqual( + numberOfChanges, 2, + "The handler triggered") + } + + func testUpdate() { + contentLoader.update(.initial) + XCTAssertEqual( + numberOfChanges, 0, + "The state didn't change and the handler didn't triggered") + + contentLoader.update(.success(content: 1)) + XCTAssertEqual( + numberOfChanges, 1, + "The state changed and the handler triggered") + + contentLoader.update(.success(content: 1)) + XCTAssertEqual( + numberOfChanges, 1, + "The state didn't changed and the handler didn't triggered") + } +} diff --git a/Tests/ApexyLoaderTests/LoaderObservationTests.swift b/Tests/ApexyLoaderTests/LoaderObservationTests.swift new file mode 100644 index 0000000..0bbe1ac --- /dev/null +++ b/Tests/ApexyLoaderTests/LoaderObservationTests.swift @@ -0,0 +1,18 @@ +@testable import ApexyLoader +import XCTest + +final class LoaderObservationTests: XCTestCase { + + private var observation: LoaderObservation! + + func testDeinit() { + var numberOfTriggers = 0 + observation = LoaderObservation { + numberOfTriggers += 1 + } + + observation = nil + + XCTAssertEqual(numberOfTriggers, 1, "The handler triggered once") + } +} diff --git a/Tests/ApexyLoaderTests/LoadingStateTests.swift b/Tests/ApexyLoaderTests/LoadingStateTests.swift new file mode 100644 index 0000000..7c6957a --- /dev/null +++ b/Tests/ApexyLoaderTests/LoadingStateTests.swift @@ -0,0 +1,160 @@ +@testable import ApexyLoader +import XCTest + +final class LoadingStateTests: XCTestCase { + + private let error = URLError(.badURL) + + func testContent() { + XCTAssertNil(LoadingState.initial.content) + XCTAssertNil(LoadingState.loading(cache: nil).content) + XCTAssertNil(LoadingState.failure(error: error, cache: nil).content) + + XCTAssertEqual(LoadingState.loading(cache: 1).content, 1) + XCTAssertEqual(LoadingState.success(content: 2).content, 2) + XCTAssertEqual(LoadingState.failure(error: error, cache: 3).content, 3) + } + + func testIsLoading() { + XCTAssertTrue( + LoadingState.loading(cache: nil).isLoading) + XCTAssertFalse( + LoadingState.initial.isLoading) + XCTAssertFalse( + LoadingState.success(content: 0).isLoading) + XCTAssertFalse( + LoadingState.failure(error: error, cache: 6).isLoading) + } + + func testError() throws { + XCTAssertNil( + LoadingState.loading(cache: nil).error) + XCTAssertNil( + LoadingState.initial.error) + XCTAssertNil( + LoadingState.success(content: 0).error) + + let error = try XCTUnwrap(LoadingState.failure(error: self.error, cache: 6).error as? URLError) + XCTAssertEqual(error, self.error) + } + + func testMerge() { + XCTAssertEqual( + LoadingState.loading(cache: 2).merge(.success(content: 3), transform: +), + LoadingState.loading(cache: 5)) + XCTAssertEqual( + LoadingState.success(content: 2).merge(.loading(cache: 3), transform: +), + LoadingState.loading(cache: 5)) + XCTAssertEqual( + LoadingState.loading(cache: nil).merge(.success(content: 3), transform: +), + LoadingState.loading(cache: nil)) + XCTAssertEqual( + LoadingState.success(content: 2).merge(.loading(cache: nil), transform: +), + LoadingState.loading(cache: nil)) + + XCTAssertEqual( + LoadingState.failure(error: error, cache: 7).merge(.failure(error: error, cache: 7), transform: +), + LoadingState.failure(error: error, cache: 14)) + XCTAssertEqual( + LoadingState.failure(error: error, cache: 7).merge(.success(content: 8), transform: +), + LoadingState.failure(error: error, cache: 15)) + XCTAssertEqual( + LoadingState.success(content: 9).merge(.failure(error: error, cache: 7), transform: +), + LoadingState.failure(error: error, cache: 16)) + XCTAssertEqual( + LoadingState.success(content: 9).merge(.failure(error: error, cache: nil), transform: +), + LoadingState.failure(error: error, cache: nil)) + + XCTAssertEqual( + LoadingState.success(content: 5).merge(.success(content: 5), transform: +), + LoadingState.success(content: 10)) + XCTAssertEqual( + LoadingState.initial.merge(.initial, transform: +), + LoadingState.initial) + XCTAssertEqual( + LoadingState.initial.merge(.success(content: 1), transform: +), + LoadingState.initial) + XCTAssertEqual( + LoadingState.success(content: 2).merge(.initial, transform: +), + LoadingState.initial) + } + + func testInitialEquatable() { + XCTAssertEqual( + LoadingState.initial, + LoadingState.initial) + XCTAssertNotEqual( + LoadingState.initial, + LoadingState.loading(cache: nil)) + XCTAssertNotEqual( + LoadingState.initial, + LoadingState.loading(cache: 76)) + XCTAssertNotEqual( + LoadingState.initial, + LoadingState.success(content: 23)) + XCTAssertNotEqual( + LoadingState.initial, + LoadingState.failure(error: error, cache: nil)) + XCTAssertNotEqual( + LoadingState.initial, + LoadingState.failure(error: error, cache: 100)) + } + + func testLoadingEquatable() { + XCTAssertEqual( + LoadingState.loading(cache: 1), + LoadingState.loading(cache: 1)) + XCTAssertNotEqual( + LoadingState.loading(cache: 2), + LoadingState.loading(cache: 3)) + XCTAssertNotEqual( + LoadingState.loading(cache: 4), + LoadingState.initial) + XCTAssertNotEqual( + LoadingState.loading(cache: 6), + LoadingState.success(content: 6)) + XCTAssertNotEqual( + LoadingState.loading(cache: 6), + LoadingState.success(content: 7)) + XCTAssertNotEqual( + LoadingState.loading(cache: 8), + LoadingState.failure(error: error, cache: nil)) + } + + func testSuccessEquatable() { + XCTAssertEqual( + LoadingState.success(content: 43), + LoadingState.success(content: 43)) + XCTAssertNotEqual( + LoadingState.success(content: 43), + LoadingState.success(content: 47)) + XCTAssertNotEqual( + LoadingState.success(content: 43), + LoadingState.initial) + XCTAssertNotEqual( + LoadingState.success(content: 43), + LoadingState.loading(cache: nil)) + XCTAssertNotEqual( + LoadingState.success(content: 43), + LoadingState.failure(error: error, cache: nil)) + } + + func testFailureEquatable() { + XCTAssertEqual( + LoadingState.failure(error: error, cache: 3), + LoadingState.failure(error: error, cache: 3)) + XCTAssertNotEqual( + LoadingState.failure(error: error, cache: nil), + LoadingState.failure(error: error, cache: 3)) + XCTAssertNotEqual( + LoadingState.failure(error: error, cache: nil), + LoadingState.initial) + XCTAssertNotEqual( + LoadingState.failure(error: error, cache: nil), + LoadingState.loading(cache: nil)) + XCTAssertNotEqual( + LoadingState.failure(error: error, cache: nil), + LoadingState.success(content: 4)) + } + +} diff --git a/Tests/ApexyURLSessionTests/URLSessionClientTests.swift b/Tests/ApexyURLSessionTests/URLSessionClientTests.swift index cae8eed..7bbcd12 100644 --- a/Tests/ApexyURLSessionTests/URLSessionClientTests.swift +++ b/Tests/ApexyURLSessionTests/URLSessionClientTests.swift @@ -116,6 +116,7 @@ final class URLSessionClientTests: XCTestCase { XCTAssertEqual(content, data) exp.fulfill() }.store(in: &bag) + wait(for: [exp], timeout: 0.1) } }