diff --git a/Networking/Networking.xcodeproj/project.pbxproj b/Networking/Networking.xcodeproj/project.pbxproj index 7714f414782..025ff57fe9b 100644 --- a/Networking/Networking.xcodeproj/project.pbxproj +++ b/Networking/Networking.xcodeproj/project.pbxproj @@ -506,10 +506,8 @@ B557DA1A20979D66005962F4 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B557DA1920979D66005962F4 /* Settings.swift */; }; B557DA1D20979E7D005962F4 /* Order.swift in Sources */ = {isa = PBXBuildFile; fileRef = B557DA1C20979E7D005962F4 /* Order.swift */; }; B559EBAA20A0B5CD00836CD4 /* orders-load-all.json in Resources */ = {isa = PBXBuildFile; fileRef = B559EBA920A0B5CD00836CD4 /* orders-load-all.json */; }; - B567AF2520A0CCA300AB6C62 /* AuthenticatedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B567AF2420A0CCA300AB6C62 /* AuthenticatedRequest.swift */; }; B567AF2920A0FA1E00AB6C62 /* Mapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B567AF2820A0FA1E00AB6C62 /* Mapper.swift */; }; B567AF2B20A0FA4200AB6C62 /* OrderListMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B567AF2A20A0FA4200AB6C62 /* OrderListMapper.swift */; }; - B567AF2F20A0FB8F00AB6C62 /* AuthenticatedRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B567AF2C20A0FB8F00AB6C62 /* AuthenticatedRequestTests.swift */; }; B567AF3020A0FB8F00AB6C62 /* DotcomRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B567AF2D20A0FB8F00AB6C62 /* DotcomRequestTests.swift */; }; B567AF3120A0FB8F00AB6C62 /* JetpackRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B567AF2E20A0FB8F00AB6C62 /* JetpackRequestTests.swift */; }; B56C1EB620EA757B00D749F9 /* SiteListMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56C1EB520EA757B00D749F9 /* SiteListMapper.swift */; }; @@ -724,7 +722,7 @@ DEC51AFB2769C66B009F3DF4 /* SystemStatusMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AFA2769C66B009F3DF4 /* SystemStatusMapperTests.swift */; }; DEC51B02276AFB35009F3DF4 /* SystemStatus+DropinMustUsePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51B01276AFB34009F3DF4 /* SystemStatus+DropinMustUsePlugin.swift */; }; DEFBA74E29485A7600C35BA9 /* RESTRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFBA74D29485A7600C35BA9 /* RESTRequest.swift */; }; - DEFBA7542949CE6600C35BA9 /* RequestAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFBA7532949CE6600C35BA9 /* RequestAuthenticator.swift */; }; + DEFBA7542949CE6600C35BA9 /* RequestProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFBA7532949CE6600C35BA9 /* RequestProcessor.swift */; }; DEFBA7562949D17400C35BA9 /* RequestAuthenticatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFBA7552949D17300C35BA9 /* RequestAuthenticatorTests.swift */; }; E12552C526385B05001CEE70 /* ShippingLabelAddressValidationSuccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12552C426385B05001CEE70 /* ShippingLabelAddressValidationSuccess.swift */; }; E137619929151C7400FD098F /* error-wp-rest-forbidden.json in Resources */ = {isa = PBXBuildFile; fileRef = E137619829151C7400FD098F /* error-wp-rest-forbidden.json */; }; @@ -745,6 +743,9 @@ EE338A0E294AF9BD00183934 /* ApplicationPasswordMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE338A0D294AF9BD00183934 /* ApplicationPasswordMapperTests.swift */; }; EE54C89F2947782E00A9BF61 /* ApplicationPasswordUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE54C89E2947782E00A9BF61 /* ApplicationPasswordUseCase.swift */; }; EE54C8A729486B6800A9BF61 /* ApplicationPasswordMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE54C8A629486B6800A9BF61 /* ApplicationPasswordMapper.swift */; }; + EE62EE61295ACF8D009C965B /* RequestConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE62EE60295ACF8D009C965B /* RequestConverterTests.swift */; }; + EE62EE63295AD45E009C965B /* String+URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE62EE62295AD45E009C965B /* String+URL.swift */; }; + EE62EE65295AD46D009C965B /* String+URLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE62EE64295AD46D009C965B /* String+URLTests.swift */; }; EE71CC3D2951A8EA0074D908 /* ApplicationPasswordStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE71CC3C2951A8EA0074D908 /* ApplicationPasswordStorage.swift */; }; EE71CC412951CE700074D908 /* generate-application-password-using-wporg-creds-success.json in Resources */ = {isa = PBXBuildFile; fileRef = EE71CC402951CE700074D908 /* generate-application-password-using-wporg-creds-success.json */; }; EE80A24729547F8B003591E4 /* coupons-all-without-data.json in Resources */ = {isa = PBXBuildFile; fileRef = EE80A24529547F8B003591E4 /* coupons-all-without-data.json */; }; @@ -752,7 +753,13 @@ EE80A25029556FBD003591E4 /* coupon-reports-without-data.json in Resources */ = {isa = PBXBuildFile; fileRef = EE80A24F29556FBD003591E4 /* coupon-reports-without-data.json */; }; EE8A86F1286C5226003E8AA4 /* media-update-product-id-in-wordpress-site.json in Resources */ = {isa = PBXBuildFile; fileRef = EE8A86F0286C5226003E8AA4 /* media-update-product-id-in-wordpress-site.json */; }; EE8DE432294B17CD005054E7 /* DefaultApplicationPasswordUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE8DE431294B17CD005054E7 /* DefaultApplicationPasswordUseCaseTests.swift */; }; + EE99814E295AA7430074AE68 /* RequestAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE99814D295AA7430074AE68 /* RequestAuthenticator.swift */; }; + EE998150295AACE10074AE68 /* RequestConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE99814F295AACE10074AE68 /* RequestConverter.swift */; }; EECB7EE8286555180028C888 /* media-update-product-id.json in Resources */ = {isa = PBXBuildFile; fileRef = EECB7EE7286555180028C888 /* media-update-product-id.json */; }; + EEFAA57B295D7793003583BE /* AuthenticatedDotcomRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEFAA57A295D7793003583BE /* AuthenticatedDotcomRequest.swift */; }; + EEFAA57D295D77F0003583BE /* AuthenticatedRESTRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEFAA57C295D77F0003583BE /* AuthenticatedRESTRequest.swift */; }; + EEFAA57F295D78DF003583BE /* AuthenticatedDotcomRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEFAA57E295D78DF003583BE /* AuthenticatedDotcomRequestTests.swift */; }; + EEFAA581295D78E9003583BE /* AuthenticatedRESTRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEFAA580295D78E9003583BE /* AuthenticatedRESTRequestTests.swift */; }; FE28F6E226840DED004465C7 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE28F6E126840DED004465C7 /* User.swift */; }; FE28F6E426842848004465C7 /* UserMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE28F6E326842848004465C7 /* UserMapper.swift */; }; FE28F6E6268429B6004465C7 /* UserRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE28F6E5268429B6004465C7 /* UserRemote.swift */; }; @@ -1287,10 +1294,8 @@ B557DA1920979D66005962F4 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; B557DA1C20979E7D005962F4 /* Order.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Order.swift; sourceTree = ""; }; B559EBA920A0B5CD00836CD4 /* orders-load-all.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "orders-load-all.json"; sourceTree = ""; }; - B567AF2420A0CCA300AB6C62 /* AuthenticatedRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticatedRequest.swift; sourceTree = ""; }; B567AF2820A0FA1E00AB6C62 /* Mapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mapper.swift; sourceTree = ""; }; B567AF2A20A0FA4200AB6C62 /* OrderListMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderListMapper.swift; sourceTree = ""; }; - B567AF2C20A0FB8F00AB6C62 /* AuthenticatedRequestTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticatedRequestTests.swift; sourceTree = ""; }; B567AF2D20A0FB8F00AB6C62 /* DotcomRequestTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DotcomRequestTests.swift; sourceTree = ""; }; B567AF2E20A0FB8F00AB6C62 /* JetpackRequestTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JetpackRequestTests.swift; sourceTree = ""; }; B56C1EB520EA757B00D749F9 /* SiteListMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteListMapper.swift; sourceTree = ""; }; @@ -1507,7 +1512,7 @@ DEC51AFA2769C66B009F3DF4 /* SystemStatusMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemStatusMapperTests.swift; sourceTree = ""; }; DEC51B01276AFB34009F3DF4 /* SystemStatus+DropinMustUsePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatus+DropinMustUsePlugin.swift"; sourceTree = ""; }; DEFBA74D29485A7600C35BA9 /* RESTRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTRequest.swift; sourceTree = ""; }; - DEFBA7532949CE6600C35BA9 /* RequestAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestAuthenticator.swift; sourceTree = ""; }; + DEFBA7532949CE6600C35BA9 /* RequestProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestProcessor.swift; sourceTree = ""; }; DEFBA7552949D17300C35BA9 /* RequestAuthenticatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestAuthenticatorTests.swift; sourceTree = ""; }; E12552C426385B05001CEE70 /* ShippingLabelAddressValidationSuccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelAddressValidationSuccess.swift; sourceTree = ""; }; E137619829151C7400FD098F /* error-wp-rest-forbidden.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "error-wp-rest-forbidden.json"; sourceTree = ""; }; @@ -1528,6 +1533,9 @@ EE338A0D294AF9BD00183934 /* ApplicationPasswordMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordMapperTests.swift; sourceTree = ""; }; EE54C89E2947782E00A9BF61 /* ApplicationPasswordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordUseCase.swift; sourceTree = ""; }; EE54C8A629486B6800A9BF61 /* ApplicationPasswordMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordMapper.swift; sourceTree = ""; }; + EE62EE60295ACF8D009C965B /* RequestConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestConverterTests.swift; sourceTree = ""; }; + EE62EE62295AD45E009C965B /* String+URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+URL.swift"; sourceTree = ""; }; + EE62EE64295AD46D009C965B /* String+URLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+URLTests.swift"; sourceTree = ""; }; EE71CC3C2951A8EA0074D908 /* ApplicationPasswordStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordStorage.swift; sourceTree = ""; }; EE71CC402951CE700074D908 /* generate-application-password-using-wporg-creds-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "generate-application-password-using-wporg-creds-success.json"; sourceTree = ""; }; EE80A24529547F8B003591E4 /* coupons-all-without-data.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "coupons-all-without-data.json"; sourceTree = ""; }; @@ -1535,7 +1543,13 @@ EE80A24F29556FBD003591E4 /* coupon-reports-without-data.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "coupon-reports-without-data.json"; sourceTree = ""; }; EE8A86F0286C5226003E8AA4 /* media-update-product-id-in-wordpress-site.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "media-update-product-id-in-wordpress-site.json"; sourceTree = ""; }; EE8DE431294B17CD005054E7 /* DefaultApplicationPasswordUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultApplicationPasswordUseCaseTests.swift; sourceTree = ""; }; + EE99814D295AA7430074AE68 /* RequestAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestAuthenticator.swift; sourceTree = ""; }; + EE99814F295AACE10074AE68 /* RequestConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestConverter.swift; sourceTree = ""; }; EECB7EE7286555180028C888 /* media-update-product-id.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "media-update-product-id.json"; sourceTree = ""; }; + EEFAA57A295D7793003583BE /* AuthenticatedDotcomRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedDotcomRequest.swift; sourceTree = ""; }; + EEFAA57C295D77F0003583BE /* AuthenticatedRESTRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedRESTRequest.swift; sourceTree = ""; }; + EEFAA57E295D78DF003583BE /* AuthenticatedDotcomRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedDotcomRequestTests.swift; sourceTree = ""; }; + EEFAA580295D78E9003583BE /* AuthenticatedRESTRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticatedRESTRequestTests.swift; sourceTree = ""; }; F3F25DC15EC1D7C631169CB5 /* Pods_Networking.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Networking.framework; sourceTree = BUILT_PRODUCTS_DIR; }; F6CEE1CA2AD376C0C28AE9F6 /* Pods-NetworkingTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NetworkingTests.release.xcconfig"; path = "../Pods/Target Support Files/Pods-NetworkingTests/Pods-NetworkingTests.release.xcconfig"; sourceTree = ""; }; FE28F6E126840DED004465C7 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; @@ -1746,7 +1760,6 @@ B518662320A099BF00037A38 /* AlamofireNetwork.swift */, B518662620A09BCC00037A38 /* MockNetwork.swift */, D87F6150226591E10031A13B /* NullNetwork.swift */, - DEFBA7532949CE6600C35BA9 /* RequestAuthenticator.swift */, ); path = Network; sourceTree = ""; @@ -1818,11 +1831,12 @@ B519A3E82097A91800E2603A /* Requests */ = { isa = PBXGroup; children = ( - B567AF2C20A0FB8F00AB6C62 /* AuthenticatedRequestTests.swift */, DE34052028BDFE3500CF0D97 /* WordPressOrgRequestTests.swift */, B567AF2D20A0FB8F00AB6C62 /* DotcomRequestTests.swift */, B567AF2E20A0FB8F00AB6C62 /* JetpackRequestTests.swift */, 020D0C02291504DE00BB3DCE /* UnauthenticatedRequestTests.swift */, + EEFAA57E295D78DF003583BE /* AuthenticatedDotcomRequestTests.swift */, + EEFAA580295D78E9003583BE /* AuthenticatedRESTRequestTests.swift */, ); path = Requests; sourceTree = ""; @@ -1955,13 +1969,14 @@ B557DA0620975515005962F4 /* Requests */ = { isa = PBXGroup; children = ( - B567AF2420A0CCA300AB6C62 /* AuthenticatedRequest.swift */, E1BAB2C02913F99500C3982B /* Request.swift */, B557DA0E20975E07005962F4 /* DotcomRequest.swift */, B557D9FF209754FF005962F4 /* JetpackRequest.swift */, DE34051228BDCA5100CF0D97 /* WordPressOrgRequest.swift */, 029C9E5B291507A40013E5EE /* UnauthenticatedRequest.swift */, DEFBA74D29485A7600C35BA9 /* RESTRequest.swift */, + EEFAA57A295D7793003583BE /* AuthenticatedDotcomRequest.swift */, + EEFAA57C295D77F0003583BE /* AuthenticatedRESTRequest.swift */, ); path = Requests; sourceTree = ""; @@ -2438,6 +2453,7 @@ children = ( B57B1E6621C916850046E764 /* NetworkErrorTests.swift */, DEFBA7552949D17300C35BA9 /* RequestAuthenticatorTests.swift */, + EE62EE60295ACF8D009C965B /* RequestConverterTests.swift */, ); path = Network; sourceTree = ""; @@ -2474,6 +2490,7 @@ 57E8FED2246616AC0057CD68 /* Result+Extensions.swift */, 265EFBDB285257950033BD33 /* Order+Fallbacks.swift */, DE2E8EB0295464C5002E4B14 /* URLRequest+Request.swift */, + EE62EE62295AD45E009C965B /* String+URL.swift */, ); path = Extensions; sourceTree = ""; @@ -2487,6 +2504,7 @@ 02BDB83623EA9C4D00BCC63E /* String+HTMLTests.swift */, 0212683424C046CB00F8A892 /* MockNetwork+Path.swift */, CC851D1325E52AB500249E9C /* Decimal+ExtensionsTests.swift */, + EE62EE64295AD46D009C965B /* String+URLTests.swift */, ); path = Extensions; sourceTree = ""; @@ -2646,8 +2664,11 @@ EE54C899294777D000A9BF61 /* ApplicationPassword */ = { isa = PBXGroup; children = ( + DEFBA7532949CE6600C35BA9 /* RequestProcessor.swift */, EE54C89E2947782E00A9BF61 /* ApplicationPasswordUseCase.swift */, EE71CC3C2951A8EA0074D908 /* ApplicationPasswordStorage.swift */, + EE99814D295AA7430074AE68 /* RequestAuthenticator.swift */, + EE99814F295AACE10074AE68 /* RequestConverter.swift */, ); path = ApplicationPassword; sourceTree = ""; @@ -3160,6 +3181,7 @@ 457A574025D1817E000797AD /* ShippingLabelAddressVerification.swift in Sources */, 74ABA1D1213F22CA00FFAD30 /* TopEarnersStatsRemote.swift in Sources */, DEC51AF127699E7A009F3DF4 /* SystemStatus+Page.swift in Sources */, + EE99814E295AA7430074AE68 /* RequestAuthenticator.swift in Sources */, 025CA2C0238EB8CB00B05C81 /* ProductShippingClass.swift in Sources */, 02C1CEF424C6A02B00703EBA /* ProductVariationMapper.swift in Sources */, 3105470C262E27F000C5C02B /* WCPayPaymentIntentStatusEnum.swift in Sources */, @@ -3190,11 +3212,10 @@ 020D07B823D852BB00FD9580 /* Media.swift in Sources */, B5BB1D0C20A2050300112D92 /* DateFormatter+Woo.swift in Sources */, 743E84EE2217244C00FAC9D7 /* ShipmentTrackingListMapper.swift in Sources */, - DEFBA7542949CE6600C35BA9 /* RequestAuthenticator.swift in Sources */, + DEFBA7542949CE6600C35BA9 /* RequestProcessor.swift in Sources */, 451A97E5260B631E0059D135 /* ShippingLabelPredefinedPackage.swift in Sources */, BAB373722795A1FB00837B4A /* OrderTaxLine.swift in Sources */, EE54C89F2947782E00A9BF61 /* ApplicationPasswordUseCase.swift in Sources */, - B567AF2520A0CCA300AB6C62 /* AuthenticatedRequest.swift in Sources */, 453305E92459DF2100264E50 /* PostMapper.swift in Sources */, E12552C526385B05001CEE70 /* ShippingLabelAddressValidationSuccess.swift in Sources */, 456F71D424CB1E2400472EC1 /* ProductTagFromBatchCreation.swift in Sources */, @@ -3214,6 +3235,7 @@ 7452387221124B7700A973CD /* AnyEncodable.swift in Sources */, 740CF89921937A030023ED3A /* CommentRemote.swift in Sources */, 31054702262E04F700C5C02B /* RemotePaymentIntentMapper.swift in Sources */, + EE62EE63295AD45E009C965B /* String+URL.swift in Sources */, 025CA2C2238EBBAA00B05C81 /* ProductShippingClassListMapper.swift in Sources */, 74ABA1CD213F1B6B00FFAD30 /* TopEarnerStats.swift in Sources */, CCAAD10F2683974000909664 /* ShippingLabelPackagePurchase.swift in Sources */, @@ -3319,6 +3341,7 @@ CE50345E21B571A7007573C6 /* SitePlanMapper.swift in Sources */, DE34051528BDEB1900CF0D97 /* WordPressOrgNetwork.swift in Sources */, 3192F224260D34C40067FEF9 /* WCPayAccountStatusEnum.swift in Sources */, + EEFAA57B295D7793003583BE /* AuthenticatedDotcomRequest.swift in Sources */, D8FBFF2022D52553006E3336 /* OrderStatsV4Totals.swift in Sources */, 02C254B925637BA000A04423 /* OrderShippingLabelListMapper.swift in Sources */, 261CF1B8255AE62D0090D8D3 /* PaymentGatewayRemote.swift in Sources */, @@ -3337,6 +3360,7 @@ 24F98C522502E79800F49B68 /* FeatureFlagsRemote.swift in Sources */, 74A1D26D21189DFF00931DFA /* SiteVisitStatsMapper.swift in Sources */, 45152809257A7C6E0076B03C /* ProductAttributesRemote.swift in Sources */, + EEFAA57D295D77F0003583BE /* AuthenticatedRESTRequest.swift in Sources */, D8FBFF2222D5266E006E3336 /* OrderStatsV4Interval.swift in Sources */, 4568E2222459ADC60007E478 /* SitePostsRemote.swift in Sources */, 02C11276274285FF00F4F0B4 /* WooCommerceAvailabilityMapper.swift in Sources */, @@ -3364,6 +3388,7 @@ CE583A0E2109154500D73C1C /* OrderNoteMapper.swift in Sources */, D8FBFF0D22D3AF4A006E3336 /* StatsGranularityV4.swift in Sources */, 261870782540A252006522A1 /* ShippingLineTax.swift in Sources */, + EE998150295AACE10074AE68 /* RequestConverter.swift in Sources */, 74046E1B217A684D007DD7BF /* SiteSettingsRemote.swift in Sources */, 0359EA1D27AADE000048DE2D /* WCPayChargeMapper.swift in Sources */, B5C6FCCF20A3592900A4F8E4 /* OrderItem.swift in Sources */, @@ -3505,6 +3530,8 @@ 45150AA026837357006922EA /* CountryListMapperTests.swift in Sources */, 74D5BECE217E0F98007B0348 /* SiteSettingsRemoteTests.swift in Sources */, D8FBFF1C22D51C34006E3336 /* OrderStatsRemoteV4Tests.swift in Sources */, + EE62EE61295ACF8D009C965B /* RequestConverterTests.swift in Sources */, + EE62EE65295AD46D009C965B /* String+URLTests.swift in Sources */, CE6D666F2379E82A007835A1 /* ArrayWooTests.swift in Sources */, DE2E8EAD295418D8002E4B14 /* WordPressSiteRemoteTests.swift in Sources */, 45D685FC23D0C739005F87D0 /* ProductSkuMapperTests.swift in Sources */, @@ -3547,6 +3574,7 @@ B5C151C0217EE3FB00C7BDC1 /* NoteListMapperTests.swift in Sources */, 026CF622237D7E61009563D4 /* ProductVariationsRemoteTests.swift in Sources */, DEC51A99274DDDC9009F3DF4 /* SitePluginMapperTests.swift in Sources */, + EEFAA581295D78E9003583BE /* AuthenticatedRESTRequestTests.swift in Sources */, CCF48B802628BBC10034EA83 /* ShippingLabelAccountSettingsMapperTests.swift in Sources */, FE28F6EC268436C9004465C7 /* UserRemoteTests.swift in Sources */, DE74F29E27E0A6800002FE59 /* SiteSettingMapperTests.swift in Sources */, @@ -3579,6 +3607,7 @@ 74C8F06E20EEC1E800B6EDC9 /* OrderNotesMapperTests.swift in Sources */, 45ED4F10239E8A54004F1BE3 /* TaxClassListMapperTest.swift in Sources */, FE28F6EA26842E49004465C7 /* UserMapperTests.swift in Sources */, + EEFAA57F295D78DF003583BE /* AuthenticatedDotcomRequestTests.swift in Sources */, E137619B2915222100FD098F /* WordPressApiValidatorTests.swift in Sources */, 020C907F24C7D359001E2BEB /* ProductVariationMapperTests.swift in Sources */, 74ABA1D5213F26B300FFAD30 /* TopEarnerStatsMapperTests.swift in Sources */, @@ -3586,7 +3615,6 @@ 077F39E626A5D15800ABEADC /* SystemPluginMapperTests.swift in Sources */, 4599FC6624A633A10056157A /* ProductTagsRemoteTests.swift in Sources */, DE97C3922861B8E20042E973 /* CouponEncoderTests.swift in Sources */, - B567AF2F20A0FB8F00AB6C62 /* AuthenticatedRequestTests.swift in Sources */, B5C6FCCD20A34B8300A4F8E4 /* OrderListMapperTests.swift in Sources */, B518663520A0A2E800037A38 /* Constants.swift in Sources */, D8FBFF1E22D51F39006E3336 /* OrderStatsMapperV4Tests.swift in Sources */, diff --git a/Networking/Networking/ApplicationPassword/ApplicationPasswordUseCase.swift b/Networking/Networking/ApplicationPassword/ApplicationPasswordUseCase.swift index d4bbd8961e8..28f0d0343e9 100644 --- a/Networking/Networking/ApplicationPassword/ApplicationPasswordUseCase.swift +++ b/Networking/Networking/ApplicationPassword/ApplicationPasswordUseCase.swift @@ -6,7 +6,7 @@ import enum Alamofire.AFError enum ApplicationPasswordUseCaseError: Error { case duplicateName case applicationPasswordsDisabled - case invalidSiteAddress + case failedToConstructLoginOrAdminURLUsingSiteAddress } struct ApplicationPassword { @@ -77,7 +77,7 @@ final class DefaultApplicationPasswordUseCase: ApplicationPasswordUseCase { guard let loginURL = URL(string: siteAddress + Constants.loginPath), let adminURL = URL(string: siteAddress + Constants.adminPath) else { DDLogWarn("⚠️ Cannot construct login URL and admin URL for site \(siteAddress)") - throw ApplicationPasswordUseCaseError.invalidSiteAddress + throw ApplicationPasswordUseCaseError.failedToConstructLoginOrAdminURLUsingSiteAddress } // Prepares the authenticator with username and password let authenticator = CookieNonceAuthenticator(username: username, diff --git a/Networking/Networking/ApplicationPassword/RequestAuthenticator.swift b/Networking/Networking/ApplicationPassword/RequestAuthenticator.swift new file mode 100644 index 00000000000..701ac01e088 --- /dev/null +++ b/Networking/Networking/ApplicationPassword/RequestAuthenticator.swift @@ -0,0 +1,90 @@ +enum RequestAuthenticatorError: Error { + case applicationPasswordUseCaseNotAvailable + case applicationPasswordNotAvailable +} + +/// Authenticates request +/// +public struct RequestAuthenticator { + /// Credentials. + /// + let credentials: Credentials? + + /// The use case to handle authentication with application passwords. + /// + private let applicationPasswordUseCase: ApplicationPasswordUseCase? + + /// Sets up the authenticator with optional credentials and application password use case. + /// `applicationPasswordUseCase` can be injected for unit tests. + /// + init(credentials: Credentials?, applicationPasswordUseCase: ApplicationPasswordUseCase? = nil) { + self.credentials = credentials + let useCase: ApplicationPasswordUseCase? = { + if let applicationPasswordUseCase { + return applicationPasswordUseCase + } else if case let .wporg(username, password, siteAddress) = credentials { + return try? DefaultApplicationPasswordUseCase(username: username, + password: password, + siteAddress: siteAddress) + } else { + return nil + } + }() + self.applicationPasswordUseCase = useCase + } + + func authenticate(_ urlRequest: URLRequest) throws -> URLRequest { + if isRestAPIRequest(urlRequest) { + return try authenticateUsingApplicationPasswordIfPossible(urlRequest) + } else { + return try authenticateUsingWPCOMTokenIfPossible(urlRequest) + } + } + + func generateApplicationPassword() async throws { + guard let applicationPasswordUseCase else { + throw RequestAuthenticatorError.applicationPasswordUseCaseNotAvailable + } + let _ = try await applicationPasswordUseCase.generateNewPassword() + return + } + + /// Checks whether the given URLRequest is eligible for retyring + /// + func shouldRetry(_ urlRequest: URLRequest) -> Bool { + isRestAPIRequest(urlRequest) + } +} + +private extension RequestAuthenticator { + /// To check whether the given URLRequest is a REST API request + /// + func isRestAPIRequest(_ urlRequest: URLRequest) -> Bool { + guard case let .wporg(_, _, siteAddress) = credentials, + let url = urlRequest.url, + url.absoluteString.hasPrefix(siteAddress.trimSlashes() + "/" + RESTRequest.Settings.basePath) else { + return false + } + return true + } + + /// Attempts creating a request with WPCOM token if possible. + /// + func authenticateUsingWPCOMTokenIfPossible(_ urlRequest: URLRequest) throws -> URLRequest { + guard case let .wpcom(_, authToken, _) = credentials else { + return UnauthenticatedRequest(request: urlRequest).asURLRequest() + } + + return AuthenticatedDotcomRequest(authToken: authToken, request: urlRequest).asURLRequest() + } + + /// Attempts creating a request with application password if possible. + /// + func authenticateUsingApplicationPasswordIfPossible(_ urlRequest: URLRequest) throws -> URLRequest { + guard let applicationPassword = applicationPasswordUseCase?.applicationPassword else { + throw RequestAuthenticatorError.applicationPasswordNotAvailable + } + + return AuthenticatedRESTRequest(applicationPassword: applicationPassword, request: urlRequest).asURLRequest() + } +} diff --git a/Networking/Networking/ApplicationPassword/RequestConverter.swift b/Networking/Networking/ApplicationPassword/RequestConverter.swift new file mode 100644 index 00000000000..e00eccd01e9 --- /dev/null +++ b/Networking/Networking/ApplicationPassword/RequestConverter.swift @@ -0,0 +1,17 @@ +import Alamofire + +/// Converter to convert Jetpack tunnel requests into REST API requests if needed +/// +struct RequestConverter { + let credentials: Credentials? + + func convert(_ request: URLRequestConvertible) -> URLRequestConvertible { + guard let jetpackRequest = request as? JetpackRequest, + case let .wporg(_, _, siteAddress) = credentials, + let restRequest = jetpackRequest.asRESTRequest(with: siteAddress) else { + return request + } + + return restRequest + } +} diff --git a/Networking/Networking/ApplicationPassword/RequestProcessor.swift b/Networking/Networking/ApplicationPassword/RequestProcessor.swift new file mode 100644 index 00000000000..83364e49311 --- /dev/null +++ b/Networking/Networking/ApplicationPassword/RequestProcessor.swift @@ -0,0 +1,87 @@ +import Alamofire +import Foundation + +/// Authenticates and retries requests +/// +final class RequestProcessor { + private var requestsToRetry = [RequestRetryCompletion]() + + private var isAuthenticating = false + + private let requestAuthenticator: RequestAuthenticator + + init(credentials: Credentials?) { + requestAuthenticator = RequestAuthenticator(credentials: credentials) + } +} + +// MARK: Request Authentication +// +extension RequestProcessor: RequestAdapter { + func adapt(_ urlRequest: URLRequest) throws -> URLRequest { + return try requestAuthenticator.authenticate(urlRequest) + } +} + +// MARK: Retrying Request +// +extension RequestProcessor: RequestRetrier { + func should(_ manager: Alamofire.SessionManager, + retry request: Alamofire.Request, + with error: Error, + completion: @escaping Alamofire.RequestRetryCompletion) { + guard + request.retryCount == 0, // Only retry once + let urlRequest = request.request, + requestAuthenticator.shouldRetry(urlRequest), // Retry only REST API requests that use application password + shouldRetry(error) // Retry only specific errors + else { + return completion(false, 0.0) + } + + requestsToRetry.append(completion) + if !isAuthenticating { + generateApplicationPassword() + } + } +} + +// MARK: Helpers +// +private extension RequestProcessor { + func generateApplicationPassword() { + Task(priority: .medium) { + isAuthenticating = true + + do { + let _ = try await requestAuthenticator.generateApplicationPassword() + isAuthenticating = false + completeRequests(true) + } catch { + isAuthenticating = false + completeRequests(false) + } + } + } + + func shouldRetry(_ error: Error) -> Bool { + // Need to generate application password + if .applicationPasswordNotAvailable == error as? RequestAuthenticatorError { + return true + } + + // Failed authorization + if case .responseValidationFailed(reason: .unacceptableStatusCode(code: 401)) = error as? AFError { + return true + } + + return false + } + + func completeRequests(_ shouldRetry: Bool) { + requestsToRetry.forEach { (completion) in + completion(shouldRetry, 0.0) + } + requestsToRetry.removeAll() + } +} diff --git a/Networking/Networking/Extensions/String+URL.swift b/Networking/Networking/Extensions/String+URL.swift new file mode 100644 index 00000000000..3a73ff72d2e --- /dev/null +++ b/Networking/Networking/Extensions/String+URL.swift @@ -0,0 +1,11 @@ +import Foundation + +extension String { + /// Trims forward slash + /// + /// - Returns: String after removing prefix and suffix "/" + /// + func trimSlashes() -> String { + removingPrefix("/").removingSuffix("/") + } +} diff --git a/Networking/Networking/Network/AlamofireNetwork.swift b/Networking/Networking/Network/AlamofireNetwork.swift index 4c45d4b8980..81cad9c5448 100644 --- a/Networking/Networking/Network/AlamofireNetwork.swift +++ b/Networking/Networking/Network/AlamofireNetwork.swift @@ -7,29 +7,36 @@ extension Alamofire.MultipartFormData: MultipartFormData {} /// AlamofireWrapper: Encapsulates all of the Alamofire OP's /// public class AlamofireNetwork: Network { - /// WordPress.com Credentials. - /// - private let credentials: Credentials? + private lazy var backgroundSessionManager: Alamofire.SessionManager = { + // A unique ID is included in the background session identifier so that the session does not get invalidated when the initializer is called multiple + // times (e.g. when logging in). + let uniqueID = UUID().uuidString + let sessionConfiguration = URLSessionConfiguration.background(withIdentifier: "com.automattic.woocommerce.backgroundsession.\(uniqueID)") + let sessionManager = makeSessionManager(configuration: sessionConfiguration) + return sessionManager + }() - private let backgroundSessionManager: Alamofire.SessionManager + private lazy var sessionManager: Alamofire.SessionManager = { + let sessionConfiguration = URLSessionConfiguration.default + let sessionManager = makeSessionManager(configuration: sessionConfiguration) + return sessionManager + }() + + /// Converter to convert Jetpack tunnel requests into REST API requests if applicable + /// + private let requestConverter: RequestConverter /// Authenticator to update requests authorization header if possible. /// - private let requestAuthenticator: RequestAuthenticator + private let requestAuthenticator: RequestProcessor public var session: URLSession { SessionManager.default.session } /// Public Initializer /// public required init(credentials: Credentials?) { - self.credentials = credentials - self.requestAuthenticator = RequestAuthenticator(credentials: credentials) - - // A unique ID is included in the background session identifier so that the session does not get invalidated when the initializer is called multiple - // times (e.g. when logging in). - let uniqueID = UUID().uuidString - let configuration = URLSessionConfiguration.background(withIdentifier: "com.automattic.woocommerce.backgroundsession.\(uniqueID)") - self.backgroundSessionManager = Alamofire.SessionManager(configuration: configuration) + self.requestConverter = RequestConverter(credentials: credentials) + self.requestAuthenticator = RequestProcessor(credentials: credentials) } /// Executes the specified Network Request. Upon completion, the payload will be sent back to the caller as a Data instance. @@ -48,17 +55,12 @@ public class AlamofireNetwork: Network { /// - Yes. We do the above because the Jetpack Tunnel endpoint doesn't properly relay the correct statusCode. /// public func responseData(for request: URLRequestConvertible, completion: @escaping (Data?, Error?) -> Void) { - requestAuthenticator.authenticateRequest(request) { result in - switch result { - case .success(let request): - Alamofire.request(request) - .responseData { response in - completion(response.value, response.networkingError) - } - case .failure(let error): - completion(nil, error) + let request = requestConverter.convert(request) + sessionManager.request(request) + .validateIfRestRequest(for: request) + .responseData { response in + completion(response.value, response.networkingError) } - } } /// Executes the specified Network Request. Upon completion, the payload will be sent back to the caller as a Data instance. @@ -71,16 +73,12 @@ public class AlamofireNetwork: Network { /// - completion: Closure to be executed upon completion. /// public func responseData(for request: URLRequestConvertible, completion: @escaping (Swift.Result) -> Void) { - requestAuthenticator.authenticateRequest(request) { result in - switch result { - case .success(let request): - Alamofire.request(request).responseData { response in - completion(response.result.toSwiftResult()) - } - case .failure(let error): - completion(.failure(error)) + let request = requestConverter.convert(request) + sessionManager.request(request) + .validateIfRestRequest(for: request) + .responseData { response in + completion(response.result.toSwiftResult()) } - } } /// Executes the specified Network Request. Upon completion, the payload or error will be emitted to the publisher. @@ -93,38 +91,26 @@ public class AlamofireNetwork: Network { /// - Returns: A publisher that emits the result of the given request. public func responseDataPublisher(for request: URLRequestConvertible) -> AnyPublisher, Never> { return Future() { promise in - self.requestAuthenticator.authenticateRequest(request) { result in - switch result { - case .success(let request): - Alamofire.request(request).responseData { response in - let result = response.result.toSwiftResult() - promise(.success(result)) - } - case .failure(let error): - promise(.success(.failure(error))) + let request = self.requestConverter.convert(request) + self.sessionManager + .request(request) + .validateIfRestRequest(for: request) + .responseData { response in + let result = response.result.toSwiftResult() + promise(.success(result)) } - } }.eraseToAnyPublisher() } public func uploadMultipartFormData(multipartFormData: @escaping (MultipartFormData) -> Void, to request: URLRequestConvertible, completion: @escaping (Data?, Error?) -> Void) { - requestAuthenticator.authenticateRequest(request) { [weak self] result in - guard let self else { - return completion(nil, nil) - } - switch result { - case .success(let request): - self.backgroundSessionManager.upload(multipartFormData: multipartFormData, with: request) { (encodingResult) in - switch encodingResult { - case .success(let upload, _, _): - upload.responseData { response in - completion(response.value, response.error) - } - case .failure(let error): - completion(nil, error) - } + let request = requestConverter.convert(request) + backgroundSessionManager.upload(multipartFormData: multipartFormData, with: request) { (encodingResult) in + switch encodingResult { + case .success(let upload, _, _): + upload.responseData { response in + completion(response.value, response.error) } case .failure(let error): completion(nil, error) @@ -133,6 +119,30 @@ public class AlamofireNetwork: Network { } } +private extension AlamofireNetwork { + /// Creates a session manager with request retrier and adapter + /// + func makeSessionManager(configuration sessionConfiguration: URLSessionConfiguration) -> Alamofire.SessionManager { + let sessionManager = Alamofire.SessionManager(configuration: sessionConfiguration) + sessionManager.retrier = requestAuthenticator + sessionManager.adapter = requestAuthenticator + return sessionManager + } +} + +private extension DataRequest { + /// Validates only for `RESTRequest` + /// + /// Only `RESTRequest` needs to be checked for status codes and retried if applicable by `RequestProcessor` + /// + func validateIfRestRequest(for request: URLRequestConvertible) -> Self { + guard request is RESTRequest else { + return self + } + return validate() + } +} + // MARK: - Alamofire.DataResponse: Helper Methods // extension Alamofire.DataResponse { diff --git a/Networking/Networking/Network/MockNetwork.swift b/Networking/Networking/Network/MockNetwork.swift index 9eccfe54150..34f6a33cbfb 100644 --- a/Networking/Networking/Network/MockNetwork.swift +++ b/Networking/Networking/Network/MockNetwork.swift @@ -183,7 +183,9 @@ private extension MockNetwork { /// private func path(for request: URLRequestConvertible) -> String { switch request { - case let request as AuthenticatedRequest: + case let request as AuthenticatedDotcomRequest: + return path(for: request.request) + case let request as AuthenticatedRESTRequest: return path(for: request.request) case let request as UnauthenticatedRequest: return path(for: request.request) diff --git a/Networking/Networking/Network/RequestAuthenticator.swift b/Networking/Networking/Network/RequestAuthenticator.swift deleted file mode 100644 index 989436754aa..00000000000 --- a/Networking/Networking/Network/RequestAuthenticator.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Alamofire -import Foundation - -/// Helper class to update requests with authorization header if possible. -/// -final class RequestAuthenticator { - /// WordPress.com Credentials. - /// - private let credentials: Credentials? - - /// The use case to handle authentication with application passwords. - /// - private let applicationPasswordUseCase: ApplicationPasswordUseCase? - - /// Sets up the authenticator with optional credentials and application password use case. - /// `applicationPasswordUseCase` can be injected for unit tests. - /// - init(credentials: Credentials?, applicationPasswordUseCase: ApplicationPasswordUseCase? = nil) { - self.credentials = credentials - let useCase: ApplicationPasswordUseCase? = { - if let applicationPasswordUseCase { - return applicationPasswordUseCase - } else if case let .wporg(username, password, siteAddress) = credentials { - return try? DefaultApplicationPasswordUseCase(username: username, - password: password, - siteAddress: siteAddress) - } else { - return nil - } - }() - self.applicationPasswordUseCase = useCase - } - - /// Updates a request with application password or WPCOM token if possible. - /// - func authenticateRequest(_ request: URLRequestConvertible, completion: @escaping (Swift.Result) -> Void) { - guard let jetpackRequest = request as? JetpackRequest, - let useCase = applicationPasswordUseCase, - case let .wporg(_, _, siteAddress) = credentials, - let restRequest = jetpackRequest.asRESTRequest(with: siteAddress) else { - // Handle non-REST requests as before - return completion(.success(authenticateUsingWPCOMTokenIfPossible(request))) - } - - Task(priority: .medium) { - let result: Swift.Result - do { - let authenticatedRequest = try await authenticateUsingApplicationPassword(restRequest, useCase: useCase) - result = .success(authenticatedRequest) - } catch { - result = .failure(error) - } - await MainActor.run { - completion(result) - } - } - } - - /// Attempts authenticating a request with application password. - /// - private func authenticateUsingApplicationPassword(_ restRequest: RESTRequest, useCase: ApplicationPasswordUseCase) async throws -> URLRequestConvertible { - let applicationPassword: ApplicationPassword = try await { - if let password = useCase.applicationPassword { - return password - } - return try await useCase.generateNewPassword() - }() - return try await MainActor.run { - return try restRequest.authenticateRequest(with: applicationPassword) - } - } - - /// Attempts creating a request with WPCOM token if possible. - /// - private func authenticateUsingWPCOMTokenIfPossible(_ request: URLRequestConvertible) -> URLRequestConvertible { - if let credentials, case .wpcom = credentials { - return AuthenticatedRequest(credentials: credentials, request: request) - } - return UnauthenticatedRequest(request: request) - } -} diff --git a/Networking/Networking/Requests/AuthenticatedDotcomRequest.swift b/Networking/Networking/Requests/AuthenticatedDotcomRequest.swift new file mode 100644 index 00000000000..a0fc398b011 --- /dev/null +++ b/Networking/Networking/Requests/AuthenticatedDotcomRequest.swift @@ -0,0 +1,28 @@ +import Foundation +import Alamofire + +/// Wraps up a URLRequestConvertible Instance, and injects +/// the WordPress.com authentication token + `Settings.userAgent` +/// whenever the actual Request is required. +/// +struct AuthenticatedDotcomRequest: URLRequestConvertible { + /// Authenticated Request + /// + let request: URLRequest + + init(authToken: String, request: URLRequest) { + var authenticated = request + + authenticated.setValue("Bearer " + authToken, forHTTPHeaderField: "Authorization") + authenticated.setValue("application/json", forHTTPHeaderField: "Accept") + authenticated.setValue(UserAgent.defaultUserAgent, forHTTPHeaderField: "User-Agent") + + self.request = authenticated + } + + /// Returns the Wrapped Request, but with a WordPress.com Bearer Token set up. + /// + func asURLRequest() -> URLRequest { + request + } +} diff --git a/Networking/Networking/Requests/AuthenticatedRESTRequest.swift b/Networking/Networking/Requests/AuthenticatedRESTRequest.swift new file mode 100644 index 00000000000..47159cb0a36 --- /dev/null +++ b/Networking/Networking/Requests/AuthenticatedRESTRequest.swift @@ -0,0 +1,36 @@ +import Foundation +import Alamofire + +/// Wraps up a URLRequestConvertible Instance, and injects the application password + `Settings.userAgent` whenever the actual Request is required. +/// +struct AuthenticatedRESTRequest: URLRequestConvertible { + /// Authenticated Request + /// + let request: URLRequest + + init(applicationPassword: ApplicationPassword, request: URLRequest) { + var authenticated = request + + authenticated.setValue("application/json", forHTTPHeaderField: "Accept") + authenticated.setValue(UserAgent.defaultUserAgent, forHTTPHeaderField: "User-Agent") + + let username = applicationPassword.wpOrgUsername + let password = applicationPassword.password.secretValue + let loginString = "\(username):\(password)" + + if let loginData = loginString.data(using: .utf8) { + let base64LoginString = loginData.base64EncodedString() + authenticated.setValue("Basic \(base64LoginString)", forHTTPHeaderField: "Authorization") + } + + // Cookies from `CookieNonceAuthenticator` should be skipped + authenticated.httpShouldHandleCookies = false + self.request = authenticated + } + + /// Returns the Wrapped Request, but with the application password injected + /// + func asURLRequest() -> URLRequest { + request + } +} diff --git a/Networking/Networking/Requests/AuthenticatedRequest.swift b/Networking/Networking/Requests/AuthenticatedRequest.swift deleted file mode 100644 index e6b55f2b4fc..00000000000 --- a/Networking/Networking/Requests/AuthenticatedRequest.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation -import Alamofire - -enum AuthenticatedRequestError: Error { - case invalidCredentials -} - -/// Wraps up a URLRequestConvertible Instance, and injects the Credentials + `Settings.userAgent` whenever the actual Request is required. -/// -struct AuthenticatedRequest: URLRequestConvertible { - - /// WordPress.com Credentials. - /// - let credentials: Credentials - - /// Request that should be authenticated. - /// - let request: URLRequestConvertible - - - /// Returns the Wrapped Request, but with a WordPress.com Bearer Token set up. - /// - func asURLRequest() throws -> URLRequest { - guard case let .wpcom(_, authToken, _) = credentials else { - throw AuthenticatedRequestError.invalidCredentials - } - - var authenticated = try request.asURLRequest() - - authenticated.setValue("Bearer " + authToken, forHTTPHeaderField: "Authorization") - authenticated.setValue("application/json", forHTTPHeaderField: "Accept") - authenticated.setValue(UserAgent.defaultUserAgent, forHTTPHeaderField: "User-Agent") - - return authenticated - } -} diff --git a/Networking/Networking/Requests/RESTRequest.swift b/Networking/Networking/Requests/RESTRequest.swift index f7d669b4d0c..04ab05e4e1f 100644 --- a/Networking/Networking/Requests/RESTRequest.swift +++ b/Networking/Networking/Requests/RESTRequest.swift @@ -31,9 +31,6 @@ struct RESTRequest: URLRequestConvertible { /// - method: HTTP Method we should use. /// - path: path to the target endpoint. /// - parameters: Collection of String parameters to be passed over to our target endpoint. - /// This can be encoded to the URL request query if the HTTP method is `.get`. - /// - headers: Headers to be added to the request. - /// - fallbackRequest: A fallback Jetpack request to trigger if the REST request cannot be made. /// init(siteURL: String, wooApiVersion: WooAPIVersion, @@ -58,38 +55,7 @@ struct RESTRequest: URLRequestConvertible { } extension RESTRequest { - /// Updates the request headers with authentication information. - /// - func authenticateRequest(with applicationPassword: ApplicationPassword) throws -> URLRequest { - var request = try asURLRequest() - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue(UserAgent.defaultUserAgent, forHTTPHeaderField: "User-Agent") - - let username = applicationPassword.wpOrgUsername - let password = applicationPassword.password.secretValue - let loginString = "\(username):\(password)" - guard let loginData = loginString.data(using: .utf8) else { - return request - } - let base64LoginString = loginData.base64EncodedString() - - request.setValue("Basic \(base64LoginString)", forHTTPHeaderField: "Authorization") - return request - } -} - -private extension RESTRequest { enum Settings { static let basePath = "wp-json" } } - -private extension String { - /// Trims front slash - /// - /// - Returns: String after removing prefix and suffix "/" - /// - func trimSlashes() -> String { - removingPrefix("/").removingSuffix("/") - } -} diff --git a/Networking/Networking/Requests/UnauthenticatedRequest.swift b/Networking/Networking/Requests/UnauthenticatedRequest.swift index 64f3276226e..6995a27134e 100644 --- a/Networking/Networking/Requests/UnauthenticatedRequest.swift +++ b/Networking/Networking/Requests/UnauthenticatedRequest.swift @@ -1,23 +1,22 @@ import Foundation import protocol Alamofire.URLRequestConvertible - /// Wraps up a `URLRequestConvertible` instance, and injects the `UserAgent.defaultUserAgent`. /// struct UnauthenticatedRequest: URLRequestConvertible { /// Request that does not require WPCOM authentication. /// - let request: URLRequestConvertible + let request: URLRequest /// Returns the wrapped request, with a custom user-agent header. /// - func asURLRequest() throws -> URLRequest { - var authenticated = try request.asURLRequest() + func asURLRequest() -> URLRequest { + var unauthenticated = request - authenticated.setValue("application/json", forHTTPHeaderField: "Accept") - authenticated.setValue(UserAgent.defaultUserAgent, forHTTPHeaderField: "User-Agent") + unauthenticated.setValue("application/json", forHTTPHeaderField: "Accept") + unauthenticated.setValue(UserAgent.defaultUserAgent, forHTTPHeaderField: "User-Agent") - return authenticated + return unauthenticated } } diff --git a/Networking/NetworkingTests/ApplicationPassword/DefaultApplicationPasswordUseCaseTests.swift b/Networking/NetworkingTests/ApplicationPassword/DefaultApplicationPasswordUseCaseTests.swift index 081c749b1de..0b5e38ba15d 100644 --- a/Networking/NetworkingTests/ApplicationPassword/DefaultApplicationPasswordUseCaseTests.swift +++ b/Networking/NetworkingTests/ApplicationPassword/DefaultApplicationPasswordUseCaseTests.swift @@ -31,10 +31,10 @@ final class DefaultApplicationPasswordUseCaseTests: XCTestCase { filename: "generate-application-password-using-wporg-creds-success") let username = "demo" let siteAddress = "https://test.com" - let sut = try await DefaultApplicationPasswordUseCase(username: username, - password: "qeWOhQ5RUV8W", - siteAddress: siteAddress, - network: network) + let sut = try DefaultApplicationPasswordUseCase(username: username, + password: "qeWOhQ5RUV8W", + siteAddress: siteAddress, + network: network) // When let password = try await sut.generateNewPassword() diff --git a/Networking/NetworkingTests/Extensions/String+URLTests.swift b/Networking/NetworkingTests/Extensions/String+URLTests.swift new file mode 100644 index 00000000000..9b5da7046f4 --- /dev/null +++ b/Networking/NetworkingTests/Extensions/String+URLTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import Networking + +final class String_URLTests: XCTestCase { + func test_prefix_slash() { + XCTAssertEqual("/test".trimSlashes(), "test") + } + + func test_suffix_slash() { + XCTAssertEqual("test/".trimSlashes(), "test") + } + + func test_prefix_and_suffix_slashes() { + XCTAssertEqual("/test/".trimSlashes(), "test") + } + + func test_prefix_suffix_and_extra_slashes() { + XCTAssertEqual("/te/st/".trimSlashes(), "te/st") + } +} diff --git a/Networking/NetworkingTests/Network/RequestAuthenticatorTests.swift b/Networking/NetworkingTests/Network/RequestAuthenticatorTests.swift index 33d3509583f..7f5588e69a6 100644 --- a/Networking/NetworkingTests/Network/RequestAuthenticatorTests.swift +++ b/Networking/NetworkingTests/Network/RequestAuthenticatorTests.swift @@ -4,117 +4,148 @@ import Alamofire final class RequestAuthenticatorTests: XCTestCase { - func test_authenticateRequest_returns_unauthenticated_request_for_non_REST_request_without_WPCOM_credentials() { + func test_authenticateRequest_returns_unauthenticated_request_for_non_REST_request_without_WPCOM_credentials() throws { // Given let authenticator = RequestAuthenticator(credentials: nil) - let request = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: false) + let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: false) // When - var updatedRequest: URLRequestConvertible? - authenticator.authenticateRequest(request) { result in - updatedRequest = try? result.get() - } + let request = try jetpackRequest.asURLRequest() + let updatedRequest = try authenticator.authenticate(request) // Then - XCTAssertTrue(updatedRequest is UnauthenticatedRequest) + XCTAssertNil(updatedRequest.allHTTPHeaderFields?["Authorization"]) } - func test_authenticatedRequest_returns_authenticated_request_for_non_REST_request_with_WPCOM_credentials() { + func test_authenticatedRequest_returns_authenticated_request_for_non_REST_request_with_WPCOM_credentials() throws { // Given let credentials = Credentials(authToken: "secret") let authenticator = RequestAuthenticator(credentials: credentials) - let request = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: false) + let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: false) // When - var updatedRequest: URLRequestConvertible? - authenticator.authenticateRequest(request) { result in - updatedRequest = try? result.get() - } + let request = try jetpackRequest.asURLRequest() + let updatedRequest = try authenticator.authenticate(request) // Then - XCTAssertTrue(updatedRequest is AuthenticatedRequest) + let authorizationValue = try XCTUnwrap(updatedRequest.allHTTPHeaderFields?["Authorization"]) + XCTAssertTrue(authorizationValue.hasPrefix("Bearer")) } func test_authenticatedRequest_returns_REST_request_with_authorization_header_if_application_password_is_available() throws { // Given - let credentials: Credentials = .wporg(username: "admin", password: "supersecret", siteAddress: "https://test.com/") + let siteURL = "https://test.com/" + let credentials: Credentials = .wporg(username: "admin", password: "supersecret", siteAddress: siteURL) let applicationPassword = ApplicationPassword(wpOrgUsername: credentials.username, password: .init(credentials.secret)) let useCase = MockApplicationPasswordUseCase(mockApplicationPassword: applicationPassword) let authenticator = RequestAuthenticator(credentials: credentials, applicationPasswordUseCase: useCase) let wooAPIVersion = WooAPIVersion.mark1 - let basePath = "wp-json" - let jetpackRequest = JetpackRequest(wooApiVersion: wooAPIVersion, method: .get, siteID: 123, path: "test", availableAsRESTRequest: true) + let basePath = RESTRequest.Settings.basePath + let restRequest = RESTRequest(siteURL: siteURL, wooApiVersion: wooAPIVersion, method: .get, path: "test") // When - var updatedRequest: URLRequestConvertible? - waitForExpectation { expectation in - authenticator.authenticateRequest(jetpackRequest) { result in - updatedRequest = try? result.get() - expectation.fulfill() - } - } + let request = try restRequest.asURLRequest() + let updatedRequest = try authenticator.authenticate(request) // Then - let request = try XCTUnwrap(updatedRequest as? URLRequest) let expectedURL = "https://test.com/\(basePath)\(wooAPIVersion.path)test" - assertEqual(expectedURL, request.url?.absoluteString) - let authorizationValue = try XCTUnwrap(request.allHTTPHeaderFields?["Authorization"]) + assertEqual(expectedURL, updatedRequest.url?.absoluteString) + let authorizationValue = try XCTUnwrap(updatedRequest.allHTTPHeaderFields?["Authorization"]) XCTAssertTrue(authorizationValue.hasPrefix("Basic")) } - func test_authenticatedRequest_returns_REST_request_with_authorization_header_if_application_password_generation_succeeds() throws { + func test_authenticatedRequest_returns_REST_request_with_authorization_header_if_application_password_generation_succeeds() async throws { // Given - let credentials: Credentials = .wporg(username: "admin", password: "supersecret", siteAddress: "https://test.com/") + let siteURL = "https://test.com/" + let credentials: Credentials = .wporg(username: "admin", password: "supersecret", siteAddress: siteURL) let applicationPassword = ApplicationPassword(wpOrgUsername: credentials.username, password: .init(credentials.secret)) let useCase = MockApplicationPasswordUseCase(mockGeneratedPassword: applicationPassword) let authenticator = RequestAuthenticator(credentials: credentials, applicationPasswordUseCase: useCase) let wooAPIVersion = WooAPIVersion.mark1 - let basePath = "wp-json" - let jetpackRequest = JetpackRequest(wooApiVersion: wooAPIVersion, method: .get, siteID: 123, path: "test", availableAsRESTRequest: true) + let basePath = RESTRequest.Settings.basePath + let restRequest = RESTRequest(siteURL: siteURL, wooApiVersion: wooAPIVersion, method: .get, path: "test") // When - var updatedRequest: URLRequestConvertible? - waitForExpectation { expectation in - authenticator.authenticateRequest(jetpackRequest) { result in - updatedRequest = try? result.get() - expectation.fulfill() - } + let request = try restRequest.asURLRequest() + + do { + let _ = try authenticator.authenticate(request) + } catch RequestAuthenticatorError.applicationPasswordNotAvailable { + try await authenticator.generateApplicationPassword() } + let updatedRequest = try authenticator.authenticate(request) + // Then - let request = try XCTUnwrap(updatedRequest as? URLRequest) let expectedURL = "https://test.com/\(basePath)\(wooAPIVersion.path)test" - assertEqual(expectedURL, request.url?.absoluteString) - let authorizationValue = try XCTUnwrap(request.allHTTPHeaderFields?["Authorization"]) + assertEqual(expectedURL, updatedRequest.url?.absoluteString) + let authorizationValue = try XCTUnwrap(updatedRequest.allHTTPHeaderFields?["Authorization"]) XCTAssertTrue(authorizationValue.hasPrefix("Basic")) } - func test_authenticatedRequest_returns_error_if_generating_application_password_fails_for_REST_request() throws { + func test_authenticatedRequest_returns_error_if_generating_application_password_fails_for_REST_request() async throws { // Given - let credentials: Credentials = .wporg(username: "admin", password: "supersecret", siteAddress: "https://test.com/") + let siteURL = "https://test.com/" + let credentials: Credentials = .wporg(username: "admin", password: "supersecret", siteAddress: siteURL) let useCase = MockApplicationPasswordUseCase(mockGenerationError: NetworkError.timeout) let authenticator = RequestAuthenticator(credentials: credentials, applicationPasswordUseCase: useCase) - let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: true) + let wooAPIVersion = WooAPIVersion.mark1 + let restRequest = RESTRequest(siteURL: siteURL, wooApiVersion: wooAPIVersion, method: .get, path: "test") + + let exp = expectation(description: "Failed with `NetworkError.timeout` error") // When - var error: Error? - waitForExpectation { expectation in - authenticator.authenticateRequest(jetpackRequest) { result in - error = result.failure - expectation.fulfill() + let request = try restRequest.asURLRequest() + do { + let _ = try authenticator.authenticate(request) + } catch RequestAuthenticatorError.applicationPasswordNotAvailable { + // Then + do { + try await authenticator.generateApplicationPassword() + let _ = try authenticator.authenticate(request) + } catch NetworkError.timeout { + exp.fulfill() } } + await waitForExpectations(timeout: Constants.expectationTimeout, handler: nil) + } + + func test_shouldRetry_returns_true_for_REST_request() throws { + // Given + let siteURL = "https://test.com/" + let credentials: Credentials = .wporg(username: "admin", password: "supersecret", siteAddress: siteURL) + let useCase = MockApplicationPasswordUseCase(mockGenerationError: NetworkError.timeout) + let authenticator = RequestAuthenticator(credentials: credentials, applicationPasswordUseCase: useCase) + let wooAPIVersion = WooAPIVersion.mark1 + let restRequest = RESTRequest(siteURL: siteURL, wooApiVersion: wooAPIVersion, method: .get, path: "test") + + // When + let request = try restRequest.asURLRequest() + + // Then + XCTAssertTrue(authenticator.shouldRetry(request)) + } + + func test_shouldRetry_returns_true_for_non_REST_request() throws { + // Given + let siteURL = "https://test.com/" + let credentials: Credentials = .wporg(username: "admin", password: "supersecret", siteAddress: siteURL) + let useCase = MockApplicationPasswordUseCase(mockGenerationError: NetworkError.timeout) + let authenticator = RequestAuthenticator(credentials: credentials, applicationPasswordUseCase: useCase) + let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: false) + + // When + let request = try jetpackRequest.asURLRequest() // Then - let networkError = try XCTUnwrap(error as? NetworkError) - XCTAssertEqual(networkError, NetworkError.timeout) + XCTAssertFalse(authenticator.shouldRetry(request)) } } /// MOCK: application password use case /// private final class MockApplicationPasswordUseCase: ApplicationPasswordUseCase { - let mockApplicationPassword: ApplicationPassword? + var mockApplicationPassword: ApplicationPassword? let mockGeneratedPassword: ApplicationPassword? let mockGenerationError: Error? let mockDeletionError: Error? @@ -134,6 +165,8 @@ private final class MockApplicationPasswordUseCase: ApplicationPasswordUseCase { func generateNewPassword() async throws -> Networking.ApplicationPassword { if let mockGeneratedPassword { + // Store the newly generated password + mockApplicationPassword = mockGeneratedPassword return mockGeneratedPassword } throw mockGenerationError ?? NetworkError.notFound diff --git a/Networking/NetworkingTests/Network/RequestConverterTests.swift b/Networking/NetworkingTests/Network/RequestConverterTests.swift new file mode 100644 index 00000000000..3fad41ce528 --- /dev/null +++ b/Networking/NetworkingTests/Network/RequestConverterTests.swift @@ -0,0 +1,69 @@ +import XCTest +import Alamofire +@testable import Networking + +final class RequestConvertorTests: XCTestCase { + func test_jetpack_request_is_returned_when_credentials_not_available() { + // Given + let converter = RequestConverter(credentials: nil) + let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: true) + + // When + let request = converter.convert(jetpackRequest) + + // Then + XCTAssertTrue(request is JetpackRequest) + } + + func test_jetpack_request_is_returned_for_WPCOM_credentials_when_available_as_REST_request() { + // Given + let credentials = Credentials(authToken: "secret") + let converter = RequestConverter(credentials: credentials) + let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: true) + + // When + let request = converter.convert(jetpackRequest) + + // Then + XCTAssertTrue(request is JetpackRequest) + } + + func test_jetpack_request_is_returned_for_WPCOM_credentials_when_not_available_as_REST_request() { + // Given + let credentials = Credentials(authToken: "secret") + let converter = RequestConverter(credentials: credentials) + let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: false) + + // When + let request = converter.convert(jetpackRequest) + + // Then + XCTAssertTrue(request is JetpackRequest) + } + + func test_REST_request_is_returned_for_WPOrg_credentials_when_available_as_REST_request() { + // Given + let credentials: Credentials = .wporg(username: "admin", password: "supersecret", siteAddress: "https://test.com/") + let converter = RequestConverter(credentials: credentials) + let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: true) + + // When + let request = converter.convert(jetpackRequest) + + // Then + XCTAssertTrue(request is RESTRequest) + } + + func test_jetpack_request_is_returned_for_WPOrg_credentials_when_not_available_as_REST_request() { + // Given + let credentials: Credentials = .wporg(username: "admin", password: "supersecret", siteAddress: "https://test.com/") + let converter = RequestConverter(credentials: credentials) + let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: false) + + // When + let request = converter.convert(jetpackRequest) + + // Then + XCTAssertTrue(request is JetpackRequest) + } +} diff --git a/Networking/NetworkingTests/Requests/AuthenticatedDotcomRequestTests.swift b/Networking/NetworkingTests/Requests/AuthenticatedDotcomRequestTests.swift new file mode 100644 index 00000000000..e6b2918fe90 --- /dev/null +++ b/Networking/NetworkingTests/Requests/AuthenticatedDotcomRequestTests.swift @@ -0,0 +1,77 @@ +import Foundation +import XCTest +import WordPressShared +@testable import Networking + +/// AuthenticatedDotcomRequest Unit Tests +/// +final class AuthenticatedDotcomRequestTests: XCTestCase { + + /// Sample Unauthenticated Request + /// + private var unauthenticatedRequest: URLRequest! + + /// Sample Auth Token + /// + private let authToken = "yosemite" + + override func setUp() { + super.setUp() + + unauthenticatedRequest = try! URLRequest(url: "www.automattic.com", method: .get) + } + + override func tearDown() { + unauthenticatedRequest = nil + + super.tearDown() + } + + /// Verifies that the Bearer Token is injected, as part of the HTTP Headers. + /// + func test_bearer_token_is_injected_as_request_header_when_authenticated_using_WPCOM_token() { + // Given + XCTAssertEqual(unauthenticatedRequest.allHTTPHeaderFields, [:]) + + // When + let authenticated = AuthenticatedDotcomRequest(authToken: authToken, request: unauthenticatedRequest) + let output = authenticated.asURLRequest() + + // Then + let generated = output.allHTTPHeaderFields?["Authorization"] + let expected = "Bearer \(authToken)" + XCTAssertEqual(generated, expected) + } + + /// Verifies that the User Agent is injected as part of the HTTP Headers. + /// + func test_user_agent_is_injected_as_request_header_when_authenticated_using_WPCOM_token() { + // Given + XCTAssertEqual(unauthenticatedRequest.allHTTPHeaderFields, [:]) + + + // When + let authenticated = AuthenticatedDotcomRequest(authToken: authToken, request: unauthenticatedRequest) + let output = authenticated.asURLRequest() + + // Then + let generated = output.allHTTPHeaderFields?["User-Agent"] + XCTAssertEqual(generated, UserAgent.defaultUserAgent) + } + + /// Verifies that the `Accept` header is injected, as part of the HTTP Headers. + /// + func test_accept_is_injected_as_request_header_when_authenticated_using_WPCOM_token() { + // Given + XCTAssertEqual(unauthenticatedRequest.allHTTPHeaderFields, [:]) + + // When + let authenticated = AuthenticatedDotcomRequest(authToken: authToken, request: unauthenticatedRequest) + let output = authenticated.asURLRequest() + + // Then + let generated = output.allHTTPHeaderFields?["Accept"] + let expected = "application/json" + XCTAssertEqual(generated, expected) + } +} diff --git a/Networking/NetworkingTests/Requests/AuthenticatedRESTRequestTests.swift b/Networking/NetworkingTests/Requests/AuthenticatedRESTRequestTests.swift new file mode 100644 index 00000000000..25d8732f051 --- /dev/null +++ b/Networking/NetworkingTests/Requests/AuthenticatedRESTRequestTests.swift @@ -0,0 +1,90 @@ +import Foundation +import XCTest +import WordPressShared +@testable import Networking + +/// AuthenticatedRESTRequest Unit Tests +/// +final class AuthenticatedRESTRequestTests: XCTestCase { + + /// Sample Unauthenticated Request + /// + private var unauthenticatedRequest: URLRequest! + + /// Sample Application Password + /// + private let applicationPassword = ApplicationPassword(wpOrgUsername: "username", password: Secret("password")) + + override func setUp() { + super.setUp() + + unauthenticatedRequest = try! URLRequest(url: "www.automattic.com", method: .get) + } + + override func tearDown() { + unauthenticatedRequest = nil + + super.tearDown() + } + + /// Verifies that Basic authorization string is injected, as part of the HTTP Headers. + /// + func test_basic_is_injected_as_request_header_when_authenticated_using_application_password() throws { + // Given + XCTAssertEqual(unauthenticatedRequest.allHTTPHeaderFields, [:]) + + let authenticated = AuthenticatedRESTRequest(applicationPassword: applicationPassword, request: unauthenticatedRequest) + + // When + let output = authenticated.asURLRequest() + + // Then + let generated = try XCTUnwrap(output.allHTTPHeaderFields?["Authorization"]) + XCTAssertTrue(generated.hasPrefix("Basic")) + } + + /// Verifies that the User Agent is injected as part of the HTTP Headers. + /// + func test_user_agent_is_injected_as_request_header_when_authenticated_using_application_password() { + // Given + XCTAssertEqual(unauthenticatedRequest.allHTTPHeaderFields, [:]) + let authenticated = AuthenticatedRESTRequest(applicationPassword: applicationPassword, request: unauthenticatedRequest) + + // When + let output = authenticated.asURLRequest() + + // Then + let generated = output.allHTTPHeaderFields?["User-Agent"] + XCTAssertEqual(generated, UserAgent.defaultUserAgent) + } + + /// Verifies that the `Accept` header is injected, as part of the HTTP Headers. + /// + func test_accept_is_injected_as_request_header_when_authenticated_using_application_password() { + // Given + XCTAssertEqual(unauthenticatedRequest.allHTTPHeaderFields, [:]) + let authenticated = AuthenticatedRESTRequest(applicationPassword: applicationPassword, request: unauthenticatedRequest) + + // When + let output = authenticated.asURLRequest() + + // Then + let generated = output.allHTTPHeaderFields?["Accept"] + let expected = "application/json" + XCTAssertEqual(generated, expected) + } + + /// Verifies that handling cookies is turned off + /// + func test_httpShouldHandleCookies_is_false_when_authenticated_using_application_password() { + // Given + XCTAssertEqual(unauthenticatedRequest.allHTTPHeaderFields, [:]) + let authenticated = AuthenticatedRESTRequest(applicationPassword: applicationPassword, request: unauthenticatedRequest) + + // When + let output = authenticated.asURLRequest() + + // Then + XCTAssertFalse(output.httpShouldHandleCookies) + } +} diff --git a/Networking/NetworkingTests/Requests/AuthenticatedRequestTests.swift b/Networking/NetworkingTests/Requests/AuthenticatedRequestTests.swift deleted file mode 100644 index 7712fff1fe3..00000000000 --- a/Networking/NetworkingTests/Requests/AuthenticatedRequestTests.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Foundation -import XCTest -@testable import Networking - - -/// AuthenticatedRequest Unit Tests -/// -class AuthenticatedRequestTests: XCTestCase { - - /// Sample Unauthenticated Request - /// - private let unauthenticatedRequest = try! URLRequest(url: "www.automattic.com", method: .get) - - /// Sample Credentials - /// - private let credentials = Credentials(username: "username", authToken: "yosemite", siteAddress: "https://wordpress.com") - - - /// Verifies that the Bearer Token is injected, as part of the HTTP Headers. - /// - func test_bearer_token_is_injected_as_request_header() { - XCTAssertEqual(unauthenticatedRequest.allHTTPHeaderFields, [:]) - - let authenticated = AuthenticatedRequest(credentials: credentials, request: unauthenticatedRequest) - let output = try! authenticated.asURLRequest() - - guard case let .wpcom(_, authToken, _) = credentials else { - XCTFail("Missing credentials.") - return - } - - let generated = output.allHTTPHeaderFields?["Authorization"] - let expected = "Bearer \(authToken)" - XCTAssertEqual(generated, expected) - } - - /// Verifies that the User Agent is injected as part of the HTTP Headers. - /// - func test_user_agent_is_injected_as_request_header() { - let authenticated = AuthenticatedRequest(credentials: credentials, request: unauthenticatedRequest) - let output = try! authenticated.asURLRequest() - - let generated = output.allHTTPHeaderFields?["User-Agent"] - XCTAssertEqual(generated, UserAgent.defaultUserAgent) - } -} diff --git a/Networking/NetworkingTests/Requests/JetpackRequestTests.swift b/Networking/NetworkingTests/Requests/JetpackRequestTests.swift index c7c8bf7ea60..3f7227cd496 100644 --- a/Networking/NetworkingTests/Requests/JetpackRequestTests.swift +++ b/Networking/NetworkingTests/Requests/JetpackRequestTests.swift @@ -14,6 +14,10 @@ final class JetpackRequestTests: XCTestCase { /// private let sampleSiteID: Int64 = 1234 + /// Sample site address + /// + private let sampleSiteAddress = "https://wordpress.com" + /// RPC Sample Method Path /// private let sampleRPC = "sample" @@ -120,6 +124,42 @@ final class JetpackRequestTests: XCTestCase { let output = try! request.asURLRequest() XCTAssertTrue((output.url?.absoluteString.contains("locale=fr_FR"))!) } + + // MARK: `RESTRequest` conversion + + func test_it_is_converted_into_RESTRequest_when_availableAsRESTRequest_is_true() throws { + // Given + let request = JetpackRequest(wooApiVersion: .mark3, + method: .post, + siteID: sampleSiteID, + path: sampleRPC, + parameters: sampleParameters, + availableAsRESTRequest: true) + + // When + let output = try XCTUnwrap(request.asRESTRequest(with: sampleSiteAddress)) + + // Then + XCTAssertEqual(output.wooApiVersion, .mark3) + XCTAssertEqual(output.method, .post) + XCTAssertEqual(output.path, sampleRPC) + let params = try XCTUnwrap(output.parameters as? [String: String]) + XCTAssertEqual(params, sampleParameters) + XCTAssertEqual(output.siteURL, sampleSiteAddress) + } + + func test_converting_into_RESTRequest_is_nil_when_availableAsRESTRequest_is_false() { + // Given + let request = JetpackRequest(wooApiVersion: .mark3, + method: .post, + siteID: sampleSiteID, + path: sampleRPC, + parameters: sampleParameters, + availableAsRESTRequest: false) + + // Then + XCTAssertNil(request.asRESTRequest(with: sampleSiteAddress)) + } } diff --git a/Networking/NetworkingTests/Requests/UnauthenticatedRequestTests.swift b/Networking/NetworkingTests/Requests/UnauthenticatedRequestTests.swift index 74a6861e247..780020f6b51 100644 --- a/Networking/NetworkingTests/Requests/UnauthenticatedRequestTests.swift +++ b/Networking/NetworkingTests/Requests/UnauthenticatedRequestTests.swift @@ -13,10 +13,23 @@ final class UnauthenticatedRequestTests: XCTestCase { let request = UnauthenticatedRequest(request: unauthenticatedRequest) // When - let urlRequest = try request.asURLRequest() + let urlRequest = request.asURLRequest() // Then let userAgentHeader = urlRequest.allHTTPHeaderFields?["User-Agent"] XCTAssertEqual(userAgentHeader, UserAgent.defaultUserAgent) } + + /// Verifies that the `Accept` header is injected, as part of the HTTP Headers. + func test_accept_is_injected_as_request_header() { + // Given + let request = UnauthenticatedRequest(request: unauthenticatedRequest) + + // When + let urlRequest = request.asURLRequest() + + // Then + let acceptHeader = urlRequest.allHTTPHeaderFields?["Accept"] + XCTAssertEqual(acceptHeader, "application/json") + } } diff --git a/docs/NETWORKING.md b/docs/NETWORKING.md index ba4e4ec40c1..c9a229c9459 100644 --- a/docs/NETWORKING.md +++ b/docs/NETWORKING.md @@ -43,7 +43,9 @@ A protocol the abstracts the actual URL requests. At the moment, we provide four implementations of `URLRequestConvertible`: * [`DotcomRequest`](../Networking/Networking/Requests/DotcomRequest.swift) models requests to WordPress.com * [`JetpackRequest`](../Networking/Networking/Requests/JetpackRequest.swift) represents a Jetpack-Tunneled WordPress.com -* [`AuthenticatedRequest`](../Networking/Networking/Requests/AuthenticatedRequest.swift) Wraps up a `URLRequestConvertible` instance, and injects credentials (username and token) when required +* [`RESTRequest`](../Networking/Networking/Requests/RESTRequest.swift) represents a REST API request sent to the site (instead of through the Jetpack tunnel) directly. +* [`AuthenticatedDotcomRequest`](../Networking/Networking/Requests/AuthenticatedDotcomRequest.swift) Wraps up a `URLRequestConvertible` instance, and injects WordPress.com authentication token. +* [`AuthenticatedRESTRequest`](../Networking/Networking/Requests/AuthenticatedRESTRequest.swift) Wraps up a `URLRequestConvertible` instance, and injects application password. * [`WordPressOrgRequest`](../Networking/Networking/Requests/WordPressOrgRequest.swift) model requests to the WordPress.org REST API. * [`UnauthenticatedRequest`](../Networking/Networking/Requests/UnauthenticatedRequest.swift) Wraps up a `URLRequestConvertible` instance, and injects a custom user-agent header diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 3a527ed94b4..2f772c92dc9 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -169,14 +169,24 @@ of performing this task for us: Analog to DotcomRequest, this structure represents a Jetpack Endpoint request. Capable of building a ready-to-use URLRequest for a "Jetpack Tunneled" endpoint. -3. **AuthenticatedRequest** +3. **RESTRequest** - Injects a set of Credentials and a custom user-agent header into anything that conforms to the URLConvertible protocol. Usually wraps up - a DotcomRequest (OR) JetpackRequest. + Represents a REST API request which will be used to contact to the site directly. (Skipping Jetpack tunnel) + These requests are then authenticated using an application password using `AuthenticatedRESTRequest`. -4. **UnauthenticatedRequest** +4. **AuthenticatedDotcomRequest** - Wraps up a `URLConvertible` with a custom user-agent header. Used when the request does not require WordPress.com authentication. + Injects a WordPress.com authentication token and a custom user-agent header into a URLRequest. + Used for authenticating a DotcomRequest (OR) JetpackRequest. + +5. **AuthenticatedRESTRequest** + + Injects application password and a custom user-agent header into a URLRequest. + Used for authenticating `RESTRequest`. + +6. **UnauthenticatedRequest** + + Wraps up a `URLRequest` with a custom user-agent header. Used when the request does not require WordPress.com authentication. ### Remote Endpoints