├── .gitignore ├── .travis.yml ├── Example ├── Gemfile ├── Gemfile.lock ├── Podfile ├── Podfile.lock ├── Succulent.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ └── contents.xcworkspacedata │ └── xcshareddata │ │ └── xcschemes │ │ └── Succulent-Example.xcscheme ├── Succulent.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── Succulent │ ├── AppDelegate.swift │ ├── Base.lproj │ │ ├── LaunchScreen.xib │ │ └── Main.storyboard │ ├── Images.xcassets │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Info.plist │ └── ViewController.swift ├── Tests │ ├── Info.plist │ ├── RouterTests.swift │ ├── Succulent │ │ ├── Tests_testEncoding.trace │ │ ├── Tests_testHeaders.trace │ │ ├── Tests_testIgnoreAllParameters.trace │ │ ├── Tests_testIgnorePostVersioning.trace │ │ ├── Tests_testIgnoredParameters.trace │ │ ├── Tests_testIgnoredParametersForTrace.trace │ │ ├── Tests_testNested.trace │ │ ├── Tests_testPOST.trace │ │ ├── Tests_testPOSTDifferentBody.trace │ │ ├── Tests_testPOSTEmptyBody.trace │ │ ├── Tests_testPOSTVersions.trace │ │ ├── Tests_testPassThrough.trace │ │ ├── Tests_testQuery.trace │ │ ├── Tests_testQueryOrdering.trace │ │ ├── Tests_testQueryStringOrder.trace │ │ ├── Tests_testQueryWithMultipleRepeatedParams.trace │ │ ├── Tests_testQueryWithRepeatedParam.trace │ │ ├── Tests_testSimple.trace │ │ ├── Tests_testTilde.trace │ │ ├── Tests_testVersioned.trace │ │ └── TraceTests_testRecordingSimple.trace │ ├── TestSupport.swift │ ├── Tests.swift │ └── TraceTests.swift └── Traces │ ├── TraceTests_testRecordingResult.trace │ └── TraceTests_testRecordingSimple.trace ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Succulent.podspec ├── Succulent ├── Assets │ └── .gitkeep └── Classes │ ├── .gitkeep │ ├── FileHandle+readLine.swift │ ├── Router.swift │ ├── Succulent.swift │ └── Tracer.swift └── _Pods.xcodeproj /.gitignore: -------------------------------------------------------------------------------- 1 | # OS X 2 | .DS_Store 3 | 4 | # Xcode 5 | build/ 6 | *.pbxuser 7 | !default.pbxuser 8 | *.mode1v3 9 | !default.mode1v3 10 | *.mode2v3 11 | !default.mode2v3 12 | *.perspectivev3 13 | !default.perspectivev3 14 | xcuserdata/ 15 | *.xccheckout 16 | profile 17 | *.moved-aside 18 | DerivedData 19 | *.hmap 20 | *.ipa 21 | 22 | # Bundler 23 | .bundle 24 | 25 | # SwiftPM 26 | Packages/ 27 | .build 28 | .swiftpm 29 | 30 | Carthage 31 | # We recommend against adding the Pods directory to your .gitignore. However 32 | # you should judge for yourself, the pros and cons are mentioned at: 33 | # http://guides.cocoapods.org/using/using-cocoapods.html#should-i-ignore-the-pods-directory-in-source-control 34 | # 35 | # Note: if you ignore the Pods directory, make sure to uncomment 36 | # `pod install` in .travis.yml 37 | # 38 | # Pods/ 39 | /Example/Pods 40 | /Example/TraceRecordings 41 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # references: 2 | # * http://www.objc.io/issue-6/travis-ci.html 3 | # * https://github.com/supermarin/xcpretty#usage 4 | 5 | osx_image: xcode10 6 | language: objective-c 7 | gemfile: Example/Gemfile 8 | podfile: Example/Podfile 9 | script: 10 | - set -o pipefail && xcodebuild -workspace Example/Succulent.xcworkspace -scheme Succulent-Example -destination 'platform=iOS Simulator,name=iPhone 6S,OS=12.0' build test | xcpretty 11 | - pod lib lint 12 | -------------------------------------------------------------------------------- /Example/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | source "https://rubygems.org" 3 | 4 | gem "cocoapods" 5 | -------------------------------------------------------------------------------- /Example/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.0) 5 | activesupport (4.2.11.1) 6 | i18n (~> 0.7) 7 | minitest (~> 5.1) 8 | thread_safe (~> 0.3, >= 0.3.4) 9 | tzinfo (~> 1.1) 10 | atomos (0.1.3) 11 | claide (1.0.2) 12 | cocoapods (1.6.1) 13 | activesupport (>= 4.0.2, < 5) 14 | claide (>= 1.0.2, < 2.0) 15 | cocoapods-core (= 1.6.1) 16 | cocoapods-deintegrate (>= 1.0.2, < 2.0) 17 | cocoapods-downloader (>= 1.2.2, < 2.0) 18 | cocoapods-plugins (>= 1.0.0, < 2.0) 19 | cocoapods-search (>= 1.0.0, < 2.0) 20 | cocoapods-stats (>= 1.0.0, < 2.0) 21 | cocoapods-trunk (>= 1.3.1, < 2.0) 22 | cocoapods-try (>= 1.1.0, < 2.0) 23 | colored2 (~> 3.1) 24 | escape (~> 0.0.4) 25 | fourflusher (>= 2.2.0, < 3.0) 26 | gh_inspector (~> 1.0) 27 | molinillo (~> 0.6.6) 28 | nap (~> 1.0) 29 | ruby-macho (~> 1.4) 30 | xcodeproj (>= 1.8.1, < 2.0) 31 | cocoapods-core (1.6.1) 32 | activesupport (>= 4.0.2, < 6) 33 | fuzzy_match (~> 2.0.4) 34 | nap (~> 1.0) 35 | cocoapods-deintegrate (1.0.3) 36 | cocoapods-downloader (1.2.2) 37 | cocoapods-plugins (1.0.0) 38 | nap 39 | cocoapods-search (1.0.0) 40 | cocoapods-stats (1.1.0) 41 | cocoapods-trunk (1.3.1) 42 | nap (>= 0.8, < 2.0) 43 | netrc (~> 0.11) 44 | cocoapods-try (1.1.0) 45 | colored2 (3.1.2) 46 | concurrent-ruby (1.1.5) 47 | escape (0.0.4) 48 | fourflusher (2.2.0) 49 | fuzzy_match (2.0.4) 50 | gh_inspector (1.1.3) 51 | i18n (0.9.5) 52 | concurrent-ruby (~> 1.0) 53 | minitest (5.11.3) 54 | molinillo (0.6.6) 55 | nanaimo (0.2.6) 56 | nap (1.1.0) 57 | netrc (0.11.0) 58 | ruby-macho (1.4.0) 59 | thread_safe (0.3.6) 60 | tzinfo (1.2.5) 61 | thread_safe (~> 0.1) 62 | xcodeproj (1.8.1) 63 | CFPropertyList (>= 2.3.3, < 4.0) 64 | atomos (~> 0.1.3) 65 | claide (>= 1.0.2, < 2.0) 66 | colored2 (~> 3.1) 67 | nanaimo (~> 0.2.6) 68 | 69 | PLATFORMS 70 | ruby 71 | 72 | DEPENDENCIES 73 | cocoapods 74 | 75 | BUNDLED WITH 76 | 1.16.4 77 | -------------------------------------------------------------------------------- /Example/Podfile: -------------------------------------------------------------------------------- 1 | platform :ios, '9.0' 2 | use_frameworks! 3 | 4 | target 'Succulent_Example' do 5 | pod 'Succulent', :path => '../' 6 | 7 | 8 | 9 | target 'Succulent_Tests' do 10 | inherit! :search_paths 11 | pod 'Succulent', :path => '../' 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /Example/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - Embassy (4.1.0) 3 | - Succulent (0.5.0): 4 | - Embassy (~> 4.1.0) 5 | 6 | DEPENDENCIES: 7 | - Succulent (from `../`) 8 | 9 | SPEC REPOS: 10 | https://github.com/cocoapods/specs.git: 11 | - Embassy 12 | 13 | EXTERNAL SOURCES: 14 | Succulent: 15 | :path: "../" 16 | 17 | SPEC CHECKSUMS: 18 | Embassy: 296c51c4b16b8377b413f48f413a99aa736adc22 19 | Succulent: 600b787c9b0d67d8d71f33a93cd5a57953e6fbb3 20 | 21 | PODFILE CHECKSUM: 7cd1cfcf6f8a40e41dceed9093f40f64c15e361d 22 | 23 | COCOAPODS: 1.6.1 24 | -------------------------------------------------------------------------------- /Example/Succulent.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 46; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 1EB5F4BC60B3BD34A863CBFE /* Pods_Succulent_Tests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8A7D5F72DAC9F3C63A2D2BB4 /* Pods_Succulent_Tests.framework */; }; 11 | 607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD51AFB9204008FA782 /* AppDelegate.swift */; }; 12 | 607FACD81AFB9204008FA782 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD71AFB9204008FA782 /* ViewController.swift */; }; 13 | 607FACDB1AFB9204008FA782 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 607FACD91AFB9204008FA782 /* Main.storyboard */; }; 14 | 607FACDD1AFB9204008FA782 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDC1AFB9204008FA782 /* Images.xcassets */; }; 15 | 607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 607FACDE1AFB9204008FA782 /* LaunchScreen.xib */; }; 16 | 607FACEC1AFB9204008FA782 /* Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACEB1AFB9204008FA782 /* Tests.swift */; }; 17 | 983A99211E70FCE600C0FEF9 /* TraceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983A99201E70FCE600C0FEF9 /* TraceTests.swift */; }; 18 | B827BD9B1E2C739F003256F2 /* RouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B827BD9A1E2C739F003256F2 /* RouterTests.swift */; }; 19 | B833B1E31EDBBC0800758611 /* TestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B833B1E21EDBBC0800758611 /* TestSupport.swift */; }; 20 | B8F58B081E38633F00F6F087 /* Succulent in Resources */ = {isa = PBXBuildFile; fileRef = B8F58B071E38633F00F6F087 /* Succulent */; }; 21 | B8F58B091E38633F00F6F087 /* Succulent in Resources */ = {isa = PBXBuildFile; fileRef = B8F58B071E38633F00F6F087 /* Succulent */; }; 22 | DEF073E09BE775740BAF0EA8 /* Pods_Succulent_Example.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0BD5D5480986E7F56D19900 /* Pods_Succulent_Example.framework */; }; 23 | /* End PBXBuildFile section */ 24 | 25 | /* Begin PBXContainerItemProxy section */ 26 | 607FACE61AFB9204008FA782 /* PBXContainerItemProxy */ = { 27 | isa = PBXContainerItemProxy; 28 | containerPortal = 607FACC81AFB9204008FA782 /* Project object */; 29 | proxyType = 1; 30 | remoteGlobalIDString = 607FACCF1AFB9204008FA782; 31 | remoteInfo = Succulent; 32 | }; 33 | /* End PBXContainerItemProxy section */ 34 | 35 | /* Begin PBXFileReference section */ 36 | 0B06A720DE848A675341A76E /* Pods-Succulent_Tests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Succulent_Tests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Succulent_Tests/Pods-Succulent_Tests.debug.xcconfig"; sourceTree = ""; }; 37 | 0E67C209DDD4AC196B23E4ED /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = ""; }; 38 | 1E250097C577460A6BD13968 /* Pods-Succulent_Tests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Succulent_Tests.release.xcconfig"; path = "Pods/Target Support Files/Pods-Succulent_Tests/Pods-Succulent_Tests.release.xcconfig"; sourceTree = ""; }; 39 | 607FACD01AFB9204008FA782 /* Succulent_Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Succulent_Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 40 | 607FACD41AFB9204008FA782 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 41 | 607FACD51AFB9204008FA782 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 42 | 607FACD71AFB9204008FA782 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 43 | 607FACDA1AFB9204008FA782 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 44 | 607FACDC1AFB9204008FA782 /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = ""; }; 45 | 607FACDF1AFB9204008FA782 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/LaunchScreen.xib; sourceTree = ""; }; 46 | 607FACE51AFB9204008FA782 /* Succulent_Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Succulent_Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 47 | 607FACEA1AFB9204008FA782 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 48 | 607FACEB1AFB9204008FA782 /* Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests.swift; sourceTree = ""; }; 49 | 88FDAF260577A4CFB4E2B266 /* Pods-Succulent_Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Succulent_Example.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Succulent_Example/Pods-Succulent_Example.debug.xcconfig"; sourceTree = ""; }; 50 | 8A7D5F72DAC9F3C63A2D2BB4 /* Pods_Succulent_Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Succulent_Tests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 51 | 983A99201E70FCE600C0FEF9 /* TraceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TraceTests.swift; sourceTree = ""; }; 52 | A7D321DBFDE0C96D3D3CDE84 /* Succulent.podspec */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = Succulent.podspec; path = ../Succulent.podspec; sourceTree = ""; }; 53 | B827BD9A1E2C739F003256F2 /* RouterTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RouterTests.swift; sourceTree = ""; }; 54 | B833B1E21EDBBC0800758611 /* TestSupport.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestSupport.swift; sourceTree = ""; }; 55 | B8F58B071E38633F00F6F087 /* Succulent */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Succulent; sourceTree = ""; }; 56 | D0BD5D5480986E7F56D19900 /* Pods_Succulent_Example.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Succulent_Example.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 57 | D80E373A86FDD64DA9B6CDDE /* LICENSE */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = LICENSE; path = ../LICENSE; sourceTree = ""; }; 58 | DDAA47C3AE35A85B74CB7C12 /* Pods-Succulent_Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Succulent_Example.release.xcconfig"; path = "Pods/Target Support Files/Pods-Succulent_Example/Pods-Succulent_Example.release.xcconfig"; sourceTree = ""; }; 59 | /* End PBXFileReference section */ 60 | 61 | /* Begin PBXFrameworksBuildPhase section */ 62 | 607FACCD1AFB9204008FA782 /* Frameworks */ = { 63 | isa = PBXFrameworksBuildPhase; 64 | buildActionMask = 2147483647; 65 | files = ( 66 | DEF073E09BE775740BAF0EA8 /* Pods_Succulent_Example.framework in Frameworks */, 67 | ); 68 | runOnlyForDeploymentPostprocessing = 0; 69 | }; 70 | 607FACE21AFB9204008FA782 /* Frameworks */ = { 71 | isa = PBXFrameworksBuildPhase; 72 | buildActionMask = 2147483647; 73 | files = ( 74 | 1EB5F4BC60B3BD34A863CBFE /* Pods_Succulent_Tests.framework in Frameworks */, 75 | ); 76 | runOnlyForDeploymentPostprocessing = 0; 77 | }; 78 | /* End PBXFrameworksBuildPhase section */ 79 | 80 | /* Begin PBXGroup section */ 81 | 28A0FD65A7E1CFE6343DFE32 /* Pods */ = { 82 | isa = PBXGroup; 83 | children = ( 84 | 88FDAF260577A4CFB4E2B266 /* Pods-Succulent_Example.debug.xcconfig */, 85 | DDAA47C3AE35A85B74CB7C12 /* Pods-Succulent_Example.release.xcconfig */, 86 | 0B06A720DE848A675341A76E /* Pods-Succulent_Tests.debug.xcconfig */, 87 | 1E250097C577460A6BD13968 /* Pods-Succulent_Tests.release.xcconfig */, 88 | ); 89 | name = Pods; 90 | sourceTree = ""; 91 | }; 92 | 4809B36CEDADD132ECBCEBC1 /* Frameworks */ = { 93 | isa = PBXGroup; 94 | children = ( 95 | D0BD5D5480986E7F56D19900 /* Pods_Succulent_Example.framework */, 96 | 8A7D5F72DAC9F3C63A2D2BB4 /* Pods_Succulent_Tests.framework */, 97 | ); 98 | name = Frameworks; 99 | sourceTree = ""; 100 | }; 101 | 607FACC71AFB9204008FA782 = { 102 | isa = PBXGroup; 103 | children = ( 104 | 607FACF51AFB993E008FA782 /* Podspec Metadata */, 105 | 607FACD21AFB9204008FA782 /* Example for Succulent */, 106 | 607FACE81AFB9204008FA782 /* Tests */, 107 | 607FACD11AFB9204008FA782 /* Products */, 108 | 28A0FD65A7E1CFE6343DFE32 /* Pods */, 109 | 4809B36CEDADD132ECBCEBC1 /* Frameworks */, 110 | ); 111 | sourceTree = ""; 112 | }; 113 | 607FACD11AFB9204008FA782 /* Products */ = { 114 | isa = PBXGroup; 115 | children = ( 116 | 607FACD01AFB9204008FA782 /* Succulent_Example.app */, 117 | 607FACE51AFB9204008FA782 /* Succulent_Tests.xctest */, 118 | ); 119 | name = Products; 120 | sourceTree = ""; 121 | }; 122 | 607FACD21AFB9204008FA782 /* Example for Succulent */ = { 123 | isa = PBXGroup; 124 | children = ( 125 | 607FACD51AFB9204008FA782 /* AppDelegate.swift */, 126 | 607FACD71AFB9204008FA782 /* ViewController.swift */, 127 | 607FACD91AFB9204008FA782 /* Main.storyboard */, 128 | 607FACDC1AFB9204008FA782 /* Images.xcassets */, 129 | 607FACDE1AFB9204008FA782 /* LaunchScreen.xib */, 130 | 607FACD31AFB9204008FA782 /* Supporting Files */, 131 | ); 132 | name = "Example for Succulent"; 133 | path = Succulent; 134 | sourceTree = ""; 135 | }; 136 | 607FACD31AFB9204008FA782 /* Supporting Files */ = { 137 | isa = PBXGroup; 138 | children = ( 139 | 607FACD41AFB9204008FA782 /* Info.plist */, 140 | ); 141 | name = "Supporting Files"; 142 | sourceTree = ""; 143 | }; 144 | 607FACE81AFB9204008FA782 /* Tests */ = { 145 | isa = PBXGroup; 146 | children = ( 147 | 607FACEB1AFB9204008FA782 /* Tests.swift */, 148 | B827BD9A1E2C739F003256F2 /* RouterTests.swift */, 149 | 983A99201E70FCE600C0FEF9 /* TraceTests.swift */, 150 | B833B1E21EDBBC0800758611 /* TestSupport.swift */, 151 | B8F58B071E38633F00F6F087 /* Succulent */, 152 | 607FACE91AFB9204008FA782 /* Supporting Files */, 153 | ); 154 | path = Tests; 155 | sourceTree = ""; 156 | }; 157 | 607FACE91AFB9204008FA782 /* Supporting Files */ = { 158 | isa = PBXGroup; 159 | children = ( 160 | 607FACEA1AFB9204008FA782 /* Info.plist */, 161 | ); 162 | name = "Supporting Files"; 163 | sourceTree = ""; 164 | }; 165 | 607FACF51AFB993E008FA782 /* Podspec Metadata */ = { 166 | isa = PBXGroup; 167 | children = ( 168 | A7D321DBFDE0C96D3D3CDE84 /* Succulent.podspec */, 169 | 0E67C209DDD4AC196B23E4ED /* README.md */, 170 | D80E373A86FDD64DA9B6CDDE /* LICENSE */, 171 | ); 172 | name = "Podspec Metadata"; 173 | sourceTree = ""; 174 | }; 175 | /* End PBXGroup section */ 176 | 177 | /* Begin PBXNativeTarget section */ 178 | 607FACCF1AFB9204008FA782 /* Succulent_Example */ = { 179 | isa = PBXNativeTarget; 180 | buildConfigurationList = 607FACEF1AFB9204008FA782 /* Build configuration list for PBXNativeTarget "Succulent_Example" */; 181 | buildPhases = ( 182 | F78D3BA12CB106C61DB4D1BE /* [CP] Check Pods Manifest.lock */, 183 | 607FACCC1AFB9204008FA782 /* Sources */, 184 | 607FACCD1AFB9204008FA782 /* Frameworks */, 185 | 607FACCE1AFB9204008FA782 /* Resources */, 186 | 20F9E96BBE912F668932FC6E /* [CP] Embed Pods Frameworks */, 187 | ); 188 | buildRules = ( 189 | ); 190 | dependencies = ( 191 | ); 192 | name = Succulent_Example; 193 | productName = Succulent; 194 | productReference = 607FACD01AFB9204008FA782 /* Succulent_Example.app */; 195 | productType = "com.apple.product-type.application"; 196 | }; 197 | 607FACE41AFB9204008FA782 /* Succulent_Tests */ = { 198 | isa = PBXNativeTarget; 199 | buildConfigurationList = 607FACF21AFB9204008FA782 /* Build configuration list for PBXNativeTarget "Succulent_Tests" */; 200 | buildPhases = ( 201 | 40BC175C593738079E7DA04D /* [CP] Check Pods Manifest.lock */, 202 | 607FACE11AFB9204008FA782 /* Sources */, 203 | 607FACE21AFB9204008FA782 /* Frameworks */, 204 | 607FACE31AFB9204008FA782 /* Resources */, 205 | 67E0017B1BEC836750F951B1 /* [CP] Embed Pods Frameworks */, 206 | ); 207 | buildRules = ( 208 | ); 209 | dependencies = ( 210 | 607FACE71AFB9204008FA782 /* PBXTargetDependency */, 211 | ); 212 | name = Succulent_Tests; 213 | productName = Tests; 214 | productReference = 607FACE51AFB9204008FA782 /* Succulent_Tests.xctest */; 215 | productType = "com.apple.product-type.bundle.unit-test"; 216 | }; 217 | /* End PBXNativeTarget section */ 218 | 219 | /* Begin PBXProject section */ 220 | 607FACC81AFB9204008FA782 /* Project object */ = { 221 | isa = PBXProject; 222 | attributes = { 223 | LastSwiftUpdateCheck = 0720; 224 | LastUpgradeCheck = 1020; 225 | ORGANIZATIONNAME = CocoaPods; 226 | TargetAttributes = { 227 | 607FACCF1AFB9204008FA782 = { 228 | CreatedOnToolsVersion = 6.3.1; 229 | LastSwiftMigration = 1020; 230 | }; 231 | 607FACE41AFB9204008FA782 = { 232 | CreatedOnToolsVersion = 6.3.1; 233 | LastSwiftMigration = 1020; 234 | TestTargetID = 607FACCF1AFB9204008FA782; 235 | }; 236 | }; 237 | }; 238 | buildConfigurationList = 607FACCB1AFB9204008FA782 /* Build configuration list for PBXProject "Succulent" */; 239 | compatibilityVersion = "Xcode 3.2"; 240 | developmentRegion = English; 241 | hasScannedForEncodings = 0; 242 | knownRegions = ( 243 | English, 244 | en, 245 | Base, 246 | ); 247 | mainGroup = 607FACC71AFB9204008FA782; 248 | productRefGroup = 607FACD11AFB9204008FA782 /* Products */; 249 | projectDirPath = ""; 250 | projectRoot = ""; 251 | targets = ( 252 | 607FACCF1AFB9204008FA782 /* Succulent_Example */, 253 | 607FACE41AFB9204008FA782 /* Succulent_Tests */, 254 | ); 255 | }; 256 | /* End PBXProject section */ 257 | 258 | /* Begin PBXResourcesBuildPhase section */ 259 | 607FACCE1AFB9204008FA782 /* Resources */ = { 260 | isa = PBXResourcesBuildPhase; 261 | buildActionMask = 2147483647; 262 | files = ( 263 | 607FACDB1AFB9204008FA782 /* Main.storyboard in Resources */, 264 | 607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */, 265 | 607FACDD1AFB9204008FA782 /* Images.xcassets in Resources */, 266 | B8F58B081E38633F00F6F087 /* Succulent in Resources */, 267 | ); 268 | runOnlyForDeploymentPostprocessing = 0; 269 | }; 270 | 607FACE31AFB9204008FA782 /* Resources */ = { 271 | isa = PBXResourcesBuildPhase; 272 | buildActionMask = 2147483647; 273 | files = ( 274 | B8F58B091E38633F00F6F087 /* Succulent in Resources */, 275 | ); 276 | runOnlyForDeploymentPostprocessing = 0; 277 | }; 278 | /* End PBXResourcesBuildPhase section */ 279 | 280 | /* Begin PBXShellScriptBuildPhase section */ 281 | 20F9E96BBE912F668932FC6E /* [CP] Embed Pods Frameworks */ = { 282 | isa = PBXShellScriptBuildPhase; 283 | buildActionMask = 2147483647; 284 | files = ( 285 | ); 286 | inputPaths = ( 287 | "${PODS_ROOT}/Target Support Files/Pods-Succulent_Example/Pods-Succulent_Example-frameworks.sh", 288 | "${BUILT_PRODUCTS_DIR}/Embassy/Embassy.framework", 289 | "${BUILT_PRODUCTS_DIR}/Succulent/Succulent.framework", 290 | ); 291 | name = "[CP] Embed Pods Frameworks"; 292 | outputPaths = ( 293 | "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Embassy.framework", 294 | "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Succulent.framework", 295 | ); 296 | runOnlyForDeploymentPostprocessing = 0; 297 | shellPath = /bin/sh; 298 | shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Succulent_Example/Pods-Succulent_Example-frameworks.sh\"\n"; 299 | showEnvVarsInLog = 0; 300 | }; 301 | 40BC175C593738079E7DA04D /* [CP] Check Pods Manifest.lock */ = { 302 | isa = PBXShellScriptBuildPhase; 303 | buildActionMask = 2147483647; 304 | files = ( 305 | ); 306 | inputPaths = ( 307 | "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 308 | "${PODS_ROOT}/Manifest.lock", 309 | ); 310 | name = "[CP] Check Pods Manifest.lock"; 311 | outputPaths = ( 312 | "$(DERIVED_FILE_DIR)/Pods-Succulent_Tests-checkManifestLockResult.txt", 313 | ); 314 | runOnlyForDeploymentPostprocessing = 0; 315 | shellPath = /bin/sh; 316 | shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 317 | showEnvVarsInLog = 0; 318 | }; 319 | 67E0017B1BEC836750F951B1 /* [CP] Embed Pods Frameworks */ = { 320 | isa = PBXShellScriptBuildPhase; 321 | buildActionMask = 2147483647; 322 | files = ( 323 | ); 324 | inputPaths = ( 325 | "${PODS_ROOT}/Target Support Files/Pods-Succulent_Tests/Pods-Succulent_Tests-frameworks.sh", 326 | "${BUILT_PRODUCTS_DIR}/Embassy/Embassy.framework", 327 | "${BUILT_PRODUCTS_DIR}/Succulent/Succulent.framework", 328 | ); 329 | name = "[CP] Embed Pods Frameworks"; 330 | outputPaths = ( 331 | "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Embassy.framework", 332 | "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Succulent.framework", 333 | ); 334 | runOnlyForDeploymentPostprocessing = 0; 335 | shellPath = /bin/sh; 336 | shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Succulent_Tests/Pods-Succulent_Tests-frameworks.sh\"\n"; 337 | showEnvVarsInLog = 0; 338 | }; 339 | F78D3BA12CB106C61DB4D1BE /* [CP] Check Pods Manifest.lock */ = { 340 | isa = PBXShellScriptBuildPhase; 341 | buildActionMask = 2147483647; 342 | files = ( 343 | ); 344 | inputPaths = ( 345 | "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 346 | "${PODS_ROOT}/Manifest.lock", 347 | ); 348 | name = "[CP] Check Pods Manifest.lock"; 349 | outputPaths = ( 350 | "$(DERIVED_FILE_DIR)/Pods-Succulent_Example-checkManifestLockResult.txt", 351 | ); 352 | runOnlyForDeploymentPostprocessing = 0; 353 | shellPath = /bin/sh; 354 | shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 355 | showEnvVarsInLog = 0; 356 | }; 357 | /* End PBXShellScriptBuildPhase section */ 358 | 359 | /* Begin PBXSourcesBuildPhase section */ 360 | 607FACCC1AFB9204008FA782 /* Sources */ = { 361 | isa = PBXSourcesBuildPhase; 362 | buildActionMask = 2147483647; 363 | files = ( 364 | 607FACD81AFB9204008FA782 /* ViewController.swift in Sources */, 365 | 607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */, 366 | ); 367 | runOnlyForDeploymentPostprocessing = 0; 368 | }; 369 | 607FACE11AFB9204008FA782 /* Sources */ = { 370 | isa = PBXSourcesBuildPhase; 371 | buildActionMask = 2147483647; 372 | files = ( 373 | B827BD9B1E2C739F003256F2 /* RouterTests.swift in Sources */, 374 | B833B1E31EDBBC0800758611 /* TestSupport.swift in Sources */, 375 | 607FACEC1AFB9204008FA782 /* Tests.swift in Sources */, 376 | 983A99211E70FCE600C0FEF9 /* TraceTests.swift in Sources */, 377 | ); 378 | runOnlyForDeploymentPostprocessing = 0; 379 | }; 380 | /* End PBXSourcesBuildPhase section */ 381 | 382 | /* Begin PBXTargetDependency section */ 383 | 607FACE71AFB9204008FA782 /* PBXTargetDependency */ = { 384 | isa = PBXTargetDependency; 385 | target = 607FACCF1AFB9204008FA782 /* Succulent_Example */; 386 | targetProxy = 607FACE61AFB9204008FA782 /* PBXContainerItemProxy */; 387 | }; 388 | /* End PBXTargetDependency section */ 389 | 390 | /* Begin PBXVariantGroup section */ 391 | 607FACD91AFB9204008FA782 /* Main.storyboard */ = { 392 | isa = PBXVariantGroup; 393 | children = ( 394 | 607FACDA1AFB9204008FA782 /* Base */, 395 | ); 396 | name = Main.storyboard; 397 | sourceTree = ""; 398 | }; 399 | 607FACDE1AFB9204008FA782 /* LaunchScreen.xib */ = { 400 | isa = PBXVariantGroup; 401 | children = ( 402 | 607FACDF1AFB9204008FA782 /* Base */, 403 | ); 404 | name = LaunchScreen.xib; 405 | sourceTree = ""; 406 | }; 407 | /* End PBXVariantGroup section */ 408 | 409 | /* Begin XCBuildConfiguration section */ 410 | 607FACED1AFB9204008FA782 /* Debug */ = { 411 | isa = XCBuildConfiguration; 412 | buildSettings = { 413 | ALWAYS_SEARCH_USER_PATHS = NO; 414 | CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; 415 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 416 | CLANG_CXX_LIBRARY = "libc++"; 417 | CLANG_ENABLE_MODULES = YES; 418 | CLANG_ENABLE_OBJC_ARC = YES; 419 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 420 | CLANG_WARN_BOOL_CONVERSION = YES; 421 | CLANG_WARN_COMMA = YES; 422 | CLANG_WARN_CONSTANT_CONVERSION = YES; 423 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 424 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 425 | CLANG_WARN_EMPTY_BODY = YES; 426 | CLANG_WARN_ENUM_CONVERSION = YES; 427 | CLANG_WARN_INFINITE_RECURSION = YES; 428 | CLANG_WARN_INT_CONVERSION = YES; 429 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 430 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 431 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 432 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 433 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 434 | CLANG_WARN_STRICT_PROTOTYPES = YES; 435 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 436 | CLANG_WARN_UNREACHABLE_CODE = YES; 437 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 438 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 439 | COPY_PHASE_STRIP = NO; 440 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 441 | ENABLE_STRICT_OBJC_MSGSEND = YES; 442 | ENABLE_TESTABILITY = YES; 443 | GCC_C_LANGUAGE_STANDARD = gnu99; 444 | GCC_DYNAMIC_NO_PIC = NO; 445 | GCC_NO_COMMON_BLOCKS = YES; 446 | GCC_OPTIMIZATION_LEVEL = 0; 447 | GCC_PREPROCESSOR_DEFINITIONS = ( 448 | "DEBUG=1", 449 | "$(inherited)", 450 | ); 451 | GCC_SYMBOLS_PRIVATE_EXTERN = NO; 452 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 453 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 454 | GCC_WARN_UNDECLARED_SELECTOR = YES; 455 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 456 | GCC_WARN_UNUSED_FUNCTION = YES; 457 | GCC_WARN_UNUSED_VARIABLE = YES; 458 | IPHONEOS_DEPLOYMENT_TARGET = 8.3; 459 | MTL_ENABLE_DEBUG_INFO = YES; 460 | ONLY_ACTIVE_ARCH = YES; 461 | SDKROOT = iphoneos; 462 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 463 | }; 464 | name = Debug; 465 | }; 466 | 607FACEE1AFB9204008FA782 /* Release */ = { 467 | isa = XCBuildConfiguration; 468 | buildSettings = { 469 | ALWAYS_SEARCH_USER_PATHS = NO; 470 | CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; 471 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 472 | CLANG_CXX_LIBRARY = "libc++"; 473 | CLANG_ENABLE_MODULES = YES; 474 | CLANG_ENABLE_OBJC_ARC = YES; 475 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 476 | CLANG_WARN_BOOL_CONVERSION = YES; 477 | CLANG_WARN_COMMA = YES; 478 | CLANG_WARN_CONSTANT_CONVERSION = YES; 479 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 480 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 481 | CLANG_WARN_EMPTY_BODY = YES; 482 | CLANG_WARN_ENUM_CONVERSION = YES; 483 | CLANG_WARN_INFINITE_RECURSION = YES; 484 | CLANG_WARN_INT_CONVERSION = YES; 485 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 486 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 487 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 488 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 489 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 490 | CLANG_WARN_STRICT_PROTOTYPES = YES; 491 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 492 | CLANG_WARN_UNREACHABLE_CODE = YES; 493 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 494 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 495 | COPY_PHASE_STRIP = NO; 496 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 497 | ENABLE_NS_ASSERTIONS = NO; 498 | ENABLE_STRICT_OBJC_MSGSEND = YES; 499 | GCC_C_LANGUAGE_STANDARD = gnu99; 500 | GCC_NO_COMMON_BLOCKS = YES; 501 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 502 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 503 | GCC_WARN_UNDECLARED_SELECTOR = YES; 504 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 505 | GCC_WARN_UNUSED_FUNCTION = YES; 506 | GCC_WARN_UNUSED_VARIABLE = YES; 507 | IPHONEOS_DEPLOYMENT_TARGET = 8.3; 508 | MTL_ENABLE_DEBUG_INFO = NO; 509 | SDKROOT = iphoneos; 510 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 511 | VALIDATE_PRODUCT = YES; 512 | }; 513 | name = Release; 514 | }; 515 | 607FACF01AFB9204008FA782 /* Debug */ = { 516 | isa = XCBuildConfiguration; 517 | baseConfigurationReference = 88FDAF260577A4CFB4E2B266 /* Pods-Succulent_Example.debug.xcconfig */; 518 | buildSettings = { 519 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 520 | CLANG_ENABLE_MODULES = YES; 521 | INFOPLIST_FILE = Succulent/Info.plist; 522 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 523 | MODULE_NAME = ExampleApp; 524 | PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo.$(PRODUCT_NAME:rfc1034identifier)"; 525 | PRODUCT_NAME = "$(TARGET_NAME)"; 526 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 527 | SWIFT_VERSION = 5.0; 528 | }; 529 | name = Debug; 530 | }; 531 | 607FACF11AFB9204008FA782 /* Release */ = { 532 | isa = XCBuildConfiguration; 533 | baseConfigurationReference = DDAA47C3AE35A85B74CB7C12 /* Pods-Succulent_Example.release.xcconfig */; 534 | buildSettings = { 535 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 536 | CLANG_ENABLE_MODULES = YES; 537 | INFOPLIST_FILE = Succulent/Info.plist; 538 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 539 | MODULE_NAME = ExampleApp; 540 | PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo.$(PRODUCT_NAME:rfc1034identifier)"; 541 | PRODUCT_NAME = "$(TARGET_NAME)"; 542 | SWIFT_VERSION = 5.0; 543 | }; 544 | name = Release; 545 | }; 546 | 607FACF31AFB9204008FA782 /* Debug */ = { 547 | isa = XCBuildConfiguration; 548 | baseConfigurationReference = 0B06A720DE848A675341A76E /* Pods-Succulent_Tests.debug.xcconfig */; 549 | buildSettings = { 550 | FRAMEWORK_SEARCH_PATHS = ( 551 | "$(SDKROOT)/Developer/Library/Frameworks", 552 | "$(inherited)", 553 | ); 554 | INFOPLIST_FILE = Tests/Info.plist; 555 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 556 | PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.$(PRODUCT_NAME:rfc1034identifier)"; 557 | PRODUCT_NAME = "$(TARGET_NAME)"; 558 | SWIFT_VERSION = 5.0; 559 | }; 560 | name = Debug; 561 | }; 562 | 607FACF41AFB9204008FA782 /* Release */ = { 563 | isa = XCBuildConfiguration; 564 | baseConfigurationReference = 1E250097C577460A6BD13968 /* Pods-Succulent_Tests.release.xcconfig */; 565 | buildSettings = { 566 | FRAMEWORK_SEARCH_PATHS = ( 567 | "$(SDKROOT)/Developer/Library/Frameworks", 568 | "$(inherited)", 569 | ); 570 | INFOPLIST_FILE = Tests/Info.plist; 571 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; 572 | PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.$(PRODUCT_NAME:rfc1034identifier)"; 573 | PRODUCT_NAME = "$(TARGET_NAME)"; 574 | SWIFT_VERSION = 5.0; 575 | }; 576 | name = Release; 577 | }; 578 | /* End XCBuildConfiguration section */ 579 | 580 | /* Begin XCConfigurationList section */ 581 | 607FACCB1AFB9204008FA782 /* Build configuration list for PBXProject "Succulent" */ = { 582 | isa = XCConfigurationList; 583 | buildConfigurations = ( 584 | 607FACED1AFB9204008FA782 /* Debug */, 585 | 607FACEE1AFB9204008FA782 /* Release */, 586 | ); 587 | defaultConfigurationIsVisible = 0; 588 | defaultConfigurationName = Release; 589 | }; 590 | 607FACEF1AFB9204008FA782 /* Build configuration list for PBXNativeTarget "Succulent_Example" */ = { 591 | isa = XCConfigurationList; 592 | buildConfigurations = ( 593 | 607FACF01AFB9204008FA782 /* Debug */, 594 | 607FACF11AFB9204008FA782 /* Release */, 595 | ); 596 | defaultConfigurationIsVisible = 0; 597 | defaultConfigurationName = Release; 598 | }; 599 | 607FACF21AFB9204008FA782 /* Build configuration list for PBXNativeTarget "Succulent_Tests" */ = { 600 | isa = XCConfigurationList; 601 | buildConfigurations = ( 602 | 607FACF31AFB9204008FA782 /* Debug */, 603 | 607FACF41AFB9204008FA782 /* Release */, 604 | ); 605 | defaultConfigurationIsVisible = 0; 606 | defaultConfigurationName = Release; 607 | }; 608 | /* End XCConfigurationList section */ 609 | }; 610 | rootObject = 607FACC81AFB9204008FA782 /* Project object */; 611 | } 612 | -------------------------------------------------------------------------------- /Example/Succulent.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Succulent.xcodeproj/xcshareddata/xcschemes/Succulent-Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 47 | 53 | 54 | 55 | 56 | 57 | 63 | 64 | 65 | 66 | 67 | 68 | 78 | 80 | 86 | 87 | 88 | 89 | 90 | 91 | 97 | 99 | 105 | 106 | 107 | 108 | 110 | 111 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /Example/Succulent.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Example/Succulent.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Succulent/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Succulent 4 | // 5 | // Created by Karl von Randow on 01/16/2017. 6 | // Copyright (c) 2017 Karl von Randow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | var window: UIWindow? 15 | 16 | 17 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 18 | // Override point for customization after application launch. 19 | return true 20 | } 21 | 22 | func applicationWillResignActive(_ application: UIApplication) { 23 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 24 | // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. 25 | } 26 | 27 | func applicationDidEnterBackground(_ application: UIApplication) { 28 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 29 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. 30 | } 31 | 32 | func applicationWillEnterForeground(_ application: UIApplication) { 33 | // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background. 34 | } 35 | 36 | func applicationDidBecomeActive(_ application: UIApplication) { 37 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 38 | } 39 | 40 | func applicationWillTerminate(_ application: UIApplication) { 41 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 42 | } 43 | 44 | 45 | } 46 | 47 | -------------------------------------------------------------------------------- /Example/Succulent/Base.lproj/LaunchScreen.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 20 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /Example/Succulent/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Example/Succulent/Images.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "29x29", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "29x29", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "40x40", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "40x40", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "60x60", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "60x60", 31 | "scale" : "3x" 32 | } 33 | ], 34 | "info" : { 35 | "version" : 1, 36 | "author" : "xcode" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Example/Succulent/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | LSRequiresIPhoneOS 24 | 25 | UILaunchStoryboardName 26 | LaunchScreen 27 | UIMainStoryboardFile 28 | Main 29 | UIRequiredDeviceCapabilities 30 | 31 | armv7 32 | 33 | UISupportedInterfaceOrientations 34 | 35 | UIInterfaceOrientationPortrait 36 | UIInterfaceOrientationLandscapeLeft 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /Example/Succulent/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // Succulent 4 | // 5 | // Created by Karl von Randow on 01/16/2017. 6 | // Copyright (c) 2017 Karl von Randow. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class ViewController: UIViewController { 12 | 13 | override func viewDidLoad() { 14 | super.viewDidLoad() 15 | // Do any additional setup after loading the view, typically from a nib. 16 | } 17 | 18 | override func didReceiveMemoryWarning() { 19 | super.didReceiveMemoryWarning() 20 | // Dispose of any resources that can be recreated. 21 | } 22 | 23 | } 24 | 25 | -------------------------------------------------------------------------------- /Example/Tests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | TraceRecordPath 24 | $(PROJECT_DIR)/Traces/ 25 | 26 | 27 | -------------------------------------------------------------------------------- /Example/Tests/RouterTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UITestMockingTests.swift 3 | // UITestMockingTests 4 | // 5 | // Created by Karl von Randow on 15/01/17. 6 | // Copyright © 2017 XK72. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Succulent 11 | 12 | /// Test the Router functionality 13 | class RouterTests: XCTestCase { 14 | 15 | var mock: Router! 16 | 17 | override func setUp() { 18 | super.setUp() 19 | 20 | mock = Router() 21 | } 22 | 23 | override func tearDown() { 24 | // Put teardown code here. This method is called after the invocation of each test method in the class. 25 | super.tearDown() 26 | } 27 | 28 | private func handle(request: Request) -> Response { 29 | let result = mock.handleSync(request: request) 30 | switch result { 31 | case .response(let res): 32 | return res 33 | case .error: 34 | return Response(status: .internalServerError) 35 | case .noRoute: 36 | return Response(status: .notFound) 37 | } 38 | } 39 | 40 | func testAnchoredMatching() { 41 | mock.add("/login").status(.ok) 42 | 43 | XCTAssert(handle(request: Request(path: "/login")).status == .ok) 44 | XCTAssert(handle(request: Request(path: "x/login")).status == .notFound) 45 | XCTAssert(handle(request: Request(path: "/loginx")).status == .notFound) 46 | } 47 | 48 | func testParamMatching() { 49 | mock.add("/login").status(.ok) 50 | mock.add("/login").param("username", "karl").status(.ok) 51 | 52 | XCTAssert(handle(request: Request(path: "/login", queryString: "username=karl")).status == .ok) 53 | XCTAssert(handle(request: Request(path: "/login", queryString: "username=karlx")).status == .notFound) 54 | XCTAssert(handle(request: Request(path: "/login", queryString: "username=karl&another=other")).status == .notFound) 55 | } 56 | 57 | func testParamMatchingWithAny() { 58 | mock.add("/login").status(.ok) 59 | mock.add("/login").param("username", "karl").anyParams().status(.ok) 60 | 61 | XCTAssert(handle(request: Request(path: "/login", queryString: "username=karl")).status == .ok) 62 | XCTAssert(handle(request: Request(path: "/login", queryString: "username=karl&another=other")).status == .ok) 63 | } 64 | 65 | func testMultiParamMatching() { 66 | mock.add("/login").status(.ok) 67 | mock.add("/login").param("username", "karl").param("password", "toast").status(.ok) 68 | 69 | XCTAssert(handle(request: Request(path: "/login", queryString: "username=karl")).status == .notFound) 70 | XCTAssert(handle(request: Request(path: "/login", queryString: "username=karl&password=toast")).status == .ok) 71 | XCTAssert(handle(request: Request(path: "/login", queryString: "username=karl&password=toastx")).status == .notFound) 72 | XCTAssert(handle(request: Request(path: "/login", queryString: "username=karl&password=toast&another=other")).status == .notFound) 73 | } 74 | 75 | func testMultiParamMatchingWithAny() { 76 | mock.add("/login").status(.ok) 77 | mock.add("/login").param("username", "karl").param("password", "toast").anyParams().status(.ok) 78 | 79 | XCTAssert(handle(request: Request(path: "/login", queryString: "username=karl")).status == .notFound) 80 | XCTAssert(handle(request: Request(path: "/login", queryString: "username=karl&password=toast")).status == .ok) 81 | XCTAssert(handle(request: Request(path: "/login", queryString: "username=karl&password=toastx")).status == .notFound) 82 | XCTAssert(handle(request: Request(path: "/login", queryString: "username=karl&password=toast&another=other")).status == .ok) 83 | } 84 | 85 | func testBlock() { 86 | mock.add("/echo").anyParams().block { (req, resultBlock) in 87 | resultBlock(.response(Response(status: .ok, data: req.body, contentType: req.contentType))) 88 | } 89 | 90 | let res = handle(request: Request(path: "/echo", queryString: "username=karl")) 91 | XCTAssertEqual(res.status, .ok) 92 | XCTAssertEqual(res.data, nil) 93 | 94 | var req = Request(path: "/echo") 95 | req.body = "Success".data(using: .utf8) 96 | 97 | let res2 = handle(request: req) 98 | XCTAssertEqual(res2.status, .ok) 99 | XCTAssertEqual(res2.data, req.body) 100 | } 101 | 102 | func testExample() { 103 | mock.add("/login").status(.ok) 104 | mock.add("/login").param("username", "karl").status(.ok) 105 | mock.add("/login").param("username", "donald").content("OK", .TextPlain).then { 106 | print("Did then") 107 | } 108 | mock.add("/register.*").status(.ok) 109 | mock.add("/invalid)").status(.ok) 110 | 111 | mock.add("/echo").anyParams().block { (req, resultBlock) in 112 | resultBlock(.response(Response(status: .ok, data: req.body, contentType: req.contentType))) 113 | } 114 | 115 | XCTAssert(handle(request: Request(path: "/login")).status == .ok) 116 | XCTAssert(handle(request: Request(path: "x/login")).status == .notFound) 117 | XCTAssert(handle(request: Request(path: "/loginx")).status == .notFound) 118 | 119 | XCTAssert(handle(request: Request(path: "/login", queryString: "username=karl")).status == .ok) 120 | XCTAssert(handle(request: Request(path: "/login", queryString: "username=karlx")).status == .notFound) 121 | XCTAssert(handle(request: Request(path: "/login", queryString: "username=karl&another=other")).status == .notFound) 122 | 123 | XCTAssert(handle(request: Request(path: "/register")).status == .ok) 124 | XCTAssert(handle(request: Request(path: "/register123")).status == .ok) 125 | 126 | XCTAssert(handle(request: Request(path: "/invalid)")).status == .notFound) 127 | } 128 | 129 | } 130 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testEncoding.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | 5 | Method: GET 6 | Protocol-Version: HTTP/1.1 7 | Protocol: http 8 | Host: www.cactuslab.com 9 | File: /encodingTest%28%20%2742%27%20%29 10 | Response-Body:<<--EOF-1- 11 | Hello encoding! 12 | 13 | --EOF-1- 14 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testHeaders.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | 5 | Method: GET 6 | Protocol-Version: HTTP/1.1 7 | Protocol: http 8 | Host: www.cactuslab.com 9 | File: /headers/index.html 10 | Response-Header:<<--EOF-2- 11 | HTTP/1.1 404 12 | Content-Type: text/html;charset=utf-8 13 | Content-Language: en-US 14 | Date: Mon, 16 Jan 2017 09:33:20 GMT 15 | Set-Cookie: com.xk72.webparts.csrf=Iwp99pmZbnKw3YoZ; Path=/, JSESSIONID=618BAD5E5B629651CDD47328F69F7002; Path=/; HttpOnly 16 | Server: Apache/2.2.22 (Ubuntu) 17 | Vary: Accept-Encoding 18 | X-UA-Compatible: IE=Edge 19 | 20 | --EOF-2- 21 | Response-Body:<<--EOF-2- 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | Cactuslab, a web and app design and development studio with superpowers in Auckland, New Zealand 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 |
79 | 80 | 81 | 86 | 87 | 88 |
89 | 90 | 91 | 92 | 130 | 131 | 132 | 133 |
134 | 135 |
136 | 137 | 138 | 139 |
140 |
141 | 142 |
143 |
144 |

404—Page Not Found

145 | 146 |

This page appears to have vanished! Spooky times. 👻

147 | 148 | Show me some work 149 |
150 |
151 | 152 |
153 | 154 |
155 |
156 | Cactuslab Inflatable Hero 158 |
159 |
160 |
161 |
162 |
163 |
164 | 165 |
166 | 167 |
168 | 169 |
170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 191 | 192 | 193 | 194 | 195 | 196 | --EOF-2- 197 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testIgnoreAllParameters.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | 5 | Method: GET 6 | Protocol-Version: HTTP/1.1 7 | Protocol: http 8 | Host: www.cactuslab.com 9 | File: /query.txt? 10 | Response-Header:<<--EOF-2- 11 | HTTP/1.1 200 OK 12 | Content-Type: text/plain 13 | 14 | --EOF-2- 15 | Response-Body:<<--EOF-2- 16 | Success for query 17 | --EOF-2- 18 | 19 | Method: GET 20 | Protocol-Version: HTTP/1.1 21 | Protocol: http 22 | Host: www.cactuslab.com 23 | File: /query2.txt 24 | Response-Header:<<--EOF-2- 25 | HTTP/1.1 200 OK 26 | Content-Type: text/plain 27 | 28 | --EOF-2- 29 | Response-Body:<<--EOF-2- 30 | Success for query 31 | --EOF-2- 32 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testIgnorePostVersioning.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | Method: GET 5 | Protocol-Version: HTTP/1.1 6 | Protocol: http 7 | Host: www.cactuslab.com 8 | File: /get1.txt 9 | Response-Body:<<--EOF-1- 10 | get1 11 | --EOF-1- 12 | 13 | Method: POST 14 | Protocol-Version: HTTP/1.1 15 | Protocol: http 16 | Host: www.cactuslab.com 17 | File: /ignore_post.txt 18 | Response-Body:<<--EOF-1- 19 | posted1 20 | --EOF-1- 21 | 22 | Method: GET 23 | Protocol-Version: HTTP/1.1 24 | Protocol: http 25 | Host: www.cactuslab.com 26 | File: /get2.txt 27 | Response-Body:<<--EOF-1- 28 | get2 29 | --EOF-1- 30 | 31 | Method: POST 32 | Protocol-Version: HTTP/1.1 33 | Protocol: http 34 | Host: www.cactuslab.com 35 | File: /post2.txt 36 | Response-Body:<<--EOF-1- 37 | posted2 38 | --EOF-1- 39 | 40 | Method: GET 41 | Protocol-Version: HTTP/1.1 42 | Protocol: http 43 | Host: www.cactuslab.com 44 | File: /get1.txt 45 | Response-Body:<<--EOF-1- 46 | get1+1 47 | --EOF-1- 48 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testIgnoredParameters.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | 5 | Method: GET 6 | Protocol-Version: HTTP/1.1 7 | Protocol: http 8 | Host: www.cactuslab.com 9 | File: /query.txt?username=test 10 | Response-Header:<<--EOF-2- 11 | HTTP/1.1 200 OK 12 | Content-Type: text/plain 13 | 14 | --EOF-2- 15 | Response-Body:<<--EOF-2- 16 | Success for query 17 | --EOF-2- 18 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testIgnoredParametersForTrace.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | 5 | Method: GET 6 | Protocol-Version: HTTP/1.1 7 | Protocol: http 8 | Host: www.cactuslab.com 9 | File: /query.txt?username=test&toBe=ignored 10 | Response-Header:<<--EOF-2- 11 | HTTP/1.1 200 OK 12 | Content-Type: text/plain 13 | 14 | --EOF-2- 15 | Response-Body:<<--EOF-2- 16 | Success for query 17 | --EOF-2- 18 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testNested.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | 5 | Method: GET 6 | Protocol-Version: HTTP/1.1 7 | Protocol: http 8 | Host: www.cactuslab.com 9 | File: /folder/testing.txt 10 | Response-Body:<<--EOF-1- 11 | Great 12 | --EOF-1- 13 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testPOST.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | Method: POST 5 | Protocol-Version: HTTP/1.1 6 | Protocol: http 7 | Host: www.cactuslab.com 8 | File: /testing.txt 9 | Response-Body:<<--EOF-1- 10 | posted 11 | --EOF-1- 12 | 13 | Method: GET 14 | Protocol-Version: HTTP/1.1 15 | Protocol: http 16 | Host: www.cactuslab.com 17 | File: /testing.txt 18 | Response-Body:<<--EOF-1- 19 | Hello! 20 | --EOF-1- 21 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testPOSTDifferentBody.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | Method: POST 5 | Protocol-Version: HTTP/1.1 6 | Protocol: http 7 | Host: www.cactuslab.com 8 | File: /testing.txt 9 | Response-Body:<<--EOF-1- 10 | posted 11 | --EOF-1- 12 | 13 | Method: POST 14 | Protocol-Version: HTTP/1.1 15 | Protocol: http 16 | Host: www.cactuslab.com 17 | File: /testing.txt 18 | Response-Body:<<--EOF-1- 19 | posted2 20 | --EOF-1- 21 | 22 | Method: GET 23 | Protocol-Version: HTTP/1.1 24 | Protocol: http 25 | Host: www.cactuslab.com 26 | File: /testing.txt 27 | Response-Body:<<--EOF-1- 28 | Hello! 29 | --EOF-1- 30 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testPOSTEmptyBody.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | Method: POST 5 | Protocol-Version: HTTP/1.1 6 | Protocol: http 7 | Host: www.cactuslab.com 8 | File: /testing.txt 9 | Response-Body:<<--EOF-1- 10 | posted 11 | --EOF-1- 12 | 13 | Method: GET 14 | Protocol-Version: HTTP/1.1 15 | Protocol: http 16 | Host: www.cactuslab.com 17 | File: /testing.txt 18 | Response-Body:<<--EOF-1- 19 | Hello! 20 | --EOF-1- 21 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testPOSTVersions.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | Method: POST 5 | Protocol-Version: HTTP/1.1 6 | Protocol: http 7 | Host: www.cactuslab.com 8 | File: /ignore_post.txt 9 | Response-Body:<<--EOF-1- 10 | Post1 11 | --EOF-1- 12 | 13 | Method: GET 14 | Protocol-Version: HTTP/1.1 15 | Protocol: http 16 | Host: www.cactuslab.com 17 | File: /folder/testing.txt 18 | Response-Body:<<--EOF-1- 19 | Great 20 | --EOF-1- 21 | 22 | Method: POST 23 | Protocol-Version: HTTP/1.1 24 | Protocol: http 25 | Host: www.cactuslab.com 26 | File: /testing.txt 27 | Response-Body:<<--EOF-1- 28 | Post2 29 | --EOF-1- 30 | 31 | Method: GET 32 | Protocol-Version: HTTP/1.1 33 | Protocol: http 34 | Host: www.cactuslab.com 35 | File: /folder/testing.txt 36 | Response-Body:<<--EOF-1- 37 | Wrong 38 | --EOF-1- 39 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testPassThrough.trace: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cactuslab/Succulent/cd59f12a2bbc8965c1e522ed867f4412373e4f24/Example/Tests/Succulent/Tests_testPassThrough.trace -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testQuery.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | 5 | Method: GET 6 | Protocol-Version: HTTP/1.1 7 | Protocol: http 8 | Host: www.cactuslab.com 9 | File: /query.txt?username=test 10 | Response-Header:<<--EOF-2- 11 | HTTP/1.1 200 OK 12 | Content-Type: text/plain 13 | 14 | --EOF-2- 15 | Response-Body:<<--EOF-2- 16 | Success for query 17 | --EOF-2- 18 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testQueryOrdering.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | Method: GET 5 | Protocol-Version: HTTP/1.1 6 | Protocol: http 7 | Host: www.cactuslab.com 8 | File: /query.txt?a=1&b=2&a=2 9 | Response-Header:<<--EOF-2- 10 | HTTP/1.1 200 OK 11 | Content-Type: text/plain 12 | 13 | --EOF-2- 14 | Response-Body:<<--EOF-2- 15 | Success for query 16 | --EOF-2- 17 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testQueryStringOrder.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | Method: GET 5 | Protocol-Version: HTTP/1.1 6 | Protocol: http 7 | Host: www.cactuslab.com 8 | File: /query.txt?username=test&perPage=2 9 | Response-Header:<<--EOF-2- 10 | HTTP/1.1 200 OK 11 | Content-Type: text/plain 12 | 13 | --EOF-2- 14 | Response-Body:<<--EOF-2- 15 | Success for query 16 | --EOF-2- 17 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testQueryWithMultipleRepeatedParams.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | Method: GET 5 | Protocol-Version: HTTP/1.1 6 | Protocol: http 7 | Host: www.cactuslab.com 8 | File: /query.txt?username=test&perPage=2&username=test1&perPage=z 9 | Response-Header:<<--EOF-2- 10 | HTTP/1.1 200 OK 11 | Content-Type: text/plain 12 | 13 | --EOF-2- 14 | Response-Body:<<--EOF-2- 15 | Success for query 16 | --EOF-2- 17 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testQueryWithRepeatedParam.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | Method: GET 5 | Protocol-Version: HTTP/1.1 6 | Protocol: http 7 | Host: www.cactuslab.com 8 | File: /query.txt?username=test&username=test1 9 | Response-Header:<<--EOF-2- 10 | HTTP/1.1 200 OK 11 | Content-Type: text/plain 12 | 13 | --EOF-2- 14 | Response-Body:<<--EOF-2- 15 | Success for query 16 | --EOF-2- 17 | 18 | Method: GET 19 | Protocol-Version: HTTP/1.1 20 | Protocol: http 21 | Host: www.cactuslab.com 22 | File: /query.txt?username=test&username=test1&username=test2 23 | Response-Header:<<--EOF-2- 24 | HTTP/1.1 200 OK 25 | Content-Type: text/plain 26 | 27 | --EOF-2- 28 | Response-Body:<<--EOF-2- 29 | This is also a Success 30 | --EOF-2- 31 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testSimple.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | 5 | Method: GET 6 | Protocol-Version: HTTP/1.1 7 | Protocol: http 8 | Host: www.cactuslab.com 9 | File: /testing 10 | Response-Body:<<--EOF-1- 11 | Hello world 12 | --EOF-1- 13 | 14 | Method: GET 15 | Protocol-Version: HTTP/1.1 16 | Protocol: http 17 | Host: www.cactuslab.com 18 | File: /testing.txt 19 | Response-Header:<<--EOF-2- 20 | HTTP/1.1 200 OK 21 | Content-Type: text/plain 22 | 23 | --EOF-2- 24 | Response-Body:<<--EOF-2- 25 | Hello! 26 | 27 | --EOF-2- 28 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testTilde.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | 5 | Method: GET 6 | Protocol-Version: HTTP/1.1 7 | Protocol: http 8 | Host: www.cactuslab.com 9 | File: /~/testing.txt 10 | Response-Body:<<--EOF-1- 11 | Tilde 12 | --EOF-1- 13 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/Tests_testVersioned.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | 5 | Method: GET 6 | Protocol-Version: HTTP/1.1 7 | Protocol: http 8 | Host: www.cactuslab.com 9 | File: /folder/testing.txt 10 | Response-Body:<<--EOF-1- 11 | Great 12 | --EOF-1- 13 | 14 | Method: POST 15 | Protocol-Version: HTTP/1.1 16 | Protocol: http 17 | Host: www.cactuslab.com 18 | File: /testing.txt 19 | Response-Body:<<--EOF-1- 20 | Random post to up the version number 21 | --EOF-1- 22 | 23 | Method: GET 24 | Protocol-Version: HTTP/1.1 25 | Protocol: http 26 | Host: www.cactuslab.com 27 | File: /folder/testing.txt 28 | Response-Body:<<--EOF-1- 29 | Wrong 30 | --EOF-1- 31 | -------------------------------------------------------------------------------- /Example/Tests/Succulent/TraceTests_testRecordingSimple.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | 5 | Method: GET 6 | Protocol-Version: HTTP/1.1 7 | Protocol: http 8 | Host: www.cactuslab.com 9 | File: /index.html 10 | Response-Header:<<--EOF-2- 11 | HTTP/1.1 200 OK 12 | Content-Type: text/plain 13 | 14 | --EOF-2- 15 | Response-Body:<<--EOF-2- 16 | Hello! 17 | --EOF-2- 18 | -------------------------------------------------------------------------------- /Example/Tests/TestSupport.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestSupport.swift 3 | // Succulent 4 | // 5 | // Created by Karl von Randow on 29/05/17. 6 | // Copyright © 2017 CocoaPods. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | 12 | protocol SucculentTest { 13 | var baseURL: URL! { get } 14 | var session: URLSession! { get } 15 | 16 | func GET(_ path: String, completion: @escaping (_ data: Data?, _ response: HTTPURLResponse?, _ error: Error?) -> ()) 17 | func POST(_ path: String, body: Data, completion: @escaping (_ data: Data?, _ response: HTTPURLResponse?, _ error: Error?) -> ()) 18 | 19 | } 20 | 21 | extension SucculentTest where Self: XCTestCase { 22 | 23 | func GET(_ path: String, completion: @escaping (_ data: Data?, _ response: HTTPURLResponse?, _ error: Error?) -> ()) { 24 | let url = URL(string: path, relativeTo: baseURL)! 25 | let expectation = self.expectation(description: "Loaded URL") 26 | 27 | let dataTask = session.dataTask(with: url) { (data, response, error) in 28 | completion(data, response as? HTTPURLResponse, error) 29 | expectation.fulfill() 30 | } 31 | dataTask.resume() 32 | 33 | self.waitForExpectations(timeout: 10) { (error) in 34 | if let error = error { 35 | completion(nil, nil, error) 36 | } 37 | } 38 | } 39 | 40 | func POST(_ path: String, body: Data, completion: @escaping (_ data: Data?, _ response: HTTPURLResponse?, _ error: Error?) -> ()) { 41 | let url = URL(string: path, relativeTo: baseURL)! 42 | let expectation = self.expectation(description: "Loaded URL") 43 | 44 | var req = URLRequest(url: url) 45 | req.httpMethod = "POST" 46 | 47 | let dataTask = session.uploadTask(with: req, from: body) { (data, response, error) in 48 | completion(data, response as? HTTPURLResponse, error) 49 | expectation.fulfill() 50 | } 51 | dataTask.resume() 52 | 53 | self.waitForExpectations(timeout: 10) { (error) in 54 | if let error = error { 55 | completion(nil, nil, error) 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Example/Tests/Tests.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import XCTest 3 | import Succulent 4 | 5 | class Tests: XCTestCase, SucculentTest { 6 | 7 | private var suc: Succulent! 8 | var session: URLSession! 9 | var baseURL: URL! 10 | 11 | override func setUp() { 12 | super.setUp() 13 | 14 | configureSucculent() 15 | 16 | session = URLSession(configuration: .default) 17 | } 18 | 19 | func configureSucculent(ignoredParams: Set? = nil, passThroughBaseUrl: URL? = nil) { 20 | if let suc = suc { 21 | suc.stop() 22 | } 23 | let conf = Configuration(port: nil, ignoreParameters: ignoredParams, ignoreVersioningRequests: ["^/ignore_post.txt"]) 24 | suc = Succulent(replayFrom: self.traceUrl, passThroughBaseUrl: passThroughBaseUrl, configuration: conf) 25 | 26 | suc.start() 27 | self.baseURL = URL(string: "http://localhost:\(suc.actualPort)") 28 | } 29 | 30 | /// The name of the trace file for the current test 31 | private var traceName: String { 32 | return self.description.trimmingCharacters(in: CharacterSet(charactersIn: "-[] ")).replacingOccurrences(of: " ", with: "_") 33 | } 34 | 35 | /// The URL to the trace file for the current test when running tests 36 | private var traceUrl: URL? { 37 | let bundle = Bundle(for: type(of: self)) 38 | return bundle.url(forResource: self.traceName, withExtension: "trace", subdirectory: "Succulent") 39 | } 40 | 41 | override func tearDown() { 42 | suc.stop() 43 | 44 | super.tearDown() 45 | } 46 | 47 | func testSimple() { 48 | GET("testing") { (data, response, error) in 49 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Hello world") 50 | XCTAssertEqual(response?.allHeaderFields["Content-Type"] as! String, "application/x-octet-stream") 51 | } 52 | 53 | GET("testing2") { (data, response, error) in 54 | XCTAssert(response?.statusCode == 404) 55 | } 56 | 57 | GET("testing.txt") { (data, response, error) in 58 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Hello!\n") 59 | XCTAssertEqual(response?.allHeaderFields["Content-Type"] as! String, "text/plain") 60 | } 61 | 62 | GET("testing.txt?") { (data, response, error) in 63 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Hello!\n") 64 | XCTAssertEqual(response?.allHeaderFields["Content-Type"] as! String, "text/plain") 65 | } 66 | } 67 | 68 | func testEncoding() { 69 | GET("encodingTest%28%20%2742%27%20%29") { (data, response, error) in 70 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Hello encoding!\n") 71 | XCTAssertEqual(response?.allHeaderFields["Content-Type"] as! String, "application/x-octet-stream") 72 | } 73 | } 74 | 75 | func testIgnoredParameters() { 76 | configureSucculent(ignoredParams: ["ignoreMe"]) 77 | 78 | GET("query.txt?username=test&ignoreMe=1209") { (data, response, error) in 79 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Success for query") 80 | XCTAssertEqual(response?.allHeaderFields["Content-Type"] as? String, "text/plain") 81 | } 82 | 83 | GET("query.txt?username=test&dontIgnoreMe=1209") { (data, response, error) in 84 | XCTAssert(response?.statusCode == 404) 85 | } 86 | } 87 | 88 | /// This tests a recording that was made without the ignoredParams that we’re going to run the replay with. So the recording contains query strings with the ignored param. We ensure that we can still match those requests. 89 | func testIgnoredParametersForTrace() { 90 | configureSucculent(ignoredParams: ["toBe"]) 91 | GET("query.txt?username=test&toBe=ignored") { (data, response, error) in 92 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Success for query") 93 | } 94 | } 95 | 96 | func testIgnoreAllParameters() { 97 | configureSucculent(ignoredParams: ["ignore_me"]) 98 | 99 | GET("query.txt?ignore_me=12345") { (data, response, error) in 100 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Success for query") 101 | XCTAssertEqual(response?.allHeaderFields["Content-Type"] as? String, "text/plain") 102 | } 103 | 104 | GET("query2.txt?ignore_me=12345") { (data, response, error) in 105 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Success for query") 106 | XCTAssertEqual(response?.allHeaderFields["Content-Type"] as? String, "text/plain") 107 | } 108 | } 109 | 110 | 111 | func testQuery() { 112 | GET("query.txt?username=test") { (data, response, error) in 113 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Success for query") 114 | XCTAssertEqual(response?.allHeaderFields["Content-Type"] as? String, "text/plain") 115 | } 116 | 117 | GET("query.txt?username=fail") { (data, response, error) in 118 | XCTAssert(response?.statusCode == 404) 119 | } 120 | 121 | GET("query.txt") { (data, response, error) in 122 | XCTAssert(response?.statusCode == 404) 123 | } 124 | } 125 | 126 | func testNested() { 127 | GET("folder/testing.txt") { (data, response, error) in 128 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Great") 129 | } 130 | } 131 | 132 | func testTilde() { 133 | GET("~/testing.txt") { (data, response, error) in 134 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Tilde") 135 | } 136 | } 137 | 138 | func testVersioned() { 139 | GET("folder/testing.txt") { (data, response, error) in 140 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Great") 141 | } 142 | 143 | POST("testing.txt", body: Data()) { (data, response, error) in 144 | XCTAssertEqual(String(data: data!, encoding: .utf8), "Random post to up the version number") 145 | } 146 | 147 | GET("folder/testing.txt") { (data, response, error) in 148 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Wrong") 149 | } 150 | } 151 | 152 | func testPOSTVersions() { 153 | 154 | POST("ignore_post.txt", body: Data()) { (data, response, error) in 155 | XCTAssertEqual(String(data: data!, encoding: .utf8), "Post1") 156 | } 157 | 158 | GET("folder/testing.txt") { (data, response, error) in 159 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Great") 160 | } 161 | 162 | POST("testing.txt", body: Data()) { (data, response, error) in 163 | XCTAssertEqual(String(data: data!, encoding: .utf8), "Post2") 164 | } 165 | 166 | GET("folder/testing.txt") { (data, response, error) in 167 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Wrong") 168 | } 169 | } 170 | 171 | func testPOSTEmptyBody() { 172 | XCTAssertEqual(0, suc.version) 173 | 174 | POST("testing.txt", body: Data()) { (data, response, error) in 175 | XCTAssertEqual(String(data: data!, encoding: .utf8), "posted") 176 | } 177 | 178 | XCTAssertEqual(1, suc.version) 179 | 180 | GET("testing.txt") { (data, response, error) in 181 | let string = String(data: data!, encoding: .utf8)! 182 | XCTAssert(string == "Hello!") 183 | } 184 | 185 | XCTAssertEqual(1, suc.version) 186 | } 187 | 188 | func testPOST() { 189 | XCTAssertEqual(0, suc.version) 190 | 191 | POST("testing.txt", body: "Body".data(using: .utf8)!) { (data, response, error) in 192 | XCTAssertEqual(String(data: data!, encoding: .utf8), "posted") 193 | } 194 | 195 | XCTAssertEqual(1, suc.version) 196 | 197 | GET("testing.txt") { (data, response, error) in 198 | let string = String(data: data!, encoding: .utf8)! 199 | XCTAssert(string == "Hello!") 200 | } 201 | 202 | XCTAssertEqual(1, suc.version) 203 | } 204 | 205 | func testPOSTDifferentBody() { 206 | XCTAssertEqual(0, suc.version) 207 | 208 | POST("testing.txt", body: "Body".data(using: .utf8)!) { (data, response, error) in 209 | XCTAssertEqual(String(data: data!, encoding: .utf8), "posted") 210 | } 211 | 212 | XCTAssertEqual(1, suc.version) 213 | 214 | POST("testing.txt", body: "Body-Diff".data(using: .utf8)!) { (data, response, error) in 215 | XCTAssertEqual(String(data: data!, encoding: .utf8), "posted2") 216 | } 217 | 218 | XCTAssertEqual(2, suc.version) 219 | 220 | GET("testing.txt") { (data, response, error) in 221 | let string = String(data: data!, encoding: .utf8)! 222 | XCTAssert(string == "Hello!") 223 | } 224 | 225 | XCTAssertEqual(2, suc.version) 226 | } 227 | 228 | func testPassThrough() { 229 | configureSucculent(ignoredParams: nil, passThroughBaseUrl: URL(string: "http://www.cactuslab.com/")) 230 | 231 | GET("index.html") { (data, response, error) in 232 | let string = String(data: data!, encoding: .utf8)! 233 | XCTAssertTrue(string.endIndex > string.startIndex) 234 | } 235 | } 236 | 237 | func testPassThroughURLPreservation() { 238 | configureSucculent(ignoredParams: nil, passThroughBaseUrl: URL(string: "http://www.cactuslab.com/api/")) 239 | 240 | GET("index.html") { (data, response, error) in 241 | XCTAssertTrue(response?.url?.absoluteString == "http://cactuslab.com/api/index.html", "The responseURL was \(response?.url?.absoluteString ?? "nil")") 242 | } 243 | } 244 | 245 | func testHeaders() { 246 | GET("headers/index.html") { (data, response, error) in 247 | XCTAssertEqual(response?.statusCode, 404) 248 | XCTAssertEqual(response?.allHeaderFields["Content-Type"] as! String, "text/html;charset=utf-8") 249 | } 250 | } 251 | 252 | func testHeaderMunge() { 253 | let value = Succulent.munge(key: "Set-Cookie", value: ".ASPXAUTH=E2A2F27E643A5060E240F3CD3BBFFF3420264C34A9B441DCB0D7C2DDB8A1CD4B0552EC1C2ACCF88D0D491C05CA780E08388CF34ACF175242CC5F7BEA273644F241C780367BE9DA96E2A4A72A88245F1AB74B70A37A876AA69F727B402E81004EF23C3752BEFC5C29D2BE734F07EFECEDB689CDB4; domain=.barfoot.co.nz; expires=Wed, 25-Jan-2017 02:32:25 GMT; path=/; HttpOnly") 254 | XCTAssertEqual(value, ".ASPXAUTH=E2A2F27E643A5060E240F3CD3BBFFF3420264C34A9B441DCB0D7C2DDB8A1CD4B0552EC1C2ACCF88D0D491C05CA780E08388CF34ACF175242CC5F7BEA273644F241C780367BE9DA96E2A4A72A88245F1AB74B70A37A876AA69F727B402E81004EF23C3752BEFC5C29D2BE734F07EFECEDB689CDB4; domain=localhost; expires=Wed, 25-Jan-2017 02:32:25 GMT; path=/; HttpOnly") 255 | 256 | XCTAssertEqual(Succulent.munge(key: "set-COOKIE", value: "name=value"), "name=value") 257 | } 258 | 259 | func testSetCookieMadness() { 260 | let value = "SC_ANALYTICS_GLOBAL_COOKIE=73051b1ef8cb4754a229d527e05b35e6; expires=Mon, 25-Jan-2027 02:39:32 GMT; path=/; HttpOnly, SC_ANALYTICS_SESSION_COOKIE=20DEC82E7861452F884C0E562C7663A9|1|00zaww0gms2fk3gkv03cyfht; path=/; HttpOnly, .ASPXAUTH=BA9DF32B7E5964D3B99F90FFB6DC39DA4A245F5FF439964D744E4412CB07D623021192D160B3C922C256A5545B17F4D19F698561E01AA870CD01028539A8CF3ADBB56A15D80239BD66D7BC4413E4C085C5AF64B425823404BAB81DC76166CBC8216D3F437CFAFC907D96CD42D99D77E846DA9FDE; expires=Wed, 25-Jan-2017 03:09:32 GMT; path=/; HttpOnly" 261 | let values = Succulent.splitSetCookie(value: value) 262 | 263 | XCTAssertEqual(values.count, 3) 264 | XCTAssertEqual(values[0], "SC_ANALYTICS_GLOBAL_COOKIE=73051b1ef8cb4754a229d527e05b35e6; expires=Mon, 25-Jan-2027 02:39:32 GMT; path=/; HttpOnly") 265 | XCTAssertEqual(values[1], "SC_ANALYTICS_SESSION_COOKIE=20DEC82E7861452F884C0E562C7663A9|1|00zaww0gms2fk3gkv03cyfht; path=/; HttpOnly") 266 | XCTAssertEqual(values[2], ".ASPXAUTH=BA9DF32B7E5964D3B99F90FFB6DC39DA4A245F5FF439964D744E4412CB07D623021192D160B3C922C256A5545B17F4D19F698561E01AA870CD01028539A8CF3ADBB56A15D80239BD66D7BC4413E4C085C5AF64B425823404BAB81DC76166CBC8216D3F437CFAFC907D96CD42D99D77E846DA9FDE; expires=Wed, 25-Jan-2017 03:09:32 GMT; path=/; HttpOnly") 267 | } 268 | 269 | func testIgnorePostVersioning() { 270 | XCTAssertEqual(0, suc.version) 271 | 272 | GET("get2.txt") { (data, response, error) in 273 | let string = String(data: data!, encoding: .utf8)! 274 | XCTAssert(string == "get2") 275 | } 276 | 277 | POST("ignore_post.txt", body: "Body".data(using: .utf8)!) { (data, response, error) in 278 | XCTAssertEqual(String(data: data!, encoding: .utf8), "posted1") 279 | } 280 | 281 | GET("get1.txt") { (data, response, error) in 282 | let string = String(data: data!, encoding: .utf8)! 283 | XCTAssert(string == "get1") 284 | } 285 | 286 | XCTAssertEqual(0, suc.version) 287 | 288 | POST("post2.txt", body: "Body".data(using: .utf8)!) { (data, response, error) in 289 | XCTAssertEqual(String(data: data!, encoding: .utf8), "posted2") 290 | } 291 | 292 | GET("get1.txt") { (data, response, error) in 293 | let string = String(data: data!, encoding: .utf8)! 294 | XCTAssert(string == "get1+1") 295 | } 296 | 297 | XCTAssertEqual(1, suc.version) 298 | } 299 | 300 | func testQueryStringOrder() { 301 | configureSucculent(ignoredParams: ["a"]) 302 | 303 | GET("query.txt?username=test&perPage=2&a=1") { (data, response, error) in 304 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Success for query") 305 | } 306 | GET("query.txt?perPage=2&username=test&a=1") { (data, response, error) in 307 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Success for query") 308 | } 309 | } 310 | 311 | func testQueryWithRepeatedParam() { 312 | GET("query.txt?username=test&username=test1") { (data, response, error) in 313 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Success for query") 314 | } 315 | GET("query.txt?username=test1&username=test") { (data, response, error) in 316 | XCTAssert(response?.statusCode == 404) 317 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "") 318 | } 319 | GET("query.txt?username=test&username=test1&username=test2") { (data, response, error) in 320 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "This is also a Success") 321 | } 322 | GET("query.txt?username=test&username=test1&username=test2&username=test3") { (data, response, error) in 323 | XCTAssert(response?.statusCode == 404) 324 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "") 325 | } 326 | } 327 | 328 | func testQueryWithMultipleRepeatedParams() { 329 | GET("query.txt?username=test&perPage=2&username=test1&perPage=z") { (data, response, error) in 330 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Success for query") 331 | } 332 | GET("query.txt?username=test&username=test1&perPage=2&perPage=z") { (data, response, error) in 333 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Success for query") 334 | } 335 | } 336 | 337 | ///Test our matching on different query string name & value order 338 | func testQueryOrdering() { 339 | GET("query.txt?a=1&b=2&a=2") { (data, response, error) in 340 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Success for query") 341 | } 342 | GET("query.txt?a=1&a=2&b=2") { (data, response, error) in 343 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Success for query") 344 | } 345 | GET("query.txt?b=2&a=1&a=2") { (data, response, error) in 346 | XCTAssertEqual(String(data: data!, encoding: .utf8)!, "Success for query") 347 | } 348 | GET("query.txt?a=2&a=1&b=2") { (data, response, error) in 349 | XCTAssert(response?.statusCode == 404) 350 | XCTAssertNotEqual(String(data: data!, encoding: .utf8)!, "Success for query") 351 | } 352 | GET("query.txt?b=2&a=2&a=1") { (data, response, error) in 353 | XCTAssert(response?.statusCode == 404) 354 | XCTAssertNotEqual(String(data: data!, encoding: .utf8)!, "Success for query") 355 | } 356 | } 357 | } 358 | -------------------------------------------------------------------------------- /Example/Tests/TraceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TraceTests.swift 3 | // Succulent 4 | // 5 | // Created by Thomas Carey on 9/03/17. 6 | // Copyright © 2017 CocoaPods. All rights reserved. 7 | // 8 | 9 | @testable import Succulent 10 | import XCTest 11 | 12 | class TraceTests: XCTestCase, SucculentTest { 13 | 14 | private var suc: Succulent! 15 | var session: URLSession! 16 | var baseURL: URL! 17 | private var recordingURL: URL! 18 | 19 | override func setUp() { 20 | super.setUp() 21 | 22 | recordingURL = self.recordUrl 23 | 24 | suc = Succulent(recordTo: recordingURL, baseUrl: URL(string: "http://cactuslab.com/")!) 25 | suc.start() 26 | 27 | baseURL = URL(string: "http://localhost:\(suc.actualPort)") 28 | session = URLSession(configuration: .default) 29 | } 30 | 31 | /// The name of the trace file for the current test 32 | private var traceName: String { 33 | return self.description.trimmingCharacters(in: CharacterSet(charactersIn: "-[] ")).replacingOccurrences(of: " ", with: "_") 34 | } 35 | 36 | /// The URL to the trace file for the current test when recording 37 | private var recordUrl: URL { 38 | let bundle = Bundle(for: type(of: self)) 39 | let recordPath = bundle.infoDictionary!["TraceRecordPath"] as! String 40 | return URL(fileURLWithPath: "\(recordPath)/\(self.traceName).trace") 41 | } 42 | 43 | override func tearDown() { 44 | suc.stop() 45 | 46 | super.tearDown() 47 | } 48 | 49 | func testRecordingSimple() { 50 | // NB: we've bundled a trace file for this test to demonstrate that existing traces are not used in recording mode 51 | GET("index.html") { (data, response, error) in 52 | XCTAssertEqual(response?.statusCode, 404) 53 | let string = String(data: data!, encoding: .utf8)! 54 | XCTAssertTrue(string.endIndex > string.startIndex) 55 | } 56 | } 57 | 58 | func testRecordingResult() { 59 | GET("/") { (data, response, error) in 60 | XCTAssertEqual(response?.statusCode, 200) 61 | 62 | let traceReader = TraceReader(fileURL: self.recordingURL) 63 | let results = traceReader.readFile()! 64 | 65 | XCTAssertEqual(results.count, 1) 66 | 67 | XCTAssert(results[0].responseBody == data) 68 | } 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /Example/Traces/TraceTests_testRecordingResult.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | 5 | Method: GET 6 | Protocol-Version: HTTP/1.1 7 | Protocol: http 8 | Host: cactuslab.com 9 | File: / 10 | Response-Header:<<--EOF-6E9A9080-CB94-43DB-B441-3F6D232F593C- 11 | HTTP/1.1 200 12 | Content-Length: 5401 13 | Set-Cookie: JSESSIONID=BE544279B5BBE907D56FD85B43725A1C; Path=/; HttpOnly 14 | Proxy-Connection: keep-alive 15 | Server: Apache 16 | X-UA-Compatible: IE=Edge 17 | Content-Type: text/html;charset=utf-8 18 | Content-Language: en-US 19 | Date: Tue, 14 May 2019 02:04:57 GMT 20 | Keep-Alive: timeout=5, max=100 21 | Content-Encoding: gzip 22 | Vary: Accept-Encoding 23 | 24 | --EOF-6E9A9080-CB94-43DB-B441-3F6D232F593C- 25 | Response-Body:<<--EOF-6E9A9080-CB94-43DB-B441-3F6D232F593C- 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | Cactuslab, a web and app design and development studio with superpowers in Auckland, New Zealand 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 |
83 | 84 | 85 | 86 | 87 | 88 | 93 | 94 | 95 |
96 | 97 | 98 | 99 | 137 | 138 | 139 | 140 |
141 | 142 |
143 | 144 | 145 | 146 | 147 |
148 |
149 | 150 |
151 |
152 |

Sites, apps and superpowers.

153 | 154 | 157 | 158 | 159 | 160 | 161 | 162 |
163 |
164 | 165 |
166 | 167 |
168 |
169 | Cactuslab Inflatable Hero 171 |
172 |
173 |
174 |
175 |
176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 |
189 |
190 | 191 |
192 |
193 |

Zoomy

194 | 195 |
196 | 197 | 198 |

We worked with local ride-sharing company Zoomy to re-imagine its rider app for iOS and Android. The result is a much improved user experience and two beautiful new apps.

199 | 200 |
201 | 202 | View Project 203 | 204 | 205 |
206 |
207 | 208 | 209 | 210 | 211 | 212 |
213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 |
221 |
222 |
223 | 224 | 225 |
226 |
227 |
228 | 229 | 230 | 231 | 232 | 233 |
234 |
235 | 236 | 237 |
238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 |
249 |
250 | 251 |
252 |
253 |

Designers Institute

254 | 255 |
256 | 257 | 258 |

We worked with two of the Institute’s long-time partners to deliver this evolution of its site, placing more focus on our design community.

259 | 260 |
261 | 262 | View Project 263 | 264 | 265 |
266 |
267 | 268 | 269 | 270 | 271 | 272 |
273 | 274 | 275 | 276 |
277 |
278 |
279 | 280 | 281 |
282 |
283 |
284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 |
292 |
293 |
294 | 295 | 296 |
297 |
298 |
299 | 300 | 301 | 302 | 303 | 304 |
305 |
306 | 307 | 308 |
309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 |
320 |
321 | 322 |
323 |
324 |

Barfoot & Thompson

325 | 326 |
327 | 328 | 329 |

We designed and built the new, universal Barfoot & Thompson app for iOS. It features an all-new interface and improved agent-mode features that allow the company’s staff to manage and access data for their properties.

330 | 331 |
332 | 333 | View Project 334 | 335 | 336 |
337 |
338 | 339 | 340 | 341 | 342 | 343 |
344 | 345 | 346 | 347 | 348 | 349 |
350 |
351 |
352 | 353 | 354 |
355 |
356 |
357 | 358 | 359 | 360 | 361 | 362 |
363 |
364 |
365 | 366 | 367 |
368 |
369 |
370 | 371 | 372 | 373 | 374 | 375 |
376 |
377 | 378 | 379 |
380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 |
391 |
392 | 393 |
394 |
395 |

Vend Register

396 | 397 |
398 | 399 | 400 |

We worked with Vend to design a new interface and experience for its flagship iPad app, and continue to provide feature development and maintenance assistance.

401 | 402 |
403 | 404 | View Project 405 | 406 | 407 |
408 |
409 |
410 | 411 |
412 |
413 | 414 | 415 | 416 | 417 | 418 |
419 | 420 | 421 | 422 | 423 | 424 |
425 |
426 |
427 | 428 | 429 |
430 |
431 |
432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 |
440 |
441 | 442 | 443 |
444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 |
455 |
456 | 457 |
458 |
459 |

Back of a Napkin

460 | 461 |
462 | 463 | 464 |

We created this tool for Buddle Findlay to promote its Startup Companies division. The resulting webapp creates a customised contract based on an applicant’s answers to five simple questions.

465 | 466 |
467 | 468 | View Project 469 | 470 | 471 |
472 |
473 |
474 | 475 |
476 |
477 | 478 | 479 | 480 | 481 | 482 |
483 | 484 | 485 | 486 |
487 |
488 |
489 | 490 | 491 |
492 |
493 |
494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 |
504 |
505 | 506 |
507 |
508 | 509 | 510 | 511 |
512 |
513 | 514 | 515 |
516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 526 |
527 |
528 | 529 |
530 |
531 |

Letterboxd

532 | 533 |
534 | 535 | 536 |

For years we dreamt of a better place to discuss our shared interest in film, and when we couldn’t find the right outlet, we made one. The result is Letterboxd, a social network for grass-roots film discussion and discovery.

537 | 538 |
539 | 540 | View Project 541 | 542 | 543 |
544 |
545 |
546 | 547 |
548 |
549 | 550 | 551 | 552 | 553 | 554 |
555 | 556 | 557 | 558 |
559 |
560 |
561 | 562 | 563 |
564 |
565 |
566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 |
574 |
575 |
576 | 577 | 578 |
579 |
580 |
581 | 582 | 583 | 584 | 585 | 586 |
587 |
588 | 589 |
590 |
591 | 592 | 593 | 594 |
595 |
596 | 597 | 598 |
599 | 600 | 601 | 602 |
603 | 604 |
605 | 606 |
607 | 608 |
609 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 630 | 631 | 632 | 633 | 634 | 635 | --EOF-6E9A9080-CB94-43DB-B441-3F6D232F593C- 636 | -------------------------------------------------------------------------------- /Example/Traces/TraceTests_testRecordingSimple.trace: -------------------------------------------------------------------------------- 1 | HTTP-Trace-Version: 1.0 2 | Generator: Succulent/1.0 3 | 4 | 5 | Method: GET 6 | Protocol-Version: HTTP/1.1 7 | Protocol: http 8 | Host: cactuslab.com 9 | File: /index.html 10 | Response-Header:<<--EOF-4FCA3DFC-B28D-4A8A-9A1D-280CCDB36CF5- 11 | HTTP/1.1 404 12 | Proxy-Connection: keep-alive 13 | Date: Tue, 14 May 2019 02:04:57 GMT 14 | X-UA-Compatible: IE=Edge 15 | Server: Apache 16 | Content-Type: text/html;charset=utf-8 17 | Content-Language: en-US 18 | Keep-Alive: timeout=5, max=100 19 | Transfer-Encoding: Identity 20 | 21 | --EOF-4FCA3DFC-B28D-4A8A-9A1D-280CCDB36CF5- 22 | Response-Body:<<--EOF-4FCA3DFC-B28D-4A8A-9A1D-280CCDB36CF5- 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | Cactuslab, a web and app design and development studio with superpowers in Auckland, New Zealand 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 |
80 | 81 | 82 | 87 | 88 | 89 |
90 | 91 | 92 | 93 | 131 | 132 | 133 | 134 |
135 | 136 |
137 | 138 | 139 | 140 |
141 |
142 | 143 |
144 |
145 |

404—Page Not Found

146 | 147 |

This page appears to have vanished! Spooky times. 👻

148 | 149 | Show me some work 150 |
151 |
152 | 153 |
154 | 155 |
156 |
157 | Cactuslab Inflatable Hero 159 |
160 |
161 |
162 |
163 |
164 |
165 | 166 |
167 | 168 |
169 | 170 |
171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 192 | 193 | 194 | 195 | 196 | 197 | --EOF-4FCA3DFC-B28D-4A8A-9A1D-280CCDB36CF5- 198 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Cactuslab Ltd 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Embassy", 6 | "repositoryURL": "https://github.com/envoy/Embassy.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "189436100c00efbf5fb2653fe7972a9371db0a91", 10 | "version": "4.1.1" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Succulent", 8 | products: [ 9 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 10 | .library( 11 | name: "Succulent", 12 | targets: ["Succulent"]), 13 | ], 14 | dependencies: [ 15 | // Dependencies declare other packages that this package depends on. 16 | .package(url: "https://github.com/envoy/Embassy.git", .upToNextMajor(from: "4.1.1")), 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 20 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 21 | .target( 22 | name: "Succulent", 23 | dependencies: [ 24 | .product(name: "Embassy", package: "Embassy") 25 | ], 26 | path: "./Succulent/Classes" 27 | ), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Succulent 2 | 3 | [![Version](https://img.shields.io/cocoapods/v/Succulent.svg?style=flat)](http://cocoapods.org/pods/Succulent) 4 | [![License](https://img.shields.io/cocoapods/l/Succulent.svg?style=flat)](http://cocoapods.org/pods/Succulent) 5 | [![Platform](https://img.shields.io/cocoapods/p/Succulent.svg?style=flat)](http://cocoapods.org/pods/Succulent) 6 | [![Build Status](https://travis-ci.org/cactuslab/Succulent.svg?branch=develop)](https://travis-ci.org/cactuslab/Succulent) 7 | 8 | Succulent is a Swift library to provide API recording and replay for automated testing on iOS. 9 | 10 | Succulent creates a local web server that you point your app to, instead of the live API. In recording 11 | mode, Succulent receives the API request from the app and then makes the same request to the live API, 12 | recording the request and response for future replay. 13 | 14 | Succulent can also handle mutating requests, like POST, PUT and DELETE: after a mutating request Succulent 15 | stores a new version of any subsequent responses, then correctly simulates the change during playback. 16 | 17 | ## Why? 18 | 19 | Succulent solves the problem of getting repeatable API results to support stable automated testing. 20 | 21 | ## Example 22 | 23 | Set up Succulent in your XCTestCase's `setUp` method: 24 | 25 | ```swift 26 | var app: XCUIApplication! 27 | var succulent: Succulent! 28 | 29 | override func setUp() { 30 | super.setUp() 31 | 32 | self.app = XCUIApplication() 33 | 34 | if let traceUrl = self.traceUrl { 35 | // Replay using an existing trace file 36 | self.succulent = Succulent(replayFrom: traceUrl) 37 | } else { 38 | // Record to a new trace file 39 | // The "/" at the end of the base URL is required 40 | self.succulent = Succulent(recordTo: self.recordUrl, baseUrl: URL(string: "{YOUR-REAL-BASE-URL}/")!) 41 | } 42 | 43 | self.succulent.start() 44 | 45 | self.app.launchEnvironment["succulentBaseURL"] = "http://localhost:\(succulent.actualPort)/" 46 | self.app.launch() 47 | } 48 | 49 | /// The name of the trace file for the current test 50 | private var traceName: String { 51 | return self.description.trimmingCharacters(in: CharacterSet(charactersIn: "-[] ")).replacingOccurrences(of: " ", with: "_") 52 | } 53 | 54 | /// The URL to the trace file for the current test when running tests 55 | private var traceUrl: URL? { 56 | let bundle = Bundle(for: type(of: self)) 57 | return bundle.url(forResource: self.traceName, withExtension: "trace", subdirectory: "Succulent") 58 | } 59 | 60 | /// The URL to the trace file for the current test when recording 61 | private var recordUrl: URL { 62 | let bundle = Bundle(for: type(of: self)) 63 | let recordPath = bundle.infoDictionary!["TraceRecordPath"] as! String 64 | return URL(fileURLWithPath: "\(recordPath)/\(self.traceName).trace") 65 | } 66 | ``` 67 | 68 | Note that `recordUrl` uses a string that must be set up in your UI testing directory's `Info.plist` file: 69 | 70 | ```xml 71 | TraceRecordPath 72 | $(PROJECT_DIR)/Succulent/ 73 | ``` 74 | 75 | You also need to give the target you are testing permission to connect to a local server. This is done by adding the following to the `Info.plist` of the target you are testing against: 76 | 77 | ```xml 78 | NSAppTransportSecurity 79 | 80 | NSAllowsLocalNetworking 81 | 82 | 83 | ``` 84 | 85 | With this setting, Succulent records trace files into your project source tree. Therefore your Succulent traces are committed to source control with your test files, and when you build and run your tests the traces are copied into the test application. 86 | 87 | Finally, in your app, look for the `"succulentBaseURL"` environment variable, and use that URL in place 88 | of your live API URL: 89 | 90 | ```swift 91 | let apiBaseUrlString = ProcessInfo.processInfo.environment["succulentBaseURL"] ?? "{YOUR-REAL-BASE-URL}" 92 | let apiBaseUrl = URL(string: baseUrlString) 93 | ``` 94 | 95 | There is an example project in the `Example` directory. To run the example project, run `pod install` from within the Example directory, then open the Xcode workspace and run the tests. The example project demonstrates some of the use of Succulent in a stand-alone setting rather than as it is intended, which is for UI automation testing of another app. 96 | 97 | ## Requirements 98 | 99 | ## Installation 100 | 101 | Succulent is available through [CocoaPods](http://cocoapods.org). To install 102 | it, simply add the following line to your Podfile: 103 | 104 | ```ruby 105 | pod "Succulent" 106 | ``` 107 | 108 | ## Authors 109 | 110 | [Karl von Randow](https://github.com/karlvr), [Tom Carey](https://github.com/tomcarey) 111 | 112 | ## License 113 | 114 | Succulent is available under the MIT license. See the LICENSE file for more info. 115 | -------------------------------------------------------------------------------- /Succulent.podspec: -------------------------------------------------------------------------------- 1 | # 2 | # Be sure to run `pod lib lint Succulent.podspec' to ensure this is a 3 | # valid spec before submitting. 4 | # 5 | # Any lines starting with a # are optional, but their use is encouraged 6 | # To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html 7 | # 8 | 9 | Pod::Spec.new do |s| 10 | s.name = 'Succulent' 11 | s.version = '0.5.0' 12 | s.summary = 'Succulent allows you to record and replay API responses to speed up and isolate 13 | your unit and UI tests.' 14 | 15 | # This description is used to generate tags and improve search results. 16 | # * Think: What does it do? Why did you write it? What is the focus? 17 | # * Try to keep it short, snappy and to the point. 18 | # * Write the description between the DESC delimiters below. 19 | # * Finally, don't worry about the indent, CocoaPods strips it! 20 | 21 | s.description = <<-DESC 22 | A common problem is that an API is often developed in 23 | tandem with the client. Succulent is designed specifically to remove the effort 24 | in maintaining a specially built mock API by allowing you to record the current 25 | API and replay it back in unit and UI tests. This allows you to quickly update 26 | the tests when the API changes throughout your development cycle. 27 | DESC 28 | 29 | s.homepage = 'https://github.com/cactuslab/Succulent' 30 | # s.screenshots = 'www.example.com/screenshots_1', 'www.example.com/screenshots_2' 31 | s.license = { :type => 'MIT', :file => 'LICENSE' } 32 | s.author = { 'Karl von Randow' => 'karl@cactuslab.com', 'Thomas Carey' => 'tom@cactuslab.com' } 33 | s.source = { :git => 'https://github.com/cactuslab/Succulent.git', :tag => s.version.to_s } 34 | # s.social_media_url = 'https://twitter.com/' 35 | 36 | s.ios.deployment_target = '8.0' 37 | 38 | s.source_files = 'Succulent/Classes/**/*.{m,h,swift}' 39 | s.swift_version = '5.0' 40 | s.dependency 'Embassy', '~> 4.1.0' 41 | end 42 | -------------------------------------------------------------------------------- /Succulent/Assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cactuslab/Succulent/cd59f12a2bbc8965c1e522ed867f4412373e4f24/Succulent/Assets/.gitkeep -------------------------------------------------------------------------------- /Succulent/Classes/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cactuslab/Succulent/cd59f12a2bbc8965c1e522ed867f4412373e4f24/Succulent/Classes/.gitkeep -------------------------------------------------------------------------------- /Succulent/Classes/FileHandle+readLine.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileHandle+readLine.swift 3 | // Succulent 4 | // 5 | // Created by Daniel Muhra on 18/11/19. 6 | // Copyright © 2019 CaperWhite GmbH. All rights reserved. 7 | // 8 | // 9 | 10 | import Foundation 11 | 12 | /// Used to read a line within a file in chunks 13 | struct LineReader: IteratorProtocol { 14 | static private let bufferSize = 1024 15 | 16 | typealias Element = Data 17 | 18 | let fileHandle: FileHandle 19 | let delimiter: UInt8 20 | var found = false 21 | 22 | mutating func next() -> Data? { 23 | // We found the delimiter, so we are done 24 | guard !found else { return nil } 25 | 26 | let offset = self.fileHandle.offsetInFile 27 | let lineData = self.fileHandle.readData(ofLength: LineReader.bufferSize) 28 | 29 | // If the data is empty, we reached the end of the file. So we are done too 30 | if lineData.isEmpty { return nil } 31 | 32 | // If we don't find the delimiter, we simply return this data batch 33 | guard let index = lineData.firstIndex(of: delimiter) else { return lineData } 34 | 35 | // On the next iteration, we will terminate 36 | found = true 37 | 38 | // Small optimisation: If the delimiter is the last item, we simply return the whole data batch. 39 | if index == lineData.count - 1 { return lineData } 40 | 41 | // Set the handle right after the delimiter 42 | self.fileHandle.seek(toFileOffset: offset + UInt64(index) + 1) 43 | 44 | // Return the data up to the delimiter (and include the it). 45 | return lineData[0...index] 46 | } 47 | 48 | } 49 | 50 | extension FileHandle { 51 | // Standard delimiter 52 | static let delimiter = "\n".data(using: .ascii)!.first! 53 | 54 | func delimiterLength(_ delimiter: String) -> Int { 55 | return delimiter.data(using: .ascii)?.count ?? 0 56 | } 57 | 58 | func readLine(withDelimiter theDelimiter: String) -> Data? { 59 | // TODO: Remove this hack 60 | // We always use \n as delimiter in Succulent, so we can hardcode it. 61 | // Converting it to UInt8 was actually quite expensive (~30% of the overall computing time, if done each time). 62 | return self.readLine(withDelimiter: FileHandle.delimiter) 63 | } 64 | 65 | private func readLine(withDelimiter delimiter: UInt8) -> Data? { 66 | let reader = LineReader(fileHandle: self, delimiter: delimiter) 67 | 68 | // Simply read all batches and concatenate them until the delimiter is reached. 69 | let lineData = IteratorSequence(reader).reduce(Data(), +) 70 | 71 | return lineData.isEmpty ? nil : lineData 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Succulent/Classes/Router.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Router.swift 3 | // Succulent 4 | // 5 | // Created by Karl von Randow on 15/01/17. 6 | // Copyright © 2017 Cactuslab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public typealias RoutingResultBLock = (RoutingResult) -> () 12 | 13 | /// The result of routing 14 | public enum RoutingResult { 15 | 16 | case response(_: Response) 17 | case error(_: Error) 18 | case noRoute 19 | 20 | } 21 | 22 | public class Router { 23 | 24 | private var routes = [Route]() 25 | 26 | public init() { 27 | 28 | } 29 | 30 | public func add(_ path: String) -> Route { 31 | let route = Route(path) 32 | routes.append(route) 33 | return route 34 | } 35 | 36 | public func handle(request: Request, resultBlock: @escaping RoutingResultBLock) { 37 | var bestScore = -1 38 | var bestRoute: Route? 39 | 40 | for route in routes { 41 | if let score = route.match(request: request) { 42 | if score >= bestScore { 43 | bestScore = score 44 | bestRoute = route 45 | } 46 | } 47 | } 48 | 49 | if let route = bestRoute { 50 | route.handle(request: request, resultBlock: resultBlock) 51 | } else { 52 | resultBlock(.noRoute) 53 | } 54 | } 55 | 56 | public func handleSync(request: Request) -> RoutingResult { 57 | var result: RoutingResult? 58 | let semaphore = DispatchSemaphore(value: 0) 59 | 60 | handle(request: request) { theResult in 61 | result = theResult 62 | semaphore.signal() 63 | } 64 | 65 | semaphore.wait() 66 | return result! 67 | } 68 | 69 | } 70 | 71 | /// The status code of a response 72 | public enum ResponseStatus: Equatable, CustomStringConvertible { 73 | 74 | public static func ==(lhs: ResponseStatus, rhs: ResponseStatus) -> Bool { 75 | return lhs.code == rhs.code 76 | } 77 | 78 | case notFound 79 | case ok 80 | case notModified 81 | case internalServerError 82 | case other(code: Int) 83 | 84 | public var code: Int { 85 | switch self { 86 | case .notFound: return 404 87 | case .ok: return 200 88 | case .notModified: return 304 89 | case .internalServerError: return 500 90 | case .other(let code): return code 91 | } 92 | } 93 | 94 | public var message: String { 95 | return HTTPURLResponse.localizedString(forStatusCode: code) 96 | } 97 | 98 | public var description: String { 99 | return "\(self.code) \(self.message)" 100 | } 101 | 102 | } 103 | 104 | /// The mime-type part of a content type 105 | public enum ContentType { 106 | case TextJSON 107 | case TextPlain 108 | case TextHTML 109 | case Other(type: String) 110 | 111 | func type() -> String { 112 | switch self { 113 | case .TextJSON: 114 | return "text/json" 115 | case .TextPlain: 116 | return "text/plain" 117 | case .TextHTML: 118 | return "text/html" 119 | case .Other(let aType): 120 | return aType 121 | } 122 | } 123 | 124 | static func forExtension(ext: String) -> ContentType? { 125 | switch ext.lowercased() { 126 | case "json": 127 | return .TextJSON 128 | case "txt": 129 | return .TextPlain 130 | case "html", "htm": 131 | return .TextHTML 132 | default: 133 | return nil 134 | } 135 | } 136 | 137 | static func forContentType(contentType: String) -> ContentType? { 138 | let components = contentType.components(separatedBy: ";") 139 | guard components.count > 0 else { 140 | return nil 141 | } 142 | 143 | let mimeType = components[0].trimmingCharacters(in: .whitespacesAndNewlines) 144 | switch mimeType.lowercased() { 145 | case "text/json": 146 | return .TextJSON 147 | case "text/plain": 148 | return .TextPlain 149 | case "text/html": 150 | return .TextHTML 151 | default: 152 | return .Other(type: mimeType) 153 | } 154 | } 155 | 156 | } 157 | 158 | public class Route { 159 | 160 | public typealias ThenBlock = () -> () 161 | 162 | private let path: String 163 | private var params = [String: String]() 164 | private var allowOtherParams = false 165 | private var headers = [String: String]() 166 | private var responder: Responder? 167 | private var thenBlock: ThenBlock? 168 | 169 | public init(_ path: String) { 170 | self.path = path 171 | } 172 | 173 | @discardableResult public func param(_ name: String, _ value: String) -> Route { 174 | params[name] = value 175 | return self 176 | } 177 | 178 | @discardableResult public func anyParams() -> Route { 179 | allowOtherParams = true 180 | return self 181 | } 182 | 183 | @discardableResult public func header(_ name: String, _ value: String) -> Route { 184 | headers[name] = value 185 | return self 186 | } 187 | 188 | @discardableResult public func respond(_ responder: Responder) -> Route { 189 | return self 190 | } 191 | 192 | @discardableResult public func status(_ status: ResponseStatus) -> Route { 193 | responder = StatusResponder(status: status) 194 | return self 195 | } 196 | 197 | @discardableResult public func resource(_ url: URL) -> Route { 198 | return self 199 | } 200 | 201 | @discardableResult public func resource(_ resource: String) throws -> Route { 202 | return self 203 | } 204 | 205 | @discardableResult public func resource(bundle: Bundle, resource: String) throws -> Route { 206 | return self 207 | } 208 | 209 | @discardableResult public func content(_ string: String, _ type: ContentType) -> Route { 210 | responder = ContentResponder(string: string, contentType: type, encoding: .utf8) 211 | return self 212 | } 213 | 214 | @discardableResult public func content(_ data: Data, _ type: ContentType) -> Route { 215 | responder = ContentResponder(data: data, contentType: type) 216 | return self 217 | } 218 | 219 | @discardableResult public func block(_ block: @escaping BlockResponder.BlockResponderBlock) -> Route { 220 | responder = BlockResponder(block: block) 221 | return self 222 | } 223 | 224 | @discardableResult public func json(_ value: Any) throws -> Route { 225 | let data = try JSONSerialization.data(withJSONObject: value) 226 | return content(data, .TextJSON) 227 | } 228 | 229 | @discardableResult public func then(_ block: @escaping ThenBlock) -> Route { 230 | self.thenBlock = block 231 | return self 232 | } 233 | 234 | fileprivate func match(request: Request) -> Int? { 235 | guard match(path: request.path) else { 236 | return nil 237 | } 238 | 239 | guard let paramsScore = match(queryString: request.queryString) else { 240 | return nil 241 | } 242 | 243 | return paramsScore 244 | } 245 | 246 | private func match(path: String) -> Bool { 247 | guard let r = path.range(of: self.path, options: [.regularExpression, .anchored]) else { 248 | return false 249 | } 250 | 251 | /* Check anchoring at the end of the string, so our regex is a full match */ 252 | if r.upperBound != path.endIndex { 253 | return false 254 | } 255 | 256 | return true 257 | } 258 | 259 | private func match(queryString: String?) -> Int? { 260 | var score = 0 261 | 262 | if let params = Route.parse(queryString: queryString) { 263 | var remainingToMatch = self.params 264 | 265 | for (key, value) in params { 266 | if let requiredMatch = self.params[key] { 267 | if let r = value.range(of: requiredMatch, options: [.regularExpression, .anchored]) { 268 | /* Check anchoring at the end of the string, so our regex is a full match */ 269 | if r.upperBound != value.endIndex { 270 | return nil 271 | } 272 | 273 | score += 1 274 | remainingToMatch.removeValue(forKey: key) 275 | } else { 276 | return nil 277 | } 278 | } else if !allowOtherParams { 279 | return nil 280 | } 281 | } 282 | 283 | guard remainingToMatch.count == 0 else { 284 | return nil 285 | } 286 | } else if self.params.count != 0 { 287 | return nil 288 | } 289 | 290 | return score 291 | } 292 | 293 | public static func parse(queryString: String?) -> [(String, String)]? { 294 | guard let queryString = queryString else { 295 | return nil 296 | } 297 | 298 | var result = [(String, String)]() 299 | 300 | for pair in queryString.components(separatedBy: "&") { 301 | let pairTuple = pair.components(separatedBy: "=") 302 | if pairTuple.count == 2 { 303 | result.append((pairTuple[0], pairTuple[1])) 304 | } else { 305 | result.append((pairTuple[0], "")) 306 | } 307 | } 308 | 309 | return result 310 | } 311 | 312 | fileprivate func handle(request: Request, resultBlock: @escaping RoutingResultBLock) { 313 | if let responder = responder { 314 | responder.respond(request: request) { (result) in 315 | resultBlock(result) 316 | 317 | if let thenBlock = self.thenBlock { 318 | thenBlock() 319 | } 320 | } 321 | } else { 322 | resultBlock(.response(Response(status: .notFound))) 323 | 324 | if let thenBlock = thenBlock { 325 | thenBlock() 326 | } 327 | } 328 | } 329 | 330 | } 331 | 332 | /// Request methods 333 | public enum RequestMethod: String { 334 | case GET 335 | case HEAD 336 | case POST 337 | case PUT 338 | case DELETE 339 | } 340 | 341 | public let DefaultHTTPVersion = "HTTP/1.1" 342 | 343 | /// Model for an HTTP request 344 | public struct Request { 345 | 346 | public var version: String 347 | public var method: String 348 | public var path: String 349 | public var queryString: String? 350 | public var headers: [(String, String)]? 351 | public var body: Data? 352 | public var contentType: ContentType? { 353 | if let contentType = header("Content-Type") { 354 | return ContentType.forContentType(contentType: contentType) 355 | } else { 356 | return nil 357 | } 358 | } 359 | public var file: String { 360 | if let queryString = queryString { 361 | return "\(path)?\(queryString)" 362 | } else { 363 | return path 364 | } 365 | } 366 | 367 | public init(method: String = RequestMethod.GET.rawValue, version: String = DefaultHTTPVersion, path: String) { 368 | self.method = method 369 | self.path = path 370 | self.version = version 371 | } 372 | 373 | public init(method: String = RequestMethod.GET.rawValue, version: String = DefaultHTTPVersion, path: String, queryString: String?) { 374 | self.method = method 375 | self.path = path 376 | self.version = version 377 | self.queryString = queryString 378 | } 379 | 380 | public init(method: String = RequestMethod.GET.rawValue, version: String = DefaultHTTPVersion, path: String, queryString: String?, headers: [(String, String)]?) { 381 | self.method = method 382 | self.path = path 383 | self.version = version 384 | self.queryString = queryString 385 | self.headers = headers 386 | } 387 | 388 | public func header(_ needle: String) -> String? { 389 | guard let headers = headers else { 390 | return nil 391 | } 392 | 393 | for (key, value) in headers { 394 | if key.lowercased().replacingOccurrences(of: "-", with: "_") == needle.lowercased().replacingOccurrences(of: "-", with: "_") { 395 | return value 396 | } 397 | } 398 | 399 | return nil 400 | } 401 | 402 | } 403 | 404 | /// Model for an HTTP response 405 | public struct Response { 406 | 407 | public var status: ResponseStatus 408 | public var headers: [(String, String)]? 409 | public var data: Data? 410 | public var contentType: ContentType? 411 | 412 | public init(status: ResponseStatus) { 413 | self.status = status 414 | } 415 | 416 | public init(status: ResponseStatus, data: Data?, contentType: ContentType?) { 417 | self.status = status 418 | self.data = data 419 | self.contentType = contentType 420 | } 421 | 422 | public func containsHeader(_ needle: String) -> Bool { 423 | guard let headers = headers else { 424 | return false 425 | } 426 | 427 | for (key, _) in headers { 428 | if key.lowercased() == needle.lowercased() { 429 | return true 430 | } 431 | } 432 | 433 | return false 434 | } 435 | 436 | } 437 | 438 | public enum ResponderError: Error { 439 | case ResourceNotFound(bundle: Bundle, resource: String) 440 | } 441 | 442 | /// Protocol for objects that produce responses to requests 443 | public protocol Responder { 444 | 445 | func respond(request: Request, resultBlock: @escaping RoutingResultBLock) 446 | 447 | } 448 | 449 | /// Responder that produces a response with a given bundle resource 450 | public class ResourceResponder: Responder { 451 | 452 | private let url: URL 453 | 454 | public init(url: URL) { 455 | self.url = url 456 | } 457 | 458 | public init(bundle: Bundle, resource: String) throws { 459 | if let url = bundle.url(forResource: resource, withExtension: nil) { 460 | self.url = url 461 | } else { 462 | throw ResponderError.ResourceNotFound(bundle: bundle, resource: resource) 463 | } 464 | } 465 | 466 | public func respond(request: Request, resultBlock: @escaping RoutingResultBLock) { 467 | do { 468 | let data = try Data.init(contentsOf: url) 469 | resultBlock(.response(Response(status: .ok, data: data, contentType: .TextPlain))) //TODO contentType 470 | } catch { 471 | resultBlock(.error(error)) 472 | } 473 | } 474 | 475 | } 476 | 477 | /// Responder that produces a response with the given content 478 | public class ContentResponder: Responder { 479 | 480 | private let data: Data 481 | private let contentType: ContentType 482 | 483 | public init(string: String, contentType: ContentType, encoding: String.Encoding) { 484 | self.data = string.data(using: encoding)! 485 | self.contentType = contentType 486 | } 487 | 488 | public init(data: Data, contentType: ContentType) { 489 | self.data = data 490 | self.contentType = contentType 491 | } 492 | 493 | public func respond(request: Request, resultBlock: @escaping RoutingResultBLock) { 494 | resultBlock(.response(Response(status: .ok, data: data, contentType: contentType))) 495 | } 496 | 497 | } 498 | 499 | /// Responder that takes a block that itself produces a response given a request 500 | public class BlockResponder: Responder { 501 | 502 | public typealias BlockResponderBlock = (Request, @escaping RoutingResultBLock) -> () 503 | 504 | private let block: BlockResponderBlock 505 | 506 | public init(block: @escaping BlockResponderBlock) { 507 | self.block = block 508 | } 509 | 510 | public func respond(request: Request, resultBlock: @escaping RoutingResultBLock) { 511 | block(request, resultBlock) 512 | } 513 | } 514 | 515 | /// Responder that simply responds with the given response status 516 | public class StatusResponder: Responder { 517 | 518 | private let status: ResponseStatus 519 | 520 | public init(status: ResponseStatus) { 521 | self.status = status 522 | } 523 | 524 | public func respond(request: Request, resultBlock: @escaping RoutingResultBLock) { 525 | resultBlock(.response(Response(status: status))) 526 | } 527 | 528 | } 529 | -------------------------------------------------------------------------------- /Succulent/Classes/Succulent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Succulent.swift 3 | // Succulent 4 | // 5 | // Created by Karl von Randow on 15/01/17. 6 | // Copyright © 2017 Cactuslab. All rights reserved. 7 | // 8 | 9 | import Embassy 10 | import Foundation 11 | 12 | public struct Configuration { 13 | ///Specify the port that Succulent should listen on. Set to nil for Succulent to automatically choose a free port. 14 | public var port: Int? 15 | ///A set of query string parameter names that should be ignored for the purposes of matching incoming requests to recorded requests. 16 | public var ignoreParameters: Set? 17 | ///An array of regular expression strings to match against incoming request paths; matches will not increase the Succulent version even if they use a mutating HTTP method. 18 | public var ignoreVersioningRequests: [String]? 19 | 20 | public init() { 21 | 22 | } 23 | 24 | public init(port: Int? = nil, ignoreParameters: Set? = nil, ignoreVersioningRequests: [String]?) { 25 | self.port = port 26 | self.ignoreParameters = ignoreParameters 27 | self.ignoreVersioningRequests = ignoreVersioningRequests 28 | } 29 | } 30 | 31 | public class Succulent : NSObject, URLSessionTaskDelegate { 32 | 33 | public private(set) var port: Int? 34 | public var version = 0 35 | public private(set) var baseUrl: URL? 36 | public private(set) var recordUrl: URL? { 37 | didSet { 38 | if let recordUrl = recordUrl { 39 | //Throw away the previous trace 40 | try? FileManager.default.removeItem(at: recordUrl) 41 | } 42 | } 43 | } 44 | public private(set) var ignoreParameters: Set? 45 | 46 | public let router = Router() 47 | 48 | private var loop: EventLoop! 49 | private var server: DefaultHTTPServer! 50 | 51 | private var loopThreadCondition: NSCondition! 52 | private var loopThread: Thread! 53 | 54 | private var lastWasMutation = false 55 | 56 | private lazy var session : URLSession = { 57 | return URLSession(configuration: .default, delegate: self, delegateQueue: nil) 58 | }() 59 | 60 | public var actualPort: Int { 61 | return server.listenAddress.port 62 | } 63 | 64 | private let queryPathSplitterRegex = try! NSRegularExpression(pattern: "^([^\\?]+)\\??(.*)?$", options: []) 65 | 66 | private var traces: [String : Trace]? 67 | private var currentTrace = NSMutableOrderedSet() 68 | private var recordedKeys = Set() 69 | private var ignoreExpressions: [NSRegularExpression] = [] 70 | 71 | ///Initialise Succulent in replay mode, with an optional trace file to replay from, a pass-through URL to use if a match if not found in the trace file, and configuration. 72 | public convenience init(replayFrom traceUrl: URL?, passThroughBaseUrl baseUrl: URL? = nil, configuration: Configuration? = nil) { 73 | 74 | self.init(configuration: configuration) 75 | self.baseUrl = baseUrl 76 | 77 | if let traceUrl = traceUrl { 78 | addTrace(url: traceUrl) 79 | } 80 | } 81 | 82 | public convenience init(recordTo recordUrl: URL, baseUrl: URL, configuration: Configuration? = nil) { 83 | self.init(configuration: configuration) 84 | 85 | defer { 86 | /* Defer so that the didSet runs on recordUrl */ 87 | self.recordUrl = recordUrl 88 | self.baseUrl = baseUrl 89 | } 90 | } 91 | 92 | ///Initialise Succulent in recording mode, with a URL to record the trace to and the base URL for the upstream server that we're recording. 93 | private init(configuration: Configuration?) { 94 | super.init() 95 | if let configuration = configuration { 96 | if let ignoreVersioningRequests = configuration.ignoreVersioningRequests { 97 | ignoreExpressions = ignoreVersioningRequests.map { (expression) -> NSRegularExpression in 98 | return try! NSRegularExpression(pattern: expression, options: []) 99 | } 100 | } 101 | ignoreParameters = configuration.ignoreParameters 102 | port = configuration.port 103 | } 104 | createDefaultRouter() 105 | } 106 | 107 | private func createDefaultRouter() { 108 | router.add(".*").anyParams().block { (req, resultBlock) in 109 | /* Increment version when we get the first GET after a mutating http method */ 110 | if req.method != "GET" && req.method != "HEAD" { 111 | 112 | // Check if we are to ignore the mutation 113 | var shouldIgnore = false 114 | for expression in self.ignoreExpressions { 115 | if let _ = expression.firstMatch(in: req.path, options: [], range: req.path.nsrange) { 116 | shouldIgnore = true 117 | } 118 | } 119 | if !shouldIgnore { 120 | self.lastWasMutation = true 121 | self.version += 1 122 | } 123 | } else if self.lastWasMutation { 124 | self.lastWasMutation = false 125 | } 126 | 127 | if let trace = self.trace(for: req.path, queryString: req.queryString, method: req.method) { 128 | 129 | var status = ResponseStatus.ok 130 | var headers: [(String, String)]? 131 | 132 | if let headerData = trace.responseHeader { 133 | let (aStatus, aHeaders) = self.parseHeaderData(data: headerData) 134 | status = aStatus 135 | headers = aHeaders 136 | } 137 | 138 | if headers == nil { 139 | let contentType = self.contentType(for: req.path) 140 | headers = [("Content-Type", contentType)] 141 | } 142 | 143 | var res = Response(status: status) 144 | res.headers = headers 145 | 146 | res.data = trace.responseBody 147 | resultBlock(.response(res)) 148 | } else if let baseUrl = self.baseUrl { 149 | // Using a '.' prefix here preserves the path components of the baseUrl if there are any 150 | let url = URL(string: ".\(req.file)", relativeTo: baseUrl)! 151 | 152 | print("Pass-through URL: \(url.absoluteURL)") 153 | var urlRequest = URLRequest(url: url) 154 | req.headers?.forEach({ (key, value) in 155 | let fixedKey = key.replacingOccurrences(of: "_", with: "-").capitalized 156 | 157 | if !Succulent.dontPassThroughHeaders.contains(fixedKey.lowercased()) { 158 | urlRequest.addValue(value, forHTTPHeaderField: fixedKey) 159 | } 160 | }) 161 | urlRequest.httpMethod = req.method 162 | urlRequest.httpShouldHandleCookies = false 163 | urlRequest.cachePolicy = .reloadIgnoringLocalCacheData 164 | 165 | let completionHandler = { (data: Data?, response: URLResponse?, error: Error?) in 166 | // TODO handle nil response, occurs when the request fails, so we need to generate a synthetic error response 167 | let response = response as! HTTPURLResponse 168 | let statusCode = response.statusCode 169 | 170 | var res = Response(status: .other(code: statusCode)) 171 | 172 | var headers = [(String, String)]() 173 | for header in response.allHeaderFields { 174 | let key = (header.key as! String) 175 | if Succulent.dontPassBackHeaders.contains(key.lowercased()) { 176 | continue 177 | } 178 | let value = header.value as! String 179 | 180 | if key.lowercased() == "set-cookie" { 181 | let values = Succulent.splitSetCookie(value: value) 182 | for value in values { 183 | let mungedValue = Succulent.munge(key: key, value: value) 184 | headers.append((key, mungedValue)) 185 | } 186 | } else { 187 | headers.append((key, value)) 188 | } 189 | } 190 | res.headers = headers 191 | 192 | try! self.recordTrace(request: req, data: data, response: response) 193 | 194 | res.data = data 195 | 196 | resultBlock(.response(res)) 197 | } 198 | 199 | if let body = req.body { 200 | let uploadTask = self.session.uploadTask(with: urlRequest, from: body, completionHandler: completionHandler) 201 | uploadTask.resume() 202 | } else { 203 | let dataTask = self.session.dataTask(with: urlRequest, completionHandler: completionHandler) 204 | dataTask.resume() 205 | } 206 | } else { 207 | resultBlock(.response(Response(status: .notFound))) 208 | } 209 | } 210 | } 211 | 212 | /// Load the trace file at the given URL and populate our traces ivar 213 | private func addTrace(url: URL) { 214 | if traces == nil { 215 | traces = [String : Trace]() 216 | } 217 | 218 | 219 | 220 | let traceReader = TraceReader(fileURL: url) 221 | if let orderedTraces = traceReader.readFile() { 222 | var version = 0 223 | var lastWasMutation = false 224 | for trace in orderedTraces { 225 | if let file = trace.meta.file, let method = trace.meta.method { 226 | let matches = queryPathSplitterRegex.matches(in: file, options: [], range: file.nsrange) 227 | let path = file.substring(with: matches[0].range(at: 1))! 228 | let query = file.substring(with: matches[0].range(at: 2)) 229 | 230 | if method != "GET" && method != "HEAD" { 231 | // Check if we are to ignore the mutation 232 | var shouldIgnore = false 233 | for expression in ignoreExpressions { 234 | if let _ = expression.firstMatch(in: file, options: [], range: file.nsrange) { 235 | shouldIgnore = true 236 | } 237 | } 238 | if !shouldIgnore { 239 | version += 1 240 | lastWasMutation = true 241 | } 242 | } else if lastWasMutation { 243 | lastWasMutation = false 244 | } 245 | 246 | let key = mockPath(for: path, queryString: query, method: method, version: version) 247 | 248 | traces![key] = trace 249 | } 250 | } 251 | } 252 | } 253 | 254 | /** HttpURLRequest combines multiple set-cookies into one string separated by commas. 255 | We can't just split on comma as the expires also contains a comma, so we work around it. 256 | */ 257 | public static func splitSetCookie(value: String) -> [String] { 258 | let regex = try! NSRegularExpression(pattern: "(expires\\s*=\\s*[a-z]+),", options: .caseInsensitive) 259 | let apologies = regex.stringByReplacingMatches(in: value, options: [], range: NSMakeRange(0, value.count), withTemplate: "$1!!!OMG!!!") 260 | 261 | let split = apologies.components(separatedBy: ",") 262 | 263 | return split.map { (value) -> String in 264 | return value.replacingOccurrences(of: "!!!OMG!!!", with: ",").trimmingCharacters(in: .whitespaces) 265 | } 266 | } 267 | 268 | public static func munge(key: String, value: String) -> String { 269 | if key.lowercased() == "set-cookie" { 270 | let regex = try! NSRegularExpression(pattern: "(domain\\s*=\\s*)[^;]*(;?\\s*)", options: .caseInsensitive) 271 | return regex.stringByReplacingMatches(in: value, options: [], range: NSMakeRange(0, value.count), withTemplate: "$1localhost$2") 272 | } 273 | 274 | return value 275 | } 276 | 277 | private func parseHeaderData(data: Data) -> (ResponseStatus, [(String, String)]) { 278 | let lines = String(data: data, encoding: .utf8)!.components(separatedBy: CharacterSet.newlines) 279 | 280 | let statusLineComponents = lines[0].components(separatedBy: CharacterSet.whitespaces) 281 | let statusCode = ResponseStatus.other(code: Int(statusLineComponents[1])!) 282 | var headers = [(String, String)]() 283 | 284 | for line in lines.dropFirst() { 285 | if let r = line.range(of: ": ") { 286 | let key = String(line[.. = ["content-encoding", "content-length", "connection", "keep-alive"] 300 | private static let dontPassThroughHeaders: Set = ["accept-encoding", "content-length", "connection", "accept-language", "host"] 301 | 302 | private func createRequest(environ: [String: Any], completion: @escaping (Request)->()) { 303 | let method = environ["REQUEST_METHOD"] as! String 304 | let path = environ["PATH_INFO"] as! String 305 | let version = environ["SERVER_PROTOCOL"] as! String 306 | 307 | var req = Request(method: method, version: version, path: path) 308 | req.queryString = environ["QUERY_STRING"] as? String 309 | 310 | var headers = [(String, String)]() 311 | for pair in environ { 312 | if pair.key.hasPrefix("HTTP_"), let value = pair.value as? String { 313 | let key = String(pair.key[pair.key.index(pair.key.startIndex, offsetBy: 5)...]) 314 | //pair.key.substring(from: pair.key.index(pair.key.startIndex, offsetBy: 5)) 315 | headers.append((key, value)) 316 | } 317 | } 318 | req.headers = headers 319 | 320 | var body: Data? 321 | 322 | /* We workaround what I think is a fault in Embassy. If the request has no body, then the input 323 | block is never called with the empty data to signify EOF. So we need to detect whether or not 324 | there should be a body. 325 | */ 326 | if method == "GET" || method == "HEAD" { 327 | completion(req) 328 | } else { 329 | if let contentLengthString = req.header("Content-Length"), Int(contentLengthString) == 0 { 330 | completion(req) 331 | } else { 332 | let input = environ["swsgi.input"] as! SWSGIInput 333 | input { data in 334 | if data.count > 0 { 335 | if body == nil { 336 | body = Data() 337 | } 338 | body!.append(data) 339 | } else { 340 | req.body = body 341 | completion(req) 342 | } 343 | } 344 | } 345 | } 346 | } 347 | 348 | public func start() { 349 | loop = try! SelectorEventLoop(selector: try! KqueueSelector()) 350 | 351 | let app: SWSGI = { 352 | ( 353 | environ: [String: Any], 354 | startResponse: @escaping ((String, [(String, String)]) -> Void), 355 | sendBody: @escaping ((Data) -> Void) 356 | ) in 357 | 358 | self.createRequest(environ: environ) { req in 359 | self.router.handle(request: req) { result in 360 | self.loop.call { 361 | switch result { 362 | case .response(let res): 363 | startResponse("\(res.status)", res.headers ?? []) 364 | 365 | if let data = res.data { 366 | sendBody(data) 367 | } 368 | sendBody(Data()) 369 | 370 | case .error(let error): 371 | startResponse(ResponseStatus.internalServerError.description, [ ("Content-Type", "text/plain") ]) 372 | sendBody("An error occurred: \(error)".data(using: .utf8)!) 373 | sendBody(Data()) 374 | 375 | case .noRoute: 376 | startResponse(ResponseStatus.notFound.description, []) 377 | sendBody(Data()) 378 | 379 | } 380 | } 381 | } 382 | } 383 | 384 | } 385 | 386 | server = DefaultHTTPServer(eventLoop: loop, interface: "127.0.0.1", port: port ?? 0, app: app) 387 | 388 | try! server.start() 389 | 390 | loopThreadCondition = NSCondition() 391 | loopThread = Thread(target: self, selector: #selector(runEventLoop), object: nil) 392 | loopThread.start() 393 | } 394 | 395 | private func recordTrace(request: Request, data: Data?, response: HTTPURLResponse) throws { 396 | guard let recordUrl = self.recordUrl else { 397 | return 398 | } 399 | 400 | let traceUrl = recordUrl 401 | 402 | let key = mockPath(for: request.path, queryString: request.queryString, method: request.method, version: version) 403 | guard !recordedKeys.contains(key) else { 404 | return 405 | } 406 | 407 | //Record Metadata 408 | var path = request.path 409 | if let query = request.queryString { 410 | path.append("?\(sanitize(queryString: query))") 411 | } 412 | let traceMeta = TraceMeta(method: request.method, protocolScheme: self.baseUrl?.scheme, host: self.baseUrl?.host, file: path, version: "HTTP/1.1") 413 | 414 | let tracer = TraceWriter(fileURL: traceUrl) 415 | let token = NSUUID().uuidString 416 | 417 | try tracer.writeComponent(component: .meta, content: traceMeta, token: token) 418 | 419 | try tracer.writeComponent(component: .responseHeader, content: response, token: token) 420 | if let data = data { 421 | try tracer.writeComponent(component: .responseBody, content: data, token: token) 422 | } 423 | 424 | recordedKeys.insert(key) 425 | } 426 | 427 | private func sanitize(pathForURL path: String) -> String { 428 | return path.replacingOccurrences(of: "?", with: "%3F") 429 | // .replacingOccurrences(of: "&", with: "%26") 430 | } 431 | 432 | private func headerData(response: HTTPURLResponse) -> Data? { 433 | var string = "\(response.statusCode)\r\n" 434 | 435 | for header in response.allHeaderFields { 436 | let key = header.key as! String 437 | 438 | if Succulent.dontPassBackHeaders.contains(key.lowercased()) { 439 | continue 440 | } 441 | 442 | string += "\(key): \(header.value)\r\n" 443 | } 444 | return string.data(using: .utf8) 445 | } 446 | 447 | private func trace(for path: String, queryString: String?, method: String, replaceExtension: String? = nil) -> Trace? { 448 | guard let traces = traces else { 449 | return nil 450 | } 451 | 452 | var searchVersion = version 453 | while searchVersion >= 0 { 454 | let resource = mockPath(for: path, queryString: queryString, method: method, version: searchVersion, replaceExtension: replaceExtension) 455 | 456 | if let trace = traces[resource] { 457 | return trace 458 | } 459 | 460 | searchVersion -= 1 461 | } 462 | 463 | return nil 464 | 465 | } 466 | 467 | private func mockPath(for path: String, queryString: String?, method: String, version: Int, replaceExtension: String? = nil) -> String { 468 | let withoutExtension = (path as NSString).deletingPathExtension 469 | 470 | let ext = replaceExtension != nil ? replaceExtension! : (path as NSString).pathExtension 471 | let methodSuffix = (method == "GET") ? "" : "-\(method)" 472 | var querySuffix: String 473 | if let queryString = queryString, queryString.count > 0 { 474 | let sanitizedQueryString = sanitize(queryString: queryString) 475 | querySuffix = "?\(sanitizedQueryString)" 476 | } else { 477 | querySuffix = "?" 478 | } 479 | 480 | return ("/\(withoutExtension)-\(version)\(methodSuffix)" as NSString).appendingPathExtension(ext)!.appending(querySuffix) 481 | } 482 | 483 | private func sanitize(queryString: String) -> String { 484 | let ignoreParameters = self.ignoreParameters ?? Set() 485 | 486 | let params = Route.parse(queryString: queryString)?.enumerated() 487 | var result = "" 488 | 489 | params?.sorted(by: { (first, second) in 490 | if first.element.0 != second.element.0 { 491 | return first.element.0 < second.element.0 492 | } else { 493 | return first.offset < second.offset 494 | } 495 | }).map({ (obj) in 496 | return obj.element 497 | }).forEach({ (key, value) in 498 | if !ignoreParameters.contains(key) { 499 | if result.endIndex > result.startIndex { 500 | result += "&" 501 | } 502 | result += "\(key)=\(value)" 503 | } 504 | }) 505 | return result 506 | } 507 | 508 | private func contentType(for path: String) -> String { 509 | var path = path 510 | if let r = path.range(of: "?", options: .backwards) { 511 | path = String(path[.. Void) { 545 | completionHandler(nil) 546 | } 547 | } 548 | 549 | -------------------------------------------------------------------------------- /Succulent/Classes/Tracer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tracer.swift 3 | // Succulent 4 | // 5 | // Created by Thomas Carey on 6/03/17. 6 | // Copyright © 2017 Cactuslab. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Provides support for reading and writing the trace format from Charles Proxy 12 | 13 | /// Conform to this protocol if you output to the trace format 14 | protocol Traceable { 15 | var traceFormat: String { get } 16 | } 17 | 18 | struct TraceMeta: Traceable { 19 | 20 | var method: String? 21 | var protocolScheme: String? 22 | var host: String? 23 | var file: String? 24 | var version: String? 25 | 26 | var traceFormat: String { 27 | var trace = [String]() 28 | if let method = method { 29 | trace.append("Method: \(method)") 30 | } 31 | if let version = version { 32 | trace.append("Protocol-Version: \(version)") 33 | } 34 | if let protocolScheme = protocolScheme { 35 | trace.append("Protocol: \(protocolScheme)") 36 | } 37 | if let host = host { 38 | trace.append("Host: \(host)") 39 | } 40 | if let file = file { 41 | trace.append("File: \(file)") 42 | } 43 | 44 | return "\n\(trace.joined(separator: "\n"))" 45 | } 46 | 47 | init(method: String?, protocolScheme: String?, host: String?, file: String?, version: String?) { 48 | self.method = method 49 | self.protocolScheme = protocolScheme 50 | self.host = host 51 | self.file = file 52 | self.version = version 53 | } 54 | 55 | init(dictionary: [String: String]) { 56 | self = TraceMeta(method: dictionary["Method"], protocolScheme: dictionary["Protocol"], host: dictionary["Host"], file: dictionary["File"], version: dictionary["Protocol-Version"]) 57 | } 58 | } 59 | 60 | extension Request: Traceable { 61 | 62 | var traceFormat: String { 63 | var trace = [String]() 64 | trace.append("\(method) \(path)") 65 | self.headers?.forEach({ (key, value) in 66 | trace.append("\(key): \(value)") 67 | }) 68 | 69 | return trace.joined(separator: "\n") 70 | } 71 | 72 | } 73 | 74 | extension HTTPURLResponse: Traceable { 75 | 76 | var traceFormat: String { 77 | var trace = [String]() 78 | 79 | trace.append("HTTP/1.1 \(statusCode)") 80 | self.allHeaderFields.forEach { (key, value) in 81 | trace.append("\(key): \(value)") 82 | } 83 | 84 | return trace.joined(separator: "\n").appending("\n") 85 | } 86 | } 87 | 88 | class TraceWriter { 89 | enum Component { 90 | case meta 91 | case requestHeader 92 | case requestBody 93 | case responseHeader 94 | case responseBody 95 | 96 | var name: String? { 97 | switch self { 98 | case .meta: return nil 99 | case .requestBody: return "Request-Body" 100 | case .requestHeader: return "Request-Header" 101 | case .responseBody: return "Response-Body" 102 | case .responseHeader: return "Response-Header" 103 | } 104 | } 105 | 106 | func headerPart(token: String) -> String { 107 | if let name = self.name { 108 | return "\(name):<<--EOF-\(token)-\n" 109 | } else { 110 | return "\n" 111 | } 112 | } 113 | 114 | func footerPart(token: String) -> String { 115 | if let _ = self.name { 116 | return "\n--EOF-\(token)-\n" 117 | } else { 118 | return "\n" 119 | } 120 | } 121 | } 122 | 123 | let fileURL: URL 124 | 125 | init(fileURL: URL) { 126 | self.fileURL = fileURL 127 | 128 | writeHeader() 129 | } 130 | 131 | private func writeHeader() { 132 | let headerString = "HTTP-Trace-Version: 1.0\nGenerator: Succulent/1.0\n" 133 | if let fileHandle = FileHandle(forWritingAtPath: fileURL.path) { 134 | defer { 135 | fileHandle.closeFile() 136 | } 137 | fileHandle.seekToEndOfFile() 138 | if fileHandle.offsetInFile == 0 { 139 | fileHandle.write(headerString.data(using: .utf8)!) 140 | } 141 | } else { 142 | try! headerString.appendToURL(fileURL: fileURL) 143 | } 144 | } 145 | 146 | func writeComponent(component: Component, content: Data, token: String) throws { 147 | try component.headerPart(token: token).appendToURL(fileURL: fileURL) 148 | try content.append(fileURL: fileURL) 149 | try component.footerPart(token: token).appendToURL(fileURL: fileURL) 150 | } 151 | 152 | func writeComponent(component: Component, content: Traceable, token: String) throws { 153 | let parts = [component.headerPart(token: token), content.traceFormat, component.footerPart(token: token)] 154 | try parts.joined(separator: "").appendToURL(fileURL: fileURL) 155 | } 156 | } 157 | 158 | extension String { 159 | fileprivate func appendLineToURL(fileURL: URL) throws { 160 | try (self + "\n").appendToURL(fileURL: fileURL) 161 | } 162 | 163 | fileprivate func appendToURL(fileURL: URL) throws { 164 | let data = self.data(using: String.Encoding.utf8)! 165 | try data.append(fileURL: fileURL) 166 | } 167 | } 168 | 169 | extension Data { 170 | fileprivate func append(fileURL: URL) throws { 171 | if let fileHandle = FileHandle(forWritingAtPath: fileURL.path) { 172 | defer { 173 | fileHandle.closeFile() 174 | } 175 | fileHandle.seekToEndOfFile() 176 | fileHandle.write(self) 177 | } 178 | else { 179 | let directoryURL = fileURL.deletingLastPathComponent() 180 | try? FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) 181 | try write(to: fileURL, options: .atomic) 182 | } 183 | } 184 | } 185 | 186 | struct Trace { 187 | var meta : TraceMeta 188 | var responseHeader : Data? 189 | var responseBody: Data 190 | } 191 | 192 | class TraceReader { 193 | 194 | let fileURL: URL 195 | let delimiter = "\n" 196 | 197 | let tokenStartRegex = try! NSRegularExpression(pattern: "^(.+):<<--EOF-(.+)-$", options: []) 198 | let metaRegex = try! NSRegularExpression(pattern: "^(.+): (.+)$", options: []) 199 | 200 | init(fileURL: URL) { 201 | self.fileURL = fileURL 202 | } 203 | 204 | func readFile() -> [Trace]? { 205 | guard let fileHandle = FileHandle(forReadingAtPath: fileURL.path) else { 206 | return nil 207 | } 208 | defer { 209 | fileHandle.closeFile() 210 | } 211 | 212 | if consumeHeader(fileHandle: fileHandle) { 213 | var traces = [Trace]() 214 | while let trace = consumeTrace(fileHandle: fileHandle) { 215 | traces.append(trace) 216 | } 217 | 218 | return traces 219 | } 220 | 221 | return nil 222 | } 223 | 224 | private func consumeHeader(fileHandle: FileHandle) -> Bool { 225 | let offset = fileHandle.offsetInFile 226 | 227 | guard let data = fileHandle.readLine(withDelimiter: "\n") else { 228 | fileHandle.seek(toFileOffset: offset) 229 | return false 230 | } 231 | 232 | guard let contents = String(data: data, encoding: .ascii), contents == "HTTP-Trace-Version: 1.0\n" else { 233 | fileHandle.seek(toFileOffset: offset) 234 | return false 235 | } 236 | 237 | // lets just consume the next line 238 | let _ = fileHandle.readLine(withDelimiter: "\n") 239 | let _ = fileHandle.consumeEmptyLines() 240 | 241 | return true 242 | } 243 | 244 | enum ComponentType : String { 245 | case responseHeader = "Response-Header" 246 | case responseBody = "Response-Body" 247 | } 248 | 249 | struct Component { 250 | var type: ComponentType 251 | var data: Data 252 | } 253 | 254 | private func consumeTrace(fileHandle: FileHandle) -> Trace? { 255 | 256 | while(testForEmptyLine(fileHandle: fileHandle)) { 257 | if !consumeLines(count: 1, fileHandle: fileHandle) { 258 | return nil 259 | } 260 | } 261 | 262 | var meta = [String: String]() 263 | 264 | // Consume the Metas 265 | while(!testStartOfComponent(fileHandle: fileHandle)) { 266 | if let data = fileHandle.readLine(withDelimiter: delimiter) { 267 | if let line = String(data: data, encoding: .utf8) { 268 | 269 | let matches = metaRegex.matches(in: line, options: [], range: line.nsrange) 270 | matches.forEach({ (match) in 271 | let key = line.substring(with: match.range(at: 1))! 272 | let value = line.substring(with: match.range(at: 2))! 273 | 274 | meta[key] = value 275 | }) 276 | } 277 | } else { 278 | // At the end of the file 279 | return nil 280 | } 281 | 282 | } 283 | 284 | var responseHeader : Data? 285 | var responseBody : Data? 286 | 287 | // Consume components until no longer at the start 288 | while(testStartOfComponent(fileHandle: fileHandle)) { 289 | if let component = consumeComponent(fileHandle: fileHandle) { 290 | switch component.type { 291 | case .responseBody: 292 | responseBody = component.data 293 | case .responseHeader: 294 | responseHeader = component.data 295 | } 296 | } 297 | } 298 | 299 | if let responseBody = responseBody { 300 | return Trace(meta: TraceMeta(dictionary: meta), responseHeader: responseHeader, responseBody: responseBody) 301 | } 302 | return nil 303 | } 304 | 305 | private func testStartOfComponent(fileHandle: FileHandle) -> Bool { 306 | let startingOffset = fileHandle.offsetInFile 307 | defer { 308 | fileHandle.seek(toFileOffset: startingOffset) 309 | } 310 | guard let data = fileHandle.readLine(withDelimiter: delimiter), let line = String(data: data, encoding: .utf8) else { 311 | return false 312 | } 313 | let matches = tokenStartRegex.matches(in: line, options: [], range: line.nsrange) 314 | return matches.count > 0 315 | } 316 | 317 | private func consumeComponent(fileHandle: FileHandle) -> Component? { 318 | let startingOffset = fileHandle.offsetInFile 319 | guard let data = fileHandle.readLine(withDelimiter: delimiter), let line = String(data: data, encoding: .utf8) else { 320 | fileHandle.seek(toFileOffset: startingOffset) 321 | return nil 322 | } 323 | 324 | let matches = tokenStartRegex.matches(in: line, options: [], range: line.nsrange) 325 | if matches.count > 0 { 326 | let contentType = line.substring(with: matches[0].range(at: 1))! 327 | let token = line.substring(with: matches[0].range(at: 2))! 328 | 329 | guard let data = consumeData(forToken: token, fileHandle: fileHandle) else { 330 | //Hit the end of the file 331 | return nil 332 | } 333 | 334 | guard let componentType = ComponentType(rawValue: contentType) else { 335 | //Unrecognised component 336 | //discard the component and move on 337 | return nil 338 | } 339 | 340 | return Component(type: componentType, data: data) 341 | } else { 342 | // not the start of content but could be the start of meta 343 | fileHandle.seek(toFileOffset: startingOffset) 344 | return nil 345 | } 346 | } 347 | 348 | private func testForEmptyLine(fileHandle: FileHandle) -> Bool { 349 | return testFor("", fileHandle: fileHandle) 350 | } 351 | 352 | private func testForToken(fileHandle: FileHandle, token:String) -> Bool { 353 | return testFor("--EOF-\(token)-", fileHandle: fileHandle) 354 | } 355 | 356 | private func testFor(_ str :String, fileHandle: FileHandle) -> Bool { 357 | let startingOffset = fileHandle.offsetInFile 358 | defer { 359 | fileHandle.seek(toFileOffset: startingOffset) 360 | } 361 | if let data = fileHandle.readLine(withDelimiter: delimiter) { 362 | if let line = String(data: data, encoding: .utf8) { 363 | if line == "\(str)\(delimiter)" { 364 | return true 365 | } 366 | } 367 | } 368 | return false 369 | } 370 | 371 | private func consumeToken(fileHandle: FileHandle) -> Bool { 372 | return consumeLines(count: 1, fileHandle: fileHandle) 373 | } 374 | 375 | private func consumeLines(count: Int, fileHandle: FileHandle) -> Bool { 376 | for _ in (0.. Data? { 385 | let startingOffset = fileHandle.offsetInFile 386 | 387 | while (!testForToken(fileHandle: fileHandle, token: token)) { 388 | if(!consumeLines(count: 1, fileHandle: fileHandle)) { 389 | return nil 390 | } 391 | } 392 | 393 | let endingOffset = fileHandle.offsetInFile 394 | fileHandle.seek(toFileOffset: startingOffset) 395 | 396 | let data = fileHandle.readData(ofLength: Int(endingOffset - UInt64(fileHandle.delimiterLength(delimiter)) - startingOffset)) 397 | 398 | fileHandle.seek(toFileOffset: endingOffset) 399 | let _ = consumeToken(fileHandle: fileHandle) 400 | 401 | return data 402 | } 403 | 404 | } 405 | 406 | extension FileHandle { 407 | 408 | fileprivate func consumeEmptyLines() -> Int { 409 | var offset = offsetInFile 410 | var data = self.readLine(withDelimiter: "\n") 411 | var counter = 0 412 | 413 | while (data != nil) { 414 | if let contents = String(data: data!, encoding: .ascii), contents == "\n" { 415 | counter += 1 416 | offset = offsetInFile 417 | data = self.readLine(withDelimiter: "\n") 418 | } else { 419 | seek(toFileOffset: offset) 420 | return counter 421 | } 422 | } 423 | 424 | return counter 425 | } 426 | } 427 | 428 | extension String { 429 | /// An `NSRange` that represents the full range of the string. 430 | var nsrange: NSRange { 431 | return NSRange(location: 0, length: utf16.count) 432 | } 433 | 434 | /// Returns a substring with the given `NSRange`, 435 | /// or `nil` if the range can't be converted. 436 | func substring(with nsrange: NSRange) -> String? { 437 | guard let range = Range(nsrange) 438 | else { return nil } 439 | 440 | let start = String.Index(utf16Offset: range.lowerBound, in: self) 441 | let end = String.Index(utf16Offset: range.upperBound, in: self) 442 | return String(utf16[start.. Range? { 448 | guard let range = Range(nsrange) else { return nil } 449 | let utf16Start = String.Index(utf16Offset: range.lowerBound, in: self) 450 | let utf16End = String.Index(utf16Offset: range.upperBound, in: self) 451 | 452 | guard let start = Index(utf16Start, within: self), 453 | let end = Index(utf16End, within: self) 454 | else { return nil } 455 | 456 | return start..