diff --git a/CHANGELOG.md b/CHANGELOG.md index 2178275f..c4a2b624 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ _None._ ### Breaking Changes -_None._ +- Rewrite `WordPressOrgRestApi` to support self hosted sites and WordPress.com sites. [#724] ### New Features diff --git a/WordPressKit.xcodeproj/project.pbxproj b/WordPressKit.xcodeproj/project.pbxproj index a3244082..078cd8b5 100644 --- a/WordPressKit.xcodeproj/project.pbxproj +++ b/WordPressKit.xcodeproj/project.pbxproj @@ -143,13 +143,14 @@ 465F88B1263B3E8A00F4C950 /* get_wp_v2_themes_twentytwentyone.json in Resources */ = {isa = PBXBuildFile; fileRef = 465F88B0263B3E8A00F4C950 /* get_wp_v2_themes_twentytwentyone.json */; }; 465F88B7263B455300F4C950 /* get_wp_v2_themes_twentytwentyone-no-colors.json in Resources */ = {isa = PBXBuildFile; fileRef = 465F88B6263B455300F4C950 /* get_wp_v2_themes_twentytwentyone-no-colors.json */; }; 465F88BF263B54EE00F4C950 /* ChecksumUtilTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465F88BE263B54EE00F4C950 /* ChecksumUtilTests.swift */; }; - 467C20692626243D00DB5A38 /* WordPressRestApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 467C20682626243D00DB5A38 /* WordPressRestApi.swift */; }; 46ABD0E0262EED3D00C7FF24 /* WordPressOrgXMLRPCValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46ABD0DF262EED3D00C7FF24 /* WordPressOrgXMLRPCValidatorTests.swift */; }; 46ABD0E6262EEDAB00C7FF24 /* FakeInfoDictionaryObjectProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46ABD0E5262EEDAB00C7FF24 /* FakeInfoDictionaryObjectProvider.swift */; }; 46ABD0EA262EEE0400C7FF24 /* AppTransportSecuritySettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46ABD0E9262EEE0400C7FF24 /* AppTransportSecuritySettingsTests.swift */; }; - 4A05E7942B2FCB1D00C25E3B /* CookieNonceAuthenticatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A05E7932B2FCB1D00C25E3B /* CookieNonceAuthenticatorTests.swift */; }; 4A05E7962B2FCB6400C25E3B /* NonceRetrieval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A05E7952B2FCB6400C25E3B /* NonceRetrieval.swift */; }; 4A05E7982B2FCB9A00C25E3B /* NonceRetrievalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A05E7972B2FCB9A00C25E3B /* NonceRetrievalTests.swift */; }; + 4A05E79A2B2FDC3200C25E3B /* WordPressOrgRestApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A05E7992B2FDC3200C25E3B /* WordPressOrgRestApi.swift */; }; + 4A05E79C2B2FDC6100C25E3B /* WordPressOrgAPITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A05E79B2B2FDC6100C25E3B /* WordPressOrgAPITests.swift */; }; + 4A05E79E2B30F3C500C25E3B /* HTTPRequestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A05E79D2B30F3C500C25E3B /* HTTPRequestHelpers.swift */; }; 4A05E7A62B34142200C25E3B /* xmlrpc-response-invalid.html in Resources */ = {isa = PBXBuildFile; fileRef = 4A05E7A52B34142200C25E3B /* xmlrpc-response-invalid.html */; }; 4A05E7A82B34EAF400C25E3B /* xmlrpc-response-mobile-plugin-redirect.html in Resources */ = {isa = PBXBuildFile; fileRef = 4A05E7A72B34EAF400C25E3B /* xmlrpc-response-mobile-plugin-redirect.html */; }; 4A05E7AA2B34FC4300C25E3B /* xmlrpc-response-list-methods.xml in Resources */ = {isa = PBXBuildFile; fileRef = 4A05E7A92B34FC4300C25E3B /* xmlrpc-response-list-methods.xml */; }; @@ -161,6 +162,9 @@ 4A1123A22B19690C004690CF /* MultipartFormTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1123A12B19690C004690CF /* MultipartFormTests.swift */; }; 4A1DEF44293051BC00322608 /* LoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A1DEF43293051BC00322608 /* LoggingTests.swift */; }; 4A1DEF46293051C600322608 /* LoggingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 4A1DEF45293051C600322608 /* LoggingTests.m */; }; + 4A3239642B73132B00EFD2A8 /* SelfHostedPluginManagementClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A3239632B73132B00EFD2A8 /* SelfHostedPluginManagementClientTests.swift */; }; + 4A3239662B7314E200EFD2A8 /* self-hosted-plugins-get.json in Resources */ = {isa = PBXBuildFile; fileRef = 4A3239652B7314E200EFD2A8 /* self-hosted-plugins-get.json */; }; + 4A3239682B74319400EFD2A8 /* self-hosted-plugins-install.json in Resources */ = {isa = PBXBuildFile; fileRef = 4A3239672B74319400EFD2A8 /* self-hosted-plugins-install.json */; }; 4A40F6552B2A5A1A0015DA77 /* WordPressAPIErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A40F6542B2A5A1A0015DA77 /* WordPressAPIErrorTests.swift */; }; 4A57A6812B549144008D0660 /* WordPressComRestApiTests+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A57A6802B549144008D0660 /* WordPressComRestApiTests+Error.swift */; }; 4A57A6832B54A326008D0660 /* WordPressAPIError+NSErrorBrdige.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A57A6822B54A326008D0660 /* WordPressAPIError+NSErrorBrdige.swift */; }; @@ -696,10 +700,7 @@ FEFFD99726C158F400F34231 /* share-app-content-success.json in Resources */ = {isa = PBXBuildFile; fileRef = FEFFD99626C158F400F34231 /* share-app-content-success.json */; }; FEFFD99B26C1598F00F34231 /* ShareAppContentServiceRemoteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEFFD99A26C1598F00F34231 /* ShareAppContentServiceRemoteTests.swift */; }; FF20AD2220B8471A00082398 /* WordPressKit.podspec in Resources */ = {isa = PBXBuildFile; fileRef = FF20AD2120B8471A00082398 /* WordPressKit.podspec */; }; - FF807251241FB90E00809AF5 /* WordPressOrgRestApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF80724F241FB90E00809AF5 /* WordPressOrgRestApi.swift */; }; - FF807252241FB90E00809AF5 /* Authenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF807250241FB90E00809AF5 /* Authenticator.swift */; }; FFA4D4AA2423B10A00BF5180 /* WordPressOrgRestApiTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA4D4A82423B10A00BF5180 /* WordPressOrgRestApiTests.swift */; }; - FFA4D4AB2423B10A00BF5180 /* AuthenticatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFA4D4A92423B10A00BF5180 /* AuthenticatorTests.swift */; }; FFA4D4AD2423B1FE00BF5180 /* wp-admin-post-new.html in Resources */ = {isa = PBXBuildFile; fileRef = FFA4D4AC2423B1FE00BF5180 /* wp-admin-post-new.html */; }; FFA4D4B02423B33800BF5180 /* wp-forbidden.json in Resources */ = {isa = PBXBuildFile; fileRef = FFA4D4AE2423B33800BF5180 /* wp-forbidden.json */; }; FFA4D4B12423B33800BF5180 /* wp-pages.json in Resources */ = {isa = PBXBuildFile; fileRef = FFA4D4AF2423B33800BF5180 /* wp-pages.json */; }; @@ -863,13 +864,14 @@ 465F88B0263B3E8A00F4C950 /* get_wp_v2_themes_twentytwentyone.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = get_wp_v2_themes_twentytwentyone.json; sourceTree = ""; }; 465F88B6263B455300F4C950 /* get_wp_v2_themes_twentytwentyone-no-colors.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "get_wp_v2_themes_twentytwentyone-no-colors.json"; sourceTree = ""; }; 465F88BE263B54EE00F4C950 /* ChecksumUtilTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChecksumUtilTests.swift; sourceTree = ""; }; - 467C20682626243D00DB5A38 /* WordPressRestApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressRestApi.swift; sourceTree = ""; }; 46ABD0DF262EED3D00C7FF24 /* WordPressOrgXMLRPCValidatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WordPressOrgXMLRPCValidatorTests.swift; sourceTree = ""; }; 46ABD0E5262EEDAB00C7FF24 /* FakeInfoDictionaryObjectProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FakeInfoDictionaryObjectProvider.swift; sourceTree = ""; }; 46ABD0E9262EEE0400C7FF24 /* AppTransportSecuritySettingsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppTransportSecuritySettingsTests.swift; sourceTree = ""; }; - 4A05E7932B2FCB1D00C25E3B /* CookieNonceAuthenticatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CookieNonceAuthenticatorTests.swift; sourceTree = ""; }; 4A05E7952B2FCB6400C25E3B /* NonceRetrieval.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NonceRetrieval.swift; sourceTree = ""; }; 4A05E7972B2FCB9A00C25E3B /* NonceRetrievalTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NonceRetrievalTests.swift; sourceTree = ""; }; + 4A05E7992B2FDC3200C25E3B /* WordPressOrgRestApi.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WordPressOrgRestApi.swift; sourceTree = ""; }; + 4A05E79B2B2FDC6100C25E3B /* WordPressOrgAPITests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WordPressOrgAPITests.swift; sourceTree = ""; }; + 4A05E79D2B30F3C500C25E3B /* HTTPRequestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPRequestHelpers.swift; sourceTree = ""; }; 4A05E7A52B34142200C25E3B /* xmlrpc-response-invalid.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = "xmlrpc-response-invalid.html"; sourceTree = ""; }; 4A05E7A72B34EAF400C25E3B /* xmlrpc-response-mobile-plugin-redirect.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = "xmlrpc-response-mobile-plugin-redirect.html"; sourceTree = ""; }; 4A05E7A92B34FC4300C25E3B /* xmlrpc-response-list-methods.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = "xmlrpc-response-list-methods.xml"; sourceTree = ""; }; @@ -881,6 +883,9 @@ 4A1123A12B19690C004690CF /* MultipartFormTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultipartFormTests.swift; sourceTree = ""; }; 4A1DEF43293051BC00322608 /* LoggingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingTests.swift; sourceTree = ""; }; 4A1DEF45293051C600322608 /* LoggingTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = LoggingTests.m; sourceTree = ""; }; + 4A3239632B73132B00EFD2A8 /* SelfHostedPluginManagementClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfHostedPluginManagementClientTests.swift; sourceTree = ""; }; + 4A3239652B7314E200EFD2A8 /* self-hosted-plugins-get.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "self-hosted-plugins-get.json"; sourceTree = ""; }; + 4A3239672B74319400EFD2A8 /* self-hosted-plugins-install.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "self-hosted-plugins-install.json"; sourceTree = ""; }; 4A40F6542B2A5A1A0015DA77 /* WordPressAPIErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressAPIErrorTests.swift; sourceTree = ""; }; 4A57A6802B549144008D0660 /* WordPressComRestApiTests+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WordPressComRestApiTests+Error.swift"; sourceTree = ""; }; 4A57A6822B54A326008D0660 /* WordPressAPIError+NSErrorBrdige.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WordPressAPIError+NSErrorBrdige.swift"; sourceTree = ""; }; @@ -1427,10 +1432,7 @@ FEFFD99626C158F400F34231 /* share-app-content-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "share-app-content-success.json"; sourceTree = ""; }; FEFFD99A26C1598F00F34231 /* ShareAppContentServiceRemoteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAppContentServiceRemoteTests.swift; sourceTree = ""; }; FF20AD2120B8471A00082398 /* WordPressKit.podspec */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = WordPressKit.podspec; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; - FF80724F241FB90E00809AF5 /* WordPressOrgRestApi.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WordPressOrgRestApi.swift; sourceTree = ""; }; - FF807250241FB90E00809AF5 /* Authenticator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Authenticator.swift; sourceTree = ""; }; FFA4D4A82423B10A00BF5180 /* WordPressOrgRestApiTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WordPressOrgRestApiTests.swift; sourceTree = ""; }; - FFA4D4A92423B10A00BF5180 /* AuthenticatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticatorTests.swift; sourceTree = ""; }; FFA4D4AC2423B1FE00BF5180 /* wp-admin-post-new.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = "wp-admin-post-new.html"; sourceTree = ""; }; FFA4D4AE2423B33800BF5180 /* wp-forbidden.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "wp-forbidden.json"; sourceTree = ""; }; FFA4D4AF2423B33800BF5180 /* wp-pages.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "wp-pages.json"; sourceTree = ""; }; @@ -1629,8 +1631,8 @@ isa = PBXGroup; children = ( 46ABD0DF262EED3D00C7FF24 /* WordPressOrgXMLRPCValidatorTests.swift */, - 4A05E7932B2FCB1D00C25E3B /* CookieNonceAuthenticatorTests.swift */, 4A05E7972B2FCB9A00C25E3B /* NonceRetrievalTests.swift */, + 4A05E79B2B2FDC6100C25E3B /* WordPressOrgAPITests.swift */, ); path = WordPressAPI; sourceTree = ""; @@ -1830,7 +1832,6 @@ 93BD273E1EE732CC002BB00B /* Accounts */, 826016FE1F9FD59400533B6C /* Activity */, F194E1212417ED7E00874408 /* Authentication */, - FFA4D4A92423B10A00BF5180 /* AuthenticatorTests.swift */, FAD1345425909DF500A8FEB1 /* Backup */, FA1D0F8F299535020025D76C /* Blaze */, 465F88A5263B370300F4C950 /* BlockEditorSettings */, @@ -2479,6 +2480,8 @@ C738CAF628622B94001BE107 /* qrlogin-authenticate-200.json */, C738CAF828622BB1001BE107 /* qrlogin-authenticate-failed-400.json */, 0CB1905F2A2A6943004D3E80 /* blaze-campaigns-search.json */, + 4A3239652B7314E200EFD2A8 /* self-hosted-plugins-get.json */, + 4A3239672B74319400EFD2A8 /* self-hosted-plugins-install.json */, ); path = "Mock Data"; sourceTree = ""; @@ -2486,10 +2489,8 @@ 93BD27731EE7388E002BB00B /* WordPressAPI */ = { isa = PBXGroup; children = ( - FF807250241FB90E00809AF5 /* Authenticator.swift */, 4A05E7952B2FCB6400C25E3B /* NonceRetrieval.swift */, - 467C20682626243D00DB5A38 /* WordPressRestApi.swift */, - FF80724F241FB90E00809AF5 /* WordPressOrgRestApi.swift */, + 4A05E7992B2FDC3200C25E3B /* WordPressOrgRestApi.swift */, 93BD27741EE73944002BB00B /* HTTPAuthenticationAlertController.swift */, 93BD27751EE73944002BB00B /* NSDate+WordPressJSON.h */, 93BD27761EE73944002BB00B /* NSDate+WordPressJSON.m */, @@ -2613,6 +2614,7 @@ E1E89C691FD6BDB1006E7A33 /* PluginDirectoryTests.swift */, BA0637EC2492382200AF8419 /* PluginStateTests.swift */, BA2A78F924A486D300BB6F53 /* SitePluginTests.swift */, + 4A3239632B73132B00EFD2A8 /* SelfHostedPluginManagementClientTests.swift */, ); name = Plugins; sourceTree = ""; @@ -2720,6 +2722,7 @@ 4AE278472B2FBF1100E4D9B1 /* HTTPBodyEncodingTests.swift */, 4AE278492B2FC6C600E4D9B1 /* HTTPHeaderValueParserTests.swift */, 4A05E7AB2B35048A00C25E3B /* RSDParserTests.swift */, + 4A05E79D2B30F3C500C25E3B /* HTTPRequestHelpers.swift */, ); path = Utilities; sourceTree = ""; @@ -2976,6 +2979,7 @@ 93BD27641EE73442002BB00B /* me-sites-visibility-failure.json in Resources */, 93F50A471F227F3600B5BEBA /* xmlrpc-response-getprofile.xml in Resources */, FA79F1962591809C00D235A9 /* backup-get-backup-status-in-progress-success.json in Resources */, + 4A3239662B7314E200EFD2A8 /* self-hosted-plugins-get.json in Resources */, 7403A2F51EF06FEB00DED7DC /* me-settings-bad-json-failure.json in Resources */, 9AB6D64E218731AB0008F274 /* post-revisions-mapping-success.json in Resources */, 404057C7221B36070060250C /* stats-search-term-result.json in Resources */, @@ -3109,6 +3113,7 @@ 93BD275F1EE73442002BB00B /* me-sites-auth-failure.json in Resources */, 74585BA11F0D6F5300E7E667 /* domain-service-empty.json in Resources */, 74C473C51EF33242009918F2 /* site-active-purchases-two-active-success.json in Resources */, + 4A3239682B74319400EFD2A8 /* self-hosted-plugins-install.json in Resources */, BA7366C62490CE3100BCAC94 /* plugin-service-remote-featured.json in Resources */, 74C473BF1EF32B64009918F2 /* site-export-bad-json-failure.json in Resources */, 40819775221E497D00A298E4 /* stats-published-posts.json in Resources */, @@ -3300,7 +3305,6 @@ C7A09A53284104DB003096ED /* QRLoginValidationResponse.swift in Sources */, F181EA0227184D3D00F26141 /* ProductServiceRemote.swift in Sources */, 32E1DD23236AA09A008914B0 /* RemotePostAutosave.swift in Sources */, - FF807252241FB90E00809AF5 /* Authenticator.swift in Sources */, 464BAB0B262F6736006AEED5 /* RemoteBlockEditorSettings.swift in Sources */, FEFFD99126C1347D00F34231 /* ShareAppContentServiceRemote.swift in Sources */, 74585B8E1F0D51A100E7E667 /* DomainsServiceRemote.swift in Sources */, @@ -3326,6 +3330,7 @@ E6B0460925E5B4DD00DF6F4F /* RemoteInviteLink.swift in Sources */, 436D56332118D7AA00CEAA33 /* TransactionsServiceRemote.swift in Sources */, 93BD27721EE737A9002BB00B /* ServiceRemoteWordPressXMLRPC.m in Sources */, + 4A05E79A2B2FDC3200C25E3B /* WordPressOrgRestApi.swift in Sources */, C76F456825B9F30E00BFEC87 /* JetpackScanHistory.swift in Sources */, E1D6B556200E46F300325669 /* WPTimeZone.swift in Sources */, 93F50A3F1F227C8900B5BEBA /* UsersServiceRemoteXMLRPC.swift in Sources */, @@ -3357,7 +3362,6 @@ 4041405E220F9EF500CF7C5B /* StatsDotComFollowersInsight.swift in Sources */, 74650F721F0EA1A700188EDB /* GravatarServiceRemote.swift in Sources */, B5969E1D20A49AC4005E9DF1 /* NSString+MD5.m in Sources */, - FF807251241FB90E00809AF5 /* WordPressOrgRestApi.swift in Sources */, 01438D3B2B6A36BF0097D60A /* StatsTotalsSummaryData.swift in Sources */, 4A68E3E1294076C1004AC3DC /* RemoteReaderSiteInfo.swift in Sources */, 3F391E1A2B50F3EB007975C4 /* Result+Callback.swift in Sources */, @@ -3440,7 +3444,6 @@ 93C674F21EE8351E00BFAF05 /* NSMutableDictionary+Helpers.m in Sources */, 4624222D2548BA0F002B8A12 /* RemoteSiteDesign.swift in Sources */, 74D67F061F1528470010C5ED /* PeopleServiceRemote.swift in Sources */, - 467C20692626243D00DB5A38 /* WordPressRestApi.swift in Sources */, 98DC787522BAEBF200267279 /* StatsAllAnnualInsight.swift in Sources */, 740B23C31F17EE8000067A2A /* RemotePostCategory.m in Sources */, 8B2F4BF124ACE3C30056C08A /* RemoteReaderInterest.swift in Sources */, @@ -3541,6 +3544,7 @@ 8B2F4BE524ABB3C70056C08A /* RemoteReaderPostTests.m in Sources */, 4AA5A1A32AA68F6B00969464 /* MediaLibraryTestSupport.swift in Sources */, 748437EF1F1D4D8B00E8DDAF /* MenusServiceRemoteTests.m in Sources */, + 4A3239642B73132B00EFD2A8 /* SelfHostedPluginManagementClientTests.swift in Sources */, 73D5930121E550F500E4CF84 /* SiteVerticalsResponseDecodingTests.swift in Sources */, FE5096682A309E4600DDD071 /* JetpackSocialServiceRemoteTests.swift in Sources */, 74A44DD41F13C6D8006CD8F4 /* RemoteNotificationTests.swift in Sources */, @@ -3550,6 +3554,7 @@ 74D67F0A1F15C24C0010C5ED /* PeopleServiceRemoteTests.swift in Sources */, 4A1123A22B19690C004690CF /* MultipartFormTests.swift in Sources */, 9F3E0BAE20873836009CB5BA /* ReaderTopicServiceRemoteTest+Subscriptions.swift in Sources */, + 4A05E79E2B30F3C500C25E3B /* HTTPRequestHelpers.swift in Sources */, BA0637ED2492382200AF8419 /* PluginStateTests.swift in Sources */, 7328420621CD798A00126755 /* WordPressComServiceRemoteTests+SiteCreation.swift in Sources */, FACBDD3825ECB4480026705B /* ReaderPostServiceRemote+RelatedPostsTests.swift in Sources */, @@ -3568,7 +3573,6 @@ 4A6B4A842B26974F00802316 /* HTTPRequestBuilderTests.swift in Sources */, 465F88BF263B54EE00F4C950 /* ChecksumUtilTests.swift in Sources */, 826017001F9FD60A00533B6C /* ActivityServiceRemoteTests.swift in Sources */, - 4A05E7942B2FCB1D00C25E3B /* CookieNonceAuthenticatorTests.swift in Sources */, 93F50A3A1F226BB600B5BEBA /* WordPressComServiceRemoteRestTests.swift in Sources */, E13EE14C1F332C4400C15787 /* PluginServiceRemoteTests.swift in Sources */, 736C971021E80D48007A4200 /* SiteVerticalsPromptResponseDecodingTests.swift in Sources */, @@ -3586,6 +3590,7 @@ FEEFD8B7280EC91B00A3E261 /* BloggingPromptsServiceRemoteTests.swift in Sources */, 73B3DAD621FBB20D00B2CF18 /* WordPressComRestApiTests+Locale.swift in Sources */, C738CAEF28622325001BE107 /* QRLoginServiceRemoteTests.swift in Sources */, + 4A05E79C2B2FDC6100C25E3B /* WordPressOrgAPITests.swift in Sources */, 3297E1DE2564653A00287D21 /* JetpackScanServiceRemoteTests.swift in Sources */, 01438D352B6A2B2C0097D60A /* stats-visits-month-unit-week.json in Sources */, 9F3E0BAC20873785009CB5BA /* ServiceRequestTest.swift in Sources */, @@ -3643,7 +3648,6 @@ 7EC60EBE22DC4F9000FB0336 /* EditorServiceRemoteTests.swift in Sources */, 931924241F1662FA0069CBCC /* JSONLoader.swift in Sources */, E1787DB2200E5690004CB3AF /* TimeZoneServiceRemoteTests.swift in Sources */, - FFA4D4AB2423B10A00BF5180 /* AuthenticatorTests.swift in Sources */, FA1D0F8E299534FF0025D76C /* BlazeServiceRemoteTests.swift in Sources */, 740B23D21F17F6BB00067A2A /* PostServiceRemoteRESTTests.m in Sources */, F9E56DFB24EB18C300916770 /* FeatureFlagRemoteTests.swift in Sources */, diff --git a/WordPressKit/Authenticator.swift b/WordPressKit/Authenticator.swift deleted file mode 100644 index d094e594..00000000 --- a/WordPressKit/Authenticator.swift +++ /dev/null @@ -1,153 +0,0 @@ -import Alamofire -import Foundation -import WordPressShared - -public typealias RequestAuthenticationValidator = (URLRequest) -> Bool - -// MARK: - Authenticator - -public protocol Authenticator: RequestRetrier & RequestAdapter {} - -public extension Authenticator { - func should(_ manager: SessionManager, retry request: Request, with error: Error, completion: @escaping RequestRetryCompletion) { - completion(false, 0.0) - } -} - -// MARK: - Token Auth - -public struct TokenAuthenticator: Authenticator { - fileprivate let token: Secret - fileprivate let shouldAuthenticate: RequestAuthenticationValidator - - public init(token: String, shouldAuthenticate: RequestAuthenticationValidator? = nil) { - self.token = Secret(token) - self.shouldAuthenticate = shouldAuthenticate ?? { _ in true } - } - - public func adapt(_ urlRequest: URLRequest) throws -> URLRequest { - guard shouldAuthenticate(urlRequest) else { - return urlRequest - } - var adaptedRequest = urlRequest - adaptedRequest.addValue("Bearer \(token.secretValue)", forHTTPHeaderField: "Authorization") - return adaptedRequest - } -} - -// MARK: - Cookie Nonce Auth - -public class CookieNonceAuthenticator: Authenticator { - private let username: String - private let password: Secret - private let loginURL: URL - private let adminURL: URL - private let version: String - private var nonce: Secret? - // If we can't get this to work once, don't retry for the same site - // It is likely that there is something preventing us from extracting a nonce - private var canRetry = true - private var isAuthenticating = false - private var requestsToRetry = [RequestRetryCompletion]() - private var nonceRetrievalMethod: NonceRetrievalMethod = .newPostScrap - - public init(username: String, password: String, loginURL: URL, adminURL: URL, version: String = "5.0", nonce: String? = nil) { - self.username = username - self.password = Secret(password) - self.loginURL = loginURL - self.adminURL = adminURL - self.nonce = nonce.map(Secret.init) - self.version = version - let isVersionAtLeast5_3_0 = version.compare("5.3.0", options: .numeric) != .orderedAscending - if isVersionAtLeast5_3_0 { - nonceRetrievalMethod = .ajaxNonceRequest - } - } - - // MARK: Request Adapter - - public func adapt(_ urlRequest: URLRequest) throws -> URLRequest { - guard let nonce = nonce else { - return urlRequest - } - var adaptedRequest = urlRequest - adaptedRequest.addValue(nonce.secretValue, forHTTPHeaderField: "X-WP-Nonce") - return adaptedRequest - } - - // MARK: Retrier - public func should(_ manager: SessionManager, retry request: Request, with error: Swift.Error, completion: @escaping RequestRetryCompletion) { - guard - canRetry, - // Only retry once - request.retryCount == 0, - // And don't retry the login request - request.request?.url != loginURL, - // Only retry because of failed authorization - case .responseValidationFailed(reason: .unacceptableStatusCode(code: 401)) = error as? AFError - else { - return completion(false, 0.0) - } - - requestsToRetry.append(completion) - if !isAuthenticating { - startLoginSequence(manager: manager) - } - } - - enum Error: Swift.Error { - case invalidNewPostURL - case postLoginFailed(Swift.Error) - case missingNonce - case unknown(Swift.Error) - } -} - -// MARK: Private helpers -private extension CookieNonceAuthenticator { - - func startLoginSequence(manager: SessionManager) { - WPKitLogInfo("Starting Cookie+Nonce login sequence for \(loginURL)") - Task { @MainActor in - guard let nonce = await self.nonceRetrievalMethod.retrieveNonce( - username: username, - password: password, - loginURL: loginURL, - adminURL: adminURL, - using: manager.session - ) else { - self.invalidateLoginSequence(error: .missingNonce) - return - } - - self.nonce = Secret(nonce) - self.successfulLoginSequence() - } - } - - func successfulLoginSequence() { - WPKitLogInfo("Completed Cookie+Nonce login sequence for \(loginURL)") - completeRequests(true) - } - - func invalidateLoginSequence(error: Error) { - canRetry = false - if case .postLoginFailed(let originalError) = error { - let nsError = originalError as NSError - if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorNotConnectedToInternet { - canRetry = true - } - } - WPKitLogError("Aborting Cookie+Nonce login sequence for \(loginURL)") - completeRequests(false) - isAuthenticating = false - } - - func completeRequests(_ shouldRetry: Bool) { - requestsToRetry.forEach { (completion) in - completion(shouldRetry, 0.0) - } - requestsToRetry.removeAll() - } - -} diff --git a/WordPressKit/BlockEditorSettingsServiceRemote.swift b/WordPressKit/BlockEditorSettingsServiceRemote.swift index 18ad2ad4..d8ddd37d 100644 --- a/WordPressKit/BlockEditorSettingsServiceRemote.swift +++ b/WordPressKit/BlockEditorSettingsServiceRemote.swift @@ -1,8 +1,8 @@ import Foundation public class BlockEditorSettingsServiceRemote { - let remoteAPI: WordPressRestApi - public init(remoteAPI: WordPressRestApi) { + let remoteAPI: WordPressOrgRestApi + public init(remoteAPI: WordPressOrgRestApi) { self.remoteAPI = remoteAPI } } @@ -11,30 +11,15 @@ public class BlockEditorSettingsServiceRemote { public extension BlockEditorSettingsServiceRemote { typealias EditorThemeCompletionHandler = (Swift.Result) -> Void - func fetchTheme(forSiteID siteID: Int?, _ completion: @escaping EditorThemeCompletionHandler) { + func fetchTheme(completion: @escaping EditorThemeCompletionHandler) { let requestPath = "/wp/v2/themes" - let parameters: [String: AnyObject] = ["status": "active" as AnyObject] - let modifiedPath = remoteAPI.requestPath(fromOrgPath: requestPath, with: siteID) - remoteAPI.GET(modifiedPath, parameters: parameters) { [weak self] (result, _) in - guard let `self` = self else { return } - switch result { - case .success(let response): - self.processEditorThemeResponse(response) { editorTheme in - completion(.success(editorTheme)) - } - case .failure(let error): - completion(.failure(error)) - } - } - } - - private func processEditorThemeResponse(_ response: Any, completion: (_ editorTheme: RemoteEditorTheme?) -> Void) { - guard let responseData = try? JSONSerialization.data(withJSONObject: response, options: []), - let editorThemes = try? JSONDecoder().decode([RemoteEditorTheme].self, from: responseData) else { - completion(nil) - return + let parameters = ["status": "active"] + Task { @MainActor in + let result = await self.remoteAPI.get(path: requestPath, parameters: parameters, type: [RemoteEditorTheme].self) + .map { $0.first } + .mapError { error -> Error in error } + completion(result) } - completion(editorThemes.first) } } @@ -43,30 +28,18 @@ public extension BlockEditorSettingsServiceRemote { public extension BlockEditorSettingsServiceRemote { typealias BlockEditorSettingsCompletionHandler = (Swift.Result) -> Void - func fetchBlockEditorSettings(forSiteID siteID: Int?, _ completion: @escaping BlockEditorSettingsCompletionHandler) { - let requestPath = "/wp-block-editor/v1/settings" - let parameters: [String: AnyObject] = ["context": "mobile" as AnyObject] - let modifiedPath = remoteAPI.requestPath(fromOrgPath: requestPath, with: siteID) - - remoteAPI.GET(modifiedPath, parameters: parameters) { [weak self] (result, _) in - guard let `self` = self else { return } - switch result { - case .success(let response): - self.processBlockEditorSettingsResponse(response) { blockEditorSettings in - completion(.success(blockEditorSettings)) + func fetchBlockEditorSettings(completion: @escaping BlockEditorSettingsCompletionHandler) { + Task { @MainActor in + let result = await self.remoteAPI.get(path: "/wp-block-editor/v1/settings", parameters: ["context": "mobile"], type: RemoteBlockEditorSettings.self) + .map { settings -> RemoteBlockEditorSettings? in settings } + .flatMapError { original in + if case let .unparsableResponse(response, _, underlyingError) = original, response?.statusCode == 200, underlyingError is DecodingError { + return .success(nil) + } + return .failure(original) } - case .failure(let error): - completion(.failure(error)) - } - } - } - - private func processBlockEditorSettingsResponse(_ response: Any, completion: (_ editorTheme: RemoteBlockEditorSettings?) -> Void) { - guard let responseData = try? JSONSerialization.data(withJSONObject: response, options: []), - let blockEditorSettings = try? JSONDecoder().decode(RemoteBlockEditorSettings.self, from: responseData) else { - completion(nil) - return + .mapError { error -> Error in error } + completion(result) } - completion(blockEditorSettings) } } diff --git a/WordPressKit/HTTPClient.swift b/WordPressKit/HTTPClient.swift index 78c02086..feaf718a 100644 --- a/WordPressKit/HTTPClient.swift +++ b/WordPressKit/HTTPClient.swift @@ -162,10 +162,11 @@ extension WordPressAPIResult { } func decodeSuccess( - _ decoder: JSONDecoder = JSONDecoder() + _ decoder: JSONDecoder = JSONDecoder(), + type: NewSuccess.Type = NewSuccess.self ) -> WordPressAPIResult where Success == HTTPAPIResponse, Failure == WordPressAPIError { mapSuccess { - try decoder.decode(NewSuccess.self, from: $0.body) + try decoder.decode(type, from: $0.body) } } diff --git a/WordPressKit/HTTPProtocolHelpers.swift b/WordPressKit/HTTPProtocolHelpers.swift index dcf45718..b21feda1 100644 --- a/WordPressKit/HTTPProtocolHelpers.swift +++ b/WordPressKit/HTTPProtocolHelpers.swift @@ -57,6 +57,22 @@ extension HTTPURLResponse { return Self.value(ofParameter: parameterName, inHeaderValue: headerValue, stripQuotes: stripQuotes) } + func value(forHTTPHeaderField field: String, withoutParameters: Bool) -> String? { + guard withoutParameters else { + return value(forHTTPHeaderField: field) + } + + guard let headerValue = value(forHTTPHeaderField: field) else { + return nil + } + + guard let firstSemicolon = headerValue.firstIndex(of: ";") else { + return headerValue + } + + return String(headerValue[headerValue.startIndex.. String? { // Find location of '=' string in the header. guard let location = headerValue.range(of: parameterName + "=", options: .caseInsensitive) else { diff --git a/WordPressKit/HTTPRequestBuilder.swift b/WordPressKit/HTTPRequestBuilder.swift index 2b266761..885c8844 100644 --- a/WordPressKit/HTTPRequestBuilder.swift +++ b/WordPressKit/HTTPRequestBuilder.swift @@ -51,6 +51,23 @@ final class HTTPRequestBuilder { return self } + /// Append path and query to the original URL. + /// + /// Some may call API client using a string that contains path and query, like `api.get("post?id=1")`. + /// This function can be used to support those use cases. + func appendURLString(_ string: String) -> Self { + let urlString = Self.join("https://w.org", string) + guard let url = URL(string: urlString), + let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) + else { + assertionFailure("Illegal URL string: \(string)") + return self + } + + return append(percentEncodedPath: urlComponents.percentEncodedPath) + .append(query: urlComponents.queryItems ?? []) + } + func header(name: String, value: String?) -> Self { headers[name] = value return self diff --git a/WordPressKit/Plugin Management/SelfHostedPluginManagementClient.swift b/WordPressKit/Plugin Management/SelfHostedPluginManagementClient.swift index 57321614..cfb9e632 100644 --- a/WordPressKit/Plugin Management/SelfHostedPluginManagementClient.swift +++ b/WordPressKit/Plugin Management/SelfHostedPluginManagementClient.swift @@ -7,89 +7,74 @@ public class SelfHostedPluginManagementClient: PluginManagementClient { // MARK: - Get public func getPlugins(success: @escaping (SitePlugins) -> Void, failure: @escaping (Error) -> Void) { - remote.GET(path(), parameters: nil) { (result, _) in - switch result { - case .success(let responseObject): - guard let response = responseObject as? [[String: AnyObject]] else { - failure(PluginServiceRemote.ResponseError.decodingFailure) - return + Task { @MainActor in + await remote.get(path: path(), type: [PluginStateResponse].self) + .mapError { error -> Error in + if case let .unparsableResponse(_, _, underlyingError) = error, underlyingError is DecodingError { + return PluginServiceRemote.ResponseError.decodingFailure } + return error + } + .map { + SitePlugins( + plugins: $0.compactMap { self.pluginState(with: $0) }, + capabilities: SitePluginCapabilities(modify: true, autoupdate: false) + ) + } + .execute(onSuccess: success, onFailure: failure) - let plugins = response.compactMap { (obj) -> PluginState? in - self.pluginState(with: obj) - } - - let result = SitePlugins(plugins: plugins, - capabilities: SitePluginCapabilities(modify: true, autoupdate: false)) - success(result) - - case .failure(let error): - failure(error) - } } } // MARK: - Activate / Deactivate public func activatePlugin(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { - let parameters = ["status": "active"] as [String: AnyObject] + let parameters = ["status": "active"] let path = self.path(with: pluginID) - remote.request(method: .put, path: path, parameters: parameters) { (result, _) in - switch result { - case .success: - success() - - case .failure(let error): - failure(error) - } + Task { @MainActor in + await remote.perform(.put, path: path, parameters: parameters, type: AnyResponse.self) + .map { _ in } + .execute(onSuccess: success, onFailure: failure) + } } public func deactivatePlugin(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { - let parameters = ["status": "inactive"] as [String: AnyObject] + let parameters = ["status": "inactive"] let path = self.path(with: pluginID) - remote.request(method: .put, path: path, parameters: parameters) { (result, _) in - switch result { - case .success: - success() - - case .failure(let error): - failure(error) - } + Task { @MainActor in + await remote.perform(.put, path: path, parameters: parameters, type: AnyResponse.self) + .map { _ in } + .execute(onSuccess: success, onFailure: failure) } - } // MARK: - Install / Uninstall public func install(pluginSlug: String, success: @escaping (PluginState) -> Void, failure: @escaping (Error) -> Void) { - let parameters = ["slug": pluginSlug] as [String: AnyObject] - - remote.request(method: .post, path: path(), parameters: parameters) { (result, _) in - switch result { - case .success(let responseObject): - guard let response = responseObject as? [String: AnyObject], - let plugin = self.pluginState(with: response) else { - failure(PluginServiceRemote.ResponseError.decodingFailure) - return + let parameters = ["slug": pluginSlug] + Task { @MainActor in + await remote.post(path: path(), parameters: parameters, type: PluginStateResponse.self) + .mapError { error -> Error in + if case let .unparsableResponse(_, _, underlyingError) = error, underlyingError is DecodingError { + return PluginServiceRemote.ResponseError.decodingFailure } - - success(plugin) - case .failure(let error): - failure(error) - } + return error + } + .flatMap { + guard let state = self.pluginState(with: $0) else { + return .failure(PluginServiceRemote.ResponseError.decodingFailure) + } + return .success(state) + } + .execute(onSuccess: success, onFailure: failure) } } public func remove(pluginID: String, success: @escaping () -> Void, failure: @escaping (Error) -> Void) { let path = self.path(with: pluginID) - - remote.request(method: .delete, path: path, parameters: nil) { (result, _) in - switch result { - case .success: - success() - - case .failure(let error): - failure(error) - } + Task { @MainActor in + await remote.perform(.delete, path: path, type: AnyResponse.self) + .map { _ in } + .execute(onSuccess: success, onFailure: failure) } } @@ -104,40 +89,29 @@ public class SelfHostedPluginManagementClient: PluginManagementClient { return returnPath } - /// Converts an incoming dictionary response to a PluginState struct - /// - Returns: Returns nil if the dictionary does not pass validation - private func pluginState(with obj: [String: AnyObject]) -> PluginState? { + private func pluginState(with response: PluginStateResponse) -> PluginState? { guard - let id = obj["plugin"] as? String, - // The slugs returned are in the form of XXX/YYY // The PluginStore uses slugs that are just XXX // Extract that information out - let slug = id.components(separatedBy: "/").first, - - let active = obj["status"] as? String, - let name = obj["name"] as? String, - let author = obj["author"] as? String, - let version = obj["version"] as? String + let slug = response.plugin.components(separatedBy: "/").first else { return nil } - let isActive = active == "active" - - // Find the URL - let url = URL(string: (obj["plugin_uri"] as? String) ?? "") + let isActive = response.status == "active" - return PluginState(id: id, + return PluginState(id: response.plugin, slug: slug, active: isActive, - name: name, - author: author, - version: version, + name: response.name, + author: response.author, + version: response.version, updateState: .updated, // API Doesn't support this yet autoupdate: false, // API Doesn't support this yet automanaged: false, // API Doesn't support this yet - url: url, + // TODO: Return nil instead of an empty URL when 'plugin_uri' is nil? + url: URL(string: response.pluginURI ?? ""), settingsURL: nil) } @@ -163,3 +137,26 @@ public class SelfHostedPluginManagementClient: PluginManagementClient { activatePlugin(pluginID: pluginID, success: success, failure: failure) } } + +private struct PluginStateResponse: Decodable { + enum CodingKeys: String, CodingKey { + case plugin = "plugin" + case status = "status" + case name = "name" + case author = "author" + case version = "version" + case pluginURI = "plugin_uri" + } + var plugin: String + var status: String + var name: String + var author: String + var version: String + var pluginURI: String? +} + +private struct AnyResponse: Decodable { + init(from decoder: Decoder) throws { + // Do nothing + } +} diff --git a/WordPressKit/WordPressComRestApi.swift b/WordPressKit/WordPressComRestApi.swift index ef5376e9..26472054 100644 --- a/WordPressKit/WordPressComRestApi.swift +++ b/WordPressKit/WordPressComRestApi.swift @@ -484,11 +484,8 @@ open class WordPressComRestApi: NSObject { } private func requestBuilder(URLString: String) throws -> HTTPRequestBuilder { - guard let url = URL(string: URLString, relativeTo: baseURL) else { - throw URLError(.badURL) - } - - var builder = HTTPRequestBuilder(url: url) + var builder = HTTPRequestBuilder(url: baseURL) + .appendURLString(URLString) if appendsPreferredLanguageLocale { let preferredLanguageIdentifier = WordPressComLanguageDatabase().deviceLanguage.slug @@ -806,24 +803,6 @@ extension WordPressComRestApi { } } -extension WordPressComRestApi: WordPressRestApi { - @discardableResult - public func GET(_ path: String, parameters: [String: AnyObject]?, completion: @escaping Completion) -> Progress? { - return GET(path, parameters: parameters, success: { (responseObject, httpResponse) in - completion(.success(responseObject), httpResponse) - }, failure: { (error, httpResponse) in - completion(.failure(error), httpResponse) - }) - } - - public func requestPath(fromOrgPath path: String, with siteID: Int?) -> String { - guard let siteID = siteID else { return path } - - return path.replacingOccurrences(of: "/wp/v2/", with: "/wp/v2/sites/\(siteID)/") - .replacingOccurrences(of: "/wp-block-editor/v1/", with: "/wp-block-editor/v1/sites/\(siteID)/") - } -} - // MARK: - Constants private extension WordPressComRestApi { diff --git a/WordPressKit/WordPressOrgRestApi.swift b/WordPressKit/WordPressOrgRestApi.swift index b34bba05..f6b3f382 100644 --- a/WordPressKit/WordPressOrgRestApi.swift +++ b/WordPressKit/WordPressOrgRestApi.swift @@ -1,105 +1,268 @@ -import Alamofire import Foundation -/** - Error constants for the WordPress.org REST API +public struct WordPressOrgRestApiError: LocalizedError, Decodable, HTTPURLResponseProviding { + public enum CodingKeys: String, CodingKey { + case code, message + } + + public var code: String + public var message: String? + + var response: HTTPAPIResponse? + + var httpResponse: HTTPURLResponse? { + response?.response + } - - RequestSerializationFailed: The serialization of the request failed - */ -@objc public enum WordPressOrgRestApiError: Int, Error { - case requestSerializationFailed + public var errorDescription: String? { + return message ?? NSLocalizedString( + "wordpresskit.org-rest-api.not-found", + value: "Couldn't find your site's REST API URL. The app needs that in order to communicate with your site. Contact your host to solve this problem.", + comment: "Message to show to user when the app can't find WordPress.org REST API URL." + ) + } } @objc -open class WordPressOrgRestApi: NSObject, WordPressRestApi { - public typealias Completion = (Swift.Result, HTTPURLResponse?) -> Void - private let apiBase: URL - private let authenticator: Authenticator? - private let userAgent: String? - - public init(apiBase: URL, authenticator: Authenticator? = nil, userAgent: String? = nil) { - self.apiBase = apiBase - self.authenticator = authenticator - self.userAgent = userAgent - super.init() - } - - @discardableResult - open func GET(_ path: String, - parameters: [String: AnyObject]?, - completion: @escaping Completion) -> Progress? { - return request(method: .get, path: path, parameters: parameters, completion: completion) - } - - @discardableResult - open func POST(_ path: String, - parameters: [String: AnyObject]?, - completion: @escaping Completion) -> Progress? { - return request(method: .post, path: path, parameters: parameters, completion: completion) - } - - @discardableResult - open func request(method: HTTPMethod, - path: String, - parameters: [String: AnyObject]?, - completion: @escaping Completion) -> Progress? { - let relativePath = path.removingPrefix("/") - guard let url = URL(string: relativePath, relativeTo: apiBase) else { - let error = NSError(domain: String(describing: WordPressOrgRestApiError.self), - code: WordPressOrgRestApiError.requestSerializationFailed.rawValue, - userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("Failed to serialize request to the REST API.", comment: "Error message to show when wrong URL format is used to access the REST API")]) - completion(.failure(error), nil) - return nil +public final class WordPressOrgRestApi: NSObject { + public struct SelfHostedSiteCredential { + public let loginURL: URL + public let username: String + public let password: Secret + public let adminURL: URL + + public init(loginURL: URL, username: String, password: String, adminURL: URL) { + self.loginURL = loginURL + self.username = username + self.password = .init(password) + self.adminURL = adminURL } + } + + enum Site { + case dotCom(siteID: UInt64, bearerToken: String) + case selfHosted(apiURL: URL, credential: SelfHostedSiteCredential) + } + + let site: Site + let urlSession: URLSession + + var selfHostedSiteNonce: String? + + public convenience init(dotComSiteID: UInt64, bearerToken: String, userAgent: String? = nil) { + self.init(site: .dotCom(siteID: dotComSiteID, bearerToken: bearerToken), userAgent: userAgent) + } + + public convenience init(selfHostedSiteWPJSONURL apiURL: URL, credential: SelfHostedSiteCredential, userAgent: String? = nil) { + assert(apiURL.host != "public-api.wordpress.com", "Not a self-hosted site: \(apiURL)") + // Potential improvement(?): discover API URL instead. See https://developer.wordpress.org/rest-api/using-the-rest-api/discovery/ + assert(apiURL.lastPathComponent == "wp-json", "Not a REST API URL: \(apiURL)") + + self.init(site: .selfHosted(apiURL: apiURL, credential: credential), userAgent: userAgent) + } + + init(site: Site, userAgent: String? = nil) { + self.site = site - let progress = Progress(totalUnitCount: 1) - let progressUpdater = {(taskProgress: Progress) in - progress.totalUnitCount = taskProgress.totalUnitCount - progress.completedUnitCount = taskProgress.completedUnitCount + var additionalHeaders = [String: String]() + if let userAgent { + additionalHeaders["User-Agent"] = userAgent } + if case let Site.dotCom(siteID: _, bearerToken: token) = site { + additionalHeaders["Authorization"] = "Bearer \(token)" + } + + let configuration = URLSessionConfiguration.default + configuration.httpAdditionalHeaders = additionalHeaders + urlSession = URLSession(configuration: configuration) + } + + deinit { + urlSession.finishTasksAndInvalidate() + } + + @objc + public func invalidateAndCancelTasks() { + urlSession.invalidateAndCancel() + } + + public func get( + path: String, + parameters: [String: Any]? = nil, + jsonDecoder: JSONDecoder = JSONDecoder(), + type: Success.Type = Success.self + ) async -> WordPressAPIResult { + await perform(.get, path: path, parameters: parameters, jsonDecoder: jsonDecoder, type: type) + } + + public func get( + path: String, + parameters: [String: Any]? = nil, + options: JSONSerialization.ReadingOptions = [] + ) async -> WordPressAPIResult { + await perform(.get, path: path, parameters: parameters, options: options) + } + + public func post( + path: String, + parameters: [String: Any]? = nil, + jsonDecoder: JSONDecoder = JSONDecoder(), + type: Success.Type = Success.self + ) async -> WordPressAPIResult { + await perform(.post, path: path, parameters: parameters, jsonDecoder: jsonDecoder, type: type) + } - let dataRequest = sessionManager.request(url, method: method, parameters: parameters, encoding: URLEncoding.default) - .validate() - .responseJSON(completionHandler: { (response) in - switch response.result { - case .success(let responseObject): - progress.completedUnitCount = progress.totalUnitCount - completion(.success(responseObject), response.response) - case .failure(let error): - completion(.failure(error), response.response) + public func post( + path: String, + parameters: [String: Any]? = nil, + options: JSONSerialization.ReadingOptions = [] + ) async -> WordPressAPIResult { + await perform(.post, path: path, parameters: parameters, options: options) + } + + func perform( + _ method: HTTPRequestBuilder.Method, + path: String, + parameters: [String: Any]? = nil, + jsonDecoder: JSONDecoder = JSONDecoder(), + type: Success.Type = Success.self + ) async -> WordPressAPIResult { + await perform(method, path: path, parameters: parameters) { + try jsonDecoder.decode(type, from: $0) + } + } + + func perform( + _ method: HTTPRequestBuilder.Method, + path: String, + parameters: [String: Any]? = nil, + options: JSONSerialization.ReadingOptions = [] + ) async -> WordPressAPIResult { + await perform(method, path: path, parameters: parameters) { + try JSONSerialization.jsonObject(with: $0, options: options) + } + } + + private func perform( + _ method: HTTPRequestBuilder.Method, + path: String, + parameters: [String: Any]? = nil, + decoder: @escaping (Data) throws -> Success + ) async -> WordPressAPIResult { + var builder = HTTPRequestBuilder(url: apiBaseURL()) + .dotOrgRESTAPI(route: path, site: site) + .method(method) + if method.allowsHTTPBody { + builder = builder.body(form: parameters ?? [:]) + } else { + builder = builder.query(parameters ?? [:]) + } + + return await perform(builder: builder) + .mapSuccess { try decoder($0.body) } + } + + func perform(builder originalBuilder: HTTPRequestBuilder) async -> WordPressAPIResult, WordPressOrgRestApiError> { + var builder = originalBuilder + + if case .selfHosted = site, let nonce = selfHostedSiteNonce { + builder = originalBuilder.header(name: "X-WP-Nonce", value: nonce) + } + + var result = await urlSession.perform(request: builder, errorType: WordPressOrgRestApiError.self) + + // When a self hosted site request fails with 401, authenticate and retry the request. + if case .selfHosted = site, + case let .failure(.unacceptableStatusCode(response, _)) = result, + response.statusCode == 401, + await refreshNonce(), + let nonce = selfHostedSiteNonce { + builder = originalBuilder.header(name: "X-WP-Nonce", value: nonce) + result = await urlSession.perform(request: builder, errorType: WordPressOrgRestApiError.self) + } + + return result + .mapError { error in + if case let .unacceptableStatusCode(response, body) = error { + do { + var endpointError = try JSONDecoder().decode(WordPressOrgRestApiError.self, from: body) + endpointError.response = HTTPAPIResponse(response: response, body: body) + return WordPressAPIError.endpointError(endpointError) + } catch { + return .unparsableResponse(response: response, body: body, underlyingError: error) + } + } + return error } + } + +} - }).downloadProgress(closure: progressUpdater) - progress.cancellationHandler = { - dataRequest.cancel() +// MARK: - Authentication + +private extension WordPressOrgRestApi { + func apiBaseURL() -> URL { + switch site { + case .dotCom: + return URL(string: "https://public-api.wordpress.com")! + case let .selfHosted(apiURL, _): + return apiURL } - return progress } - /** - Cancels all ongoing and makes the session so the object will not fullfil any more request - */ - @objc open func invalidateAndCancelTasks() { - sessionManager.session.invalidateAndCancel() + /// Fetch REST API nonce from the site. + /// + /// - Returns true if the nonce is fetched and it's different than the cached one. + func refreshNonce() async -> Bool { + guard case let .selfHosted(_, credential) = site else { + return false + } + + var refreshed = false + + let methods: [NonceRetrievalMethod] = [.ajaxNonceRequest, .newPostScrap] + for method in methods { + guard let nonce = await method.retrieveNonce( + username: credential.username, + password: credential.password, + loginURL: credential.loginURL, + adminURL: credential.adminURL, + using: urlSession + ) else { + continue + } + + refreshed = selfHostedSiteNonce != nonce + + selfHostedSiteNonce = nonce + break + } + + return refreshed } +} - private lazy var sessionManager: Alamofire.SessionManager = { - let sessionConfiguration = URLSessionConfiguration.default - let sessionManager = self.makeSessionManager(configuration: sessionConfiguration) - return sessionManager - }() +// MARK: - Helpers - private func makeSessionManager(configuration sessionConfiguration: URLSessionConfiguration) -> Alamofire.SessionManager { - var additionalHeaders: [String: AnyObject] = [:] - if let userAgent = self.userAgent { - additionalHeaders["User-Agent"] = userAgent as AnyObject? +private extension HTTPRequestBuilder { + func dotOrgRESTAPI(route aRoute: String, site: WordPressOrgRestApi.Site) -> Self { + var route = aRoute + if !route.hasPrefix("/") { + route = "/" + route } - sessionConfiguration.httpAdditionalHeaders = additionalHeaders + switch site { + case let .dotCom(siteID, _): + // Currently only the following namespaces are supported. When adding more supported namespaces, remember to + // update the "path adapter" code below for the REST API in WP.COM. + assert(route.hasPrefix("/wp/v2") || route.hasPrefix("/wp-block-editor/v1"), "Unsupported .org REST API route: \(route)") + route = route + .replacingOccurrences(of: "/wp/v2/", with: "/wp/v2/sites/\(siteID)/") + .replacingOccurrences(of: "/wp-block-editor/v1/", with: "/wp-block-editor/v1/sites/\(siteID)/") + case let .selfHosted(apiURL, _): + assert(apiURL.lastPathComponent == "wp-json") + } - let sessionManager = Alamofire.SessionManager(configuration: sessionConfiguration) - sessionManager.adapter = authenticator - sessionManager.retrier = authenticator - return sessionManager + return appendURLString(route) } } diff --git a/WordPressKit/WordPressRestApi.swift b/WordPressKit/WordPressRestApi.swift deleted file mode 100644 index 9cac3669..00000000 --- a/WordPressKit/WordPressRestApi.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Foundation - -/// This class offers a wrapper to provide a common interface between `WordPressOrgRestApi` and `WordPressComRestApi` for classes that can use either -/// one nearly interchangably. -public protocol WordPressRestApi { - typealias Completion = (Swift.Result, HTTPURLResponse?) -> Void - - /** - Executes a GET request to the specified endpoint defined on URLString - - - parameter path: the url string to be added to the baseURL - - parameter parameters: the parameters to be encoded on the request - - parameter completion: callback to be called on successful or failed request. - - - returns: a NSProgress object that can be used to track the progress of the request and to cancel the request. If the method - returns nil it's because something happened on the request serialization and the network request was not started, but the failure callback - will be invoked with the error specificing the serialization issues. - */ - @discardableResult func GET(_ path: String, parameters: [String: AnyObject]?, completion: @escaping Completion) -> Progress? - - /** - Modifies the provided path from an Org endpoint as needed to support a generic path for either `WordPressOrgRestApi` and `WordPressComRestApi`. This is meant to be - used as a helper before calling the GET request. - - - parameter path: the url string to be added to the baseURL - - parameter siteID: the unique ID that represents a site. - - - returns: a modified path or the same path depending on the implementation. A default implementation is provided that returns the same path. - */ - func requestPath(fromOrgPath path: String, with siteID: Int?) -> String -} - -extension WordPressRestApi { - public func requestPath(fromOrgPath path: String, with siteID: Int?) -> String { - return path - } -} diff --git a/WordPressKitTests/AuthenticatorTests.swift b/WordPressKitTests/AuthenticatorTests.swift deleted file mode 100644 index ed0a0a63..00000000 --- a/WordPressKitTests/AuthenticatorTests.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Alamofire -import OHHTTPStubs -import XCTest -import WordPressKit - -let apiBase = URL(string: "https://example.com/wp-json/")! -let loginURL = URL(string: "https://example.com/wp-login.php")! -let adminURL = URL(string: "https://example.com/wp-admin/")! -let apiRequest = URLRequest(url: apiBase) -let authorizationHeader = "Authorization" -let nonceHeader = "X-WP-Nonce" - -class AuthenticatorTests: XCTestCase { - var manager = SessionManager() - - override func setUp() { - manager = SessionManager() - } - - override func tearDown() { - HTTPStubs.removeAllStubs() - manager.session.invalidateAndCancel() - } - - // MARK: - Token Adapter tests - - func testTokenAuthenticatorAdaptsRequests() { - let authenticator = TokenAuthenticator(token: "TOKEN") - let adapted = try! authenticator.adapt(apiRequest) - let authorization = adapted.value(forHTTPHeaderField: authorizationHeader) - XCTAssertEqual(authorization, "Bearer TOKEN", "TokenAuthenticator should add Authorization header") - } - - func testTokenAuthenticatorDoesNotAdaptRequestsIfConditionFails() { - let authenticator = TokenAuthenticator(token: "TOKEN", shouldAuthenticate: { _ in false }) - let adapted = try! authenticator.adapt(apiRequest) - let authorization = adapted.value(forHTTPHeaderField: authorizationHeader) - XCTAssertNil(authorization, "TokenAuthenticator should not add Authorization header if the condition is false") - } - - // MARK: - CookieNonce Adapter tests - - func testCookieNonceAuthenticatorAdaptsRequestIfItHasNonce() { - let authenticator = CookieNonceAuthenticator(username: "user", password: "pass", loginURL: loginURL, adminURL: adminURL, nonce: "TEST_NONCE") - let adapted = try! authenticator.adapt(apiRequest) - let nonce = adapted.value(forHTTPHeaderField: nonceHeader) - XCTAssertEqual(nonce, "TEST_NONCE", "CookieNonceAuthenticator should add nonce header if it has a nonce") - } - - func testCookieNonceAuthenticatorDoesNotAdaptRequestIfItDoesNotHaveNonce() { - let authenticator = CookieNonceAuthenticator(username: "user", password: "pass", loginURL: loginURL, adminURL: adminURL) - let adapted = try! authenticator.adapt(apiRequest) - let nonce = adapted.value(forHTTPHeaderField: nonceHeader) - XCTAssertNil(nonce, "CookieNonceAuthenticator should not add nonce header if it does not have one") - } - - // MARK: - CookieNonce Retrier tests - func testCookieNonceAuthenticatorRetriesOnlyOnce() { - stub(condition: isLoginRequest()) { _ in - let stubPath = OHPathForFile("wp-admin-post-new.html", type(of: self)) - return fixture(filePath: stubPath!, headers: ["Content-Type" as NSObject: "text/html" as AnyObject]) - } - - let authenticator = CookieNonceAuthenticator(username: "user", password: "pass", loginURL: loginURL, adminURL: adminURL) - let error = AFError.responseValidationFailed(reason: .unacceptableStatusCode(code: 401)) - let completionExpectation = expectation(description: "First retry completion is called") - var shouldRetry: Bool? - let completion: (Bool, TimeInterval) -> Void = { (retry, _) in - shouldRetry = retry - completionExpectation.fulfill() - } - let request = manager.request(apiRequest) - authenticator.should(manager, retry: request, with: error, completion: completion) - wait(for: [completionExpectation], timeout: 2) - XCTAssertEqual(shouldRetry, true, "CookieNonceAuthenticator should retry request with 401 error if everything goes well") - } - -} - -private extension AuthenticatorTests { - func isLoginRequest() -> HTTPStubsTestBlock { - return { request in - return request.url?.absoluteString.hasPrefix(loginURL.absoluteString) ?? false - } - } -} diff --git a/WordPressKitTests/BlockEditorSettingsServiceRemoteTests.swift b/WordPressKitTests/BlockEditorSettingsServiceRemoteTests.swift index 4dbbc8e4..e81a8afc 100644 --- a/WordPressKitTests/BlockEditorSettingsServiceRemoteTests.swift +++ b/WordPressKitTests/BlockEditorSettingsServiceRemoteTests.swift @@ -17,7 +17,7 @@ class BlockEditorSettingsServiceRemoteTests: XCTestCase { XCTFail("Unexpected request: \($0)") return HTTPStubsResponse(error: URLError(URLError.Code.networkConnectionLost)) } - service = BlockEditorSettingsServiceRemote(remoteAPI: WordPressComRestApi()) + service = BlockEditorSettingsServiceRemote(remoteAPI: .init(site: .dotCom(siteID: UInt64(siteID), bearerToken: "token"))) } func mockedData(withFilename filename: String) -> AnyObject { @@ -36,7 +36,7 @@ extension BlockEditorSettingsServiceRemoteTests { } let waitExpectation = expectation(description: "Theme should be successfully fetched") - service.fetchTheme(forSiteID: siteID) { (response) in + service.fetchTheme { response in switch response { case .success(let result): XCTAssertNotNil(result) @@ -68,7 +68,7 @@ extension BlockEditorSettingsServiceRemoteTests { } let waitExpectation = expectation(description: "Theme should be successfully fetched") - service.fetchTheme(forSiteID: siteID) { (response) in + service.fetchTheme { response in switch response { case .success(let result): XCTAssertNotNil(result) @@ -103,7 +103,7 @@ extension BlockEditorSettingsServiceRemoteTests { let waitExpectation = expectation(description: "Theme should be successfully fetched") - service.fetchTheme(forSiteID: siteID) { (response) in + service.fetchTheme { response in switch response { case .success(let result): XCTAssertNotNil(result) @@ -134,7 +134,7 @@ extension BlockEditorSettingsServiceRemoteTests { } let waitExpectation = expectation(description: "Theme should be successfully fetched") - service.fetchTheme(forSiteID: siteID) { (response) in + service.fetchTheme { response in switch response { case .success(let result): XCTAssertFalse(result!.themeSupport!.blockTemplates) @@ -153,7 +153,7 @@ extension BlockEditorSettingsServiceRemoteTests { } let waitExpectation = expectation(description: "Theme should be successfully fetched") - service.fetchTheme(forSiteID: siteID) { (response) in + service.fetchTheme { response in switch response { case .success: XCTFail("This Request should have failed") @@ -171,13 +171,32 @@ extension BlockEditorSettingsServiceRemoteTests { // MARK: Editor Global Styles support extension BlockEditorSettingsServiceRemoteTests { + func testFetchBlockEditorSettingsInvalidJSON() { + stub(condition: isHost("public-api.wordpress.com") && isPath("/wp-block-editor/v1/sites/1/settings") && containsQueryParams(["context": "mobile"])) { _ in + HTTPStubsResponse(jsonObject: ["invalid-json"], statusCode: 200, headers: nil) + } + + let waitExpectation = expectation(description: "Block Settings should be successfully fetched") + service.fetchBlockEditorSettings { response in + switch response { + case .success(let result): + XCTAssertNil(result) + case .failure: + XCTFail("This payload should parse successfully") + } + waitExpectation.fulfill() + } + + wait(for: [waitExpectation], timeout: 0.3) + } + func testFetchBlockEditorSettingsNotThemeJSON() { stub(condition: isHost("public-api.wordpress.com") && isPath("/wp-block-editor/v1/sites/1/settings") && containsQueryParams(["context": "mobile"])) { _ in fixture(filePath: OHPathForFile(self.blockSettingsNOTThemeJSONResponseFilename, Self.self)!, headers: ["Content-Type": "application/json"]) } let waitExpectation = expectation(description: "Block Settings should be successfully fetched") - service.fetchBlockEditorSettings(forSiteID: siteID) { (response) in + service.fetchBlockEditorSettings { response in switch response { case .success(let result): self.validateFetchBlockEditorSettingsResults(result) @@ -198,7 +217,7 @@ extension BlockEditorSettingsServiceRemoteTests { } let waitExpectation = expectation(description: "Block Settings should be successfully fetched") - service.fetchBlockEditorSettings(forSiteID: siteID) { (response) in + service.fetchBlockEditorSettings { response in switch response { case .success(let result): self.validateFetchBlockEditorSettingsResults(result) @@ -227,7 +246,7 @@ extension BlockEditorSettingsServiceRemoteTests { let waitExpectation = expectation(description: "Block Settings should be successfully fetched") - service.fetchBlockEditorSettings(forSiteID: siteID) { (response) in + service.fetchBlockEditorSettings { response in switch response { case .success(let result): self.validateFetchBlockEditorSettingsResults(result) @@ -256,7 +275,7 @@ extension BlockEditorSettingsServiceRemoteTests { } let waitExpectation = expectation(description: "Block Settings should be successfully fetched") - service.fetchBlockEditorSettings(forSiteID: siteID) { (response) in + service.fetchBlockEditorSettings { response in switch response { case .success: XCTFail("This Request should have failed") @@ -276,7 +295,7 @@ extension BlockEditorSettingsServiceRemoteTests { let waitExpectation = expectation(description: "Block Settings should be successfully fetched") service = BlockEditorSettingsServiceRemote(remoteAPI: WordPressOrgRestApi(apiBase: URL(string: "https://example.com/wp-json/")!)) - service.fetchBlockEditorSettings(forSiteID: siteID) { (_) in + service.fetchBlockEditorSettings { (_) in waitExpectation.fulfill() } @@ -288,15 +307,13 @@ extension BlockEditorSettingsServiceRemoteTests { // This small difference ends up with an incorrect API request being sent out. Hence this test is marked as an // "expected failure". func testFetchBlockEditorSettingsOrgEndpointFailure() { - XCTExpectFailure("The 'apiBase' value should end with '/wp-json/': The last '/' is required") - stub(condition: isHost("example.com") && isPath("/wp-json/wp-block-editor/v1/settings") && containsQueryParams(["context": "mobile"])) { _ in fixture(filePath: OHPathForFile(self.blockSettingsThemeJSONResponseFilename, Self.self)!, headers: ["Content-Type": "application/json"]) } let waitExpectation = expectation(description: "Block Settings should be successfully fetched") service = BlockEditorSettingsServiceRemote(remoteAPI: WordPressOrgRestApi(apiBase: URL(string: "https://example.com/wp-json")!)) - service.fetchBlockEditorSettings(forSiteID: siteID) { (_) in + service.fetchBlockEditorSettings { (_) in waitExpectation.fulfill() } diff --git a/WordPressKitTests/Mock Data/self-hosted-plugins-get.json b/WordPressKitTests/Mock Data/self-hosted-plugins-get.json new file mode 100644 index 00000000..f692efc8 --- /dev/null +++ b/WordPressKitTests/Mock Data/self-hosted-plugins-get.json @@ -0,0 +1,98 @@ +[ + { + "plugin": "akismet/akismet", + "status": "active", + "name": "Akismet Anti-spam: Spam Protection", + "plugin_uri": "https://akismet.com/", + "author": "Automattic - Anti-spam Team", + "author_uri": "https://automattic.com/wordpress-plugins/", + "description": { + "raw": "Used by millions, Akismet is quite possibly the best way in the world to protect your blog from spam. Akismet Anti-spam keeps your site protected even while you sleep. To get started: activate the Akismet plugin and then go to your Akismet Settings page to set up your API key.", + "rendered": "Used by millions, Akismet is quite possibly the best way in the world to protect your blog from spam. Akismet Anti-spam keeps your site protected even while you sleep. To get started: activate the Akismet plugin and then go to your Akismet Settings page to set up your API key. By Automattic - Anti-spam Team." + }, + "version": "5.3.1", + "network_only": false, + "requires_wp": "5.8", + "requires_php": "5.6.20", + "textdomain": "akismet", + "_links": { + "self": [ + { + "href": "https://unlikely-student-crown.jurassic.ninja/wp-json/wp/v2/plugins/akismet/akismet" + } + ] + } + }, + { + "plugin": "companion/companion", + "status": "active", + "name": "Companion Plugin", + "plugin_uri": "https://github.com/Automattic/companion", + "author": "Osk", + "author_uri": "", + "description": { + "raw": "Helps keep the launched WordPress in order.", + "rendered": "Helps keep the launched WordPress in order. By Osk." + }, + "version": "1.30", + "network_only": false, + "requires_wp": "", + "requires_php": "", + "textdomain": "companion", + "_links": { + "self": [ + { + "href": "https://unlikely-student-crown.jurassic.ninja/wp-json/wp/v2/plugins/companion/companion" + } + ] + } + }, + { + "plugin": "jetpack/jetpack", + "status": "active", + "name": "Jetpack", + "plugin_uri": "https://jetpack.com", + "author": "Automattic", + "author_uri": "https://jetpack.com", + "description": { + "raw": "Security, performance, and marketing tools made by WordPress experts. Jetpack keeps your site protected so you can focus on more important things.", + "rendered": "Security, performance, and marketing tools made by WordPress experts. Jetpack keeps your site protected so you can focus on more important things. By Automattic." + }, + "version": "13.1.1", + "network_only": false, + "requires_wp": "6.3", + "requires_php": "7.0", + "textdomain": "jetpack", + "_links": { + "self": [ + { + "href": "https://unlikely-student-crown.jurassic.ninja/wp-json/wp/v2/plugins/jetpack/jetpack" + } + ] + } + }, + { + "plugin": "woocommerce/woocommerce", + "status": "active", + "name": "WooCommerce", + "plugin_uri": "https://woo.com/", + "author": "Automattic", + "author_uri": "https://woo.com", + "description": { + "raw": "An eCommerce toolkit that helps you sell anything. Beautifully.", + "rendered": "An eCommerce toolkit that helps you sell anything. Beautifully. By Automattic." + }, + "version": "8.5.2", + "network_only": false, + "requires_wp": "6.3", + "requires_php": "7.4", + "textdomain": "woocommerce", + "_links": { + "self": [ + { + "href": "https://unlikely-student-crown.jurassic.ninja/wp-json/wp/v2/plugins/woocommerce/woocommerce" + } + ] + } + } +] diff --git a/WordPressKitTests/Mock Data/self-hosted-plugins-install.json b/WordPressKitTests/Mock Data/self-hosted-plugins-install.json new file mode 100644 index 00000000..7a652105 --- /dev/null +++ b/WordPressKitTests/Mock Data/self-hosted-plugins-install.json @@ -0,0 +1,25 @@ +{ + "plugin": "google-site-kit/google-site-kit", + "status": "inactive", + "name": "Site Kit by Google", + "plugin_uri": "https://sitekit.withgoogle.com", + "author": "Google", + "author_uri": "https://opensource.google.com", + "description": { + "raw": "Site Kit is a one-stop solution for WordPress users to use everything Google has to offer to make them successful on the web.", + "rendered": "Site Kit is a one-stop solution for WordPress users to use everything Google has to offer to make them successful on the web. By Google." + }, + "version": "1.119.0", + "network_only": false, + "requires_wp": "5.2", + "requires_php": "5.6", + "textdomain": "google-site-kit", + "_links": { + "self": [ + { + "href": "https://unlikely-student-crown.jurassic.ninja/wp-json/wp/v2/plugins/google-site-kit/google-site-kit" + } + ] + } +} + diff --git a/WordPressKitTests/SelfHostedPluginManagementClientTests.swift b/WordPressKitTests/SelfHostedPluginManagementClientTests.swift new file mode 100644 index 00000000..5d278b7e --- /dev/null +++ b/WordPressKitTests/SelfHostedPluginManagementClientTests.swift @@ -0,0 +1,114 @@ +import Foundation +import XCTest +import OHHTTPStubs + +@testable import WordPressKit + +class SelfHostedPluginManagementClientTests: XCTestCase { + + override func setUp() { + super.setUp() + + stub(condition: { _ in true }) { + XCTFail("Unexpected request: \($0)") + return HTTPStubsResponse(error: URLError(URLError.Code.networkConnectionLost)) + } + } + + override func tearDown() { + super.tearDown() + + HTTPStubs.removeAllStubs() + } + + func testGetPluginsSuccess() throws { + let response = try fixture(filePath: XCTUnwrap(OHPathForFile("self-hosted-plugins-get.json", type(of: self))), headers: nil) + stub(condition: isHost("wp-site.com") && isPath("/wp-json/wp/v2/plugins")) { _ in + response + } + + let client = SelfHostedPluginManagementClient(with: WordPressOrgRestApi(apiBase: URL(string: "https://wp-site.com/wp-json")!)) + let success = expectation(description: "Get plugins successfully") + client?.getPlugins(success: { plugins in + XCTAssertEqual(plugins.plugins.count, 4) + + let plugin = plugins.plugins.first + XCTAssertEqual(plugin?.name, "Akismet Anti-spam: Spam Protection") + XCTAssertEqual(plugin?.id, "akismet/akismet") + XCTAssertEqual(plugin?.slug, "akismet") + XCTAssertEqual(plugin?.active, true) + XCTAssertEqual(plugin?.url?.absoluteString, "https://akismet.com/") + + success.fulfill() + }, failure: { + XCTFail("Unexpected failure: \($0)") + }) + + wait(for: [success], timeout: 0.3) + } + + func testGetPluginsFailure() throws { + stub(condition: isHost("wp-site.com") && isPath("/wp-json/wp/v2/plugins")) { _ in + HTTPStubsResponse(data: Data(), statusCode: 500, headers: nil) + } + + let client = SelfHostedPluginManagementClient(with: WordPressOrgRestApi(apiBase: URL(string: "https://wp-site.com/wp-json")!)) + let failure = expectation(description: "Get plugins successfully") + client?.getPlugins(success: { + XCTFail("Unexpected success: \($0)") + }, failure: { _ in + failure.fulfill() + }) + wait(for: [failure], timeout: 0.3) + } + + func testActivatePlugin() { + stub(condition: isHost("wp-site.com") + && isPath("/wp-json/wp/v2/plugins/akismet/akismet") + && isMethodPUT() + && hasBody("status=active".data(using: .utf8)!) + ) { _ in + HTTPStubsResponse(data: "{}".data(using: .utf8)!, statusCode: 200, headers: nil) + } + + let client = SelfHostedPluginManagementClient(with: WordPressOrgRestApi(apiBase: URL(string: "https://wp-site.com/wp-json")!)) + let success = expectation(description: "Get plugins successfully") + client?.activatePlugin(pluginID: "akismet/akismet", success: success.fulfill, failure: { XCTFail("Unexpected failure: \($0)") }) + + wait(for: [success], timeout: 0.3) + } + + func testDeactivatePlugin() { + stub(condition: isHost("wp-site.com") + && isPath("/wp-json/wp/v2/plugins/akismet/akismet") + && isMethodPUT() + && hasBody("status=inactive".data(using: .utf8)!) + ) { _ in + HTTPStubsResponse(data: "{}".data(using: .utf8)!, statusCode: 200, headers: nil) + } + + let client = SelfHostedPluginManagementClient(with: WordPressOrgRestApi(apiBase: URL(string: "https://wp-site.com/wp-json")!)) + let success = expectation(description: "Get plugins successfully") + client?.deactivatePlugin(pluginID: "akismet/akismet", success: success.fulfill, failure: { XCTFail("Unexpected failure: \($0)") }) + + wait(for: [success], timeout: 0.3) + } + + func testInstallPlugin() throws { + let response = try fixture(filePath: XCTUnwrap(OHPathForFile("self-hosted-plugins-install.json", type(of: self))), headers: nil) + stub(condition: isHost("wp-site.com") + && isPath("/wp-json/wp/v2/plugins") + && isMethodPOST() + && hasBody("slug=google-site-kit".data(using: .utf8)!) + ) { _ in + response + } + + let client = SelfHostedPluginManagementClient(with: WordPressOrgRestApi(apiBase: URL(string: "https://wp-site.com/wp-json")!)) + let success = expectation(description: "Get plugins successfully") + client?.install(pluginSlug: "google-site-kit", success: { _ in success.fulfill() }, failure: { XCTFail("Unexpected failure: \($0)") }) + + wait(for: [success], timeout: 0.3) + } + +} diff --git a/WordPressKitTests/Utilities/HTTPHeaderValueParserTests.swift b/WordPressKitTests/Utilities/HTTPHeaderValueParserTests.swift index d016597c..66b2dd3c 100644 --- a/WordPressKitTests/Utilities/HTTPHeaderValueParserTests.swift +++ b/WordPressKitTests/Utilities/HTTPHeaderValueParserTests.swift @@ -66,4 +66,14 @@ class HTTPHeaderValueParserTests: XCTestCase { ) } + func testValueWithoutParameters() { + let response = HTTPURLResponse(url: URL(string: "https://site.com")!, statusCode: 200, httpVersion: "2", headerFields: [ + "Link": "https://site.com/wp-json; rel=\"https://api.w.org\"", + "Content-Type": "text/html" + ]) + + XCTAssertEqual(response?.value(forHTTPHeaderField: "Link", withoutParameters: true), "https://site.com/wp-json") + XCTAssertEqual(response?.value(forHTTPHeaderField: "Content-Type", withoutParameters: true), "text/html") + } + } diff --git a/WordPressKitTests/Utilities/HTTPRequestBuilderTests.swift b/WordPressKitTests/Utilities/HTTPRequestBuilderTests.swift index 2893f290..7b008c75 100644 --- a/WordPressKitTests/Utilities/HTTPRequestBuilderTests.swift +++ b/WordPressKitTests/Utilities/HTTPRequestBuilderTests.swift @@ -376,14 +376,31 @@ class HTTPRequestBuilderTests: XCTestCase { } -} - -extension URLRequest { - var httpBodyText: String? { - guard let data = (httpBody ?? httpBodyStream?.readToEnd()) else { - return nil - } - - return String(data: data, encoding: .utf8) + func testAppendingURLString() { + XCTAssertEqual( + try HTTPRequestBuilder(url: URL(string: "https://wordpress.org/test")!) + .appendURLString("foo%2Fbar/path?arg=value") + .build() + .url? + .absoluteString, + "https://wordpress.org/test/foo%2Fbar/path?arg=value" + ) + XCTAssertEqual( + try HTTPRequestBuilder(url: URL(string: "https://wordpress.org/test")!) + .appendURLString("/foo%2Fbar/path?arg=value") + .build() + .url? + .absoluteString, + "https://wordpress.org/test/foo%2Fbar/path?arg=value" + ) + XCTAssertEqual( + try HTTPRequestBuilder(url: URL(string: "https://wordpress.org/test")!) + .appendURLString("/foo%2Fbar/path") + .build() + .url? + .absoluteString, + "https://wordpress.org/test/foo%2Fbar/path" + ) } + } diff --git a/WordPressKitTests/Utilities/HTTPRequestHelpers.swift b/WordPressKitTests/Utilities/HTTPRequestHelpers.swift new file mode 100644 index 00000000..eadc1047 --- /dev/null +++ b/WordPressKitTests/Utilities/HTTPRequestHelpers.swift @@ -0,0 +1,11 @@ +import Foundation + +extension URLRequest { + var httpBodyText: String? { + guard let data = (httpBody ?? httpBodyStream?.readToEnd() ) else { + return nil + } + + return String(data: data, encoding: .utf8) + } +} diff --git a/WordPressKitTests/WordPressAPI/CookieNonceAuthenticatorTests.swift b/WordPressKitTests/WordPressAPI/CookieNonceAuthenticatorTests.swift deleted file mode 100644 index a9b489f3..00000000 --- a/WordPressKitTests/WordPressAPI/CookieNonceAuthenticatorTests.swift +++ /dev/null @@ -1,93 +0,0 @@ -import Foundation -import XCTest -import Alamofire -import OHHTTPStubs - -@testable import WordPressKit - -final class CookieNonceAuthenticatorTests: XCTestCase { - - static let nonce = "leg1tn0nce" - static let siteURL = URL(string: "https://test.com")! - static let siteLoginURL = URL(string: "https://test.com/wp-login.php")! - static let siteAdminURL = URL(string: "https://test.com/wp-admin/")! - static let newPostURL = URL(string: "https://test.com/wp-admin/post-new.php")! - static let ajaxURL = URL(string: "https://test.com/wp-admin/admin-ajax.php?action=rest-nonce")! - static let endpointThatRequiresAuthentication = URL(string: "https://test.com/wp-json/wp/v2/post/delete")! - - override func setUp() { - super.setUp() - stub(condition: isAbsoluteURLString(Self.endpointThatRequiresAuthentication.absoluteString)) { request in - if request.value(forHTTPHeaderField: "X-WP-Nonce") == Self.nonce { - return HTTPStubsResponse(data: Data(), statusCode: 201, headers: nil) - } else { - return HTTPStubsResponse(data: Data(), statusCode: 401, headers: nil) - } - } - } - - override func tearDown() { - super.tearDown() - HTTPStubs.removeAllStubs() - } - - func testUsingNewPostPage() { - stubLoginRedirect(dest: Self.newPostURL) - stubNewPostPage(statusCode: 200) - - let authenticator = CookieNonceAuthenticator(username: "test", password: "password", loginURL: Self.siteLoginURL, adminURL: Self.siteAdminURL, version: "4.0") - let session = SessionManager(configuration: .ephemeral) - session.adapter = authenticator - session.retrier = authenticator - wait(for: [apiCallShouldSucceed(using: session)], timeout: 0.3) - } - - func testUsingRESTNonceAjax() { - stubLoginRedirect(dest: Self.ajaxURL) - stubAjax(statusCode: 200) - - let authenticator = CookieNonceAuthenticator(username: "test", password: "password", loginURL: Self.siteLoginURL, adminURL: Self.siteAdminURL, version: "6.0") - let session = SessionManager(configuration: .ephemeral) - session.adapter = authenticator - session.retrier = authenticator - wait(for: [apiCallShouldSucceed(using: session)], timeout: 0.3) - } - - private func stubLoginRedirect(dest: URL) { - stub(condition: isAbsoluteURLString(Self.siteLoginURL.absoluteString)) { _ in - HTTPStubsResponse(data: Data(), statusCode: 302, headers: ["Location": dest.absoluteString]) - } - } - - private func stubNewPostPage(nonceScript: String? = nil, statusCode: Int32) { - let script = nonceScript ?? """ - wp.apiFetch.nonceMiddleware = wp.apiFetch.createNonceMiddleware( "\(Self.nonce)" ); - wp.apiFetch.use( wp.apiFetch.nonceMiddleware ); - wp.apiFetch.use( wp.apiFetch.mediaUploadMiddleware ); - """ - let html = "\n\(script)\n" - stub(condition: isAbsoluteURLString(Self.newPostURL.absoluteString)) { _ in - HTTPStubsResponse(data: html.data(using: .utf8)!, statusCode: statusCode, headers: nil) - } - } - - private func stubAjax(statusCode: Int32) { - stub(condition: isAbsoluteURLString(Self.ajaxURL.absoluteString)) { _ in - HTTPStubsResponse(data: (statusCode == 200 ? Self.nonce : "...").data(using: .utf8)!, statusCode: statusCode, headers: nil) - } - } - - private func apiCallShouldSucceed(using session: SessionManager) -> XCTestExpectation { - let expectation = expectation(description: "API call should eventually succeed") - session - .request(Self.endpointThatRequiresAuthentication) - .validate() - .responseData { response in - if response.response?.statusCode == 201 { - expectation.fulfill() - } - } - return expectation - } - -} diff --git a/WordPressKitTests/WordPressAPI/WordPressOrgAPITests.swift b/WordPressKitTests/WordPressAPI/WordPressOrgAPITests.swift new file mode 100644 index 00000000..c5f93941 --- /dev/null +++ b/WordPressKitTests/WordPressAPI/WordPressOrgAPITests.swift @@ -0,0 +1,205 @@ +import Foundation +import XCTest +import OHHTTPStubs + +@testable import WordPressKit + +class WordPressOrgAPITests: XCTestCase { + + let fakeCredential = WordPressOrgRestApi.SelfHostedSiteCredential( + loginURL: URL(string: "https://wordpress.org/wp-login.php")!, + username: "test-user", + password: "test-password", + adminURL: URL(string: "https://wordpress.org/wp-admin/")! + ) + + override func tearDown() { + super.tearDown() + HTTPStubs.removeAllStubs() + } + + func testSelfHostedSiteSimpleGet() async { + let expectation = expectation(description: "matches request conditions") + stub(condition: isMethodGET() && isAbsoluteURLString("https://wordpress.org/wp-json/wp/v2/hello-world?foo=bar")) { _ in + expectation.fulfill() + return HTTPStubsResponse(data: Data(), statusCode: 200, headers: nil) + } + let api = WordPressOrgRestApi(selfHostedSiteWPJSONURL: URL(string: "https://wordpress.org/wp-json/")!, credential: fakeCredential) + let _ = await api.get(path: "wp/v2/hello-world", parameters: ["foo": "bar"], type: AnyResponse.self) + await fulfillment(of: [expectation], timeout: 0.1) + } + + func testSelfHostedSiteSimplePost() async { + let expectation = expectation(description: "matches request conditions") + stub(condition: isMethodPOST() && isAbsoluteURLString("https://wordpress.org/wp-json/wp/v2/hello-world")) { request in + XCTAssertEqual(request.httpBodyText, "foo=bar") + expectation.fulfill() + return HTTPStubsResponse(data: Data(), statusCode: 200, headers: nil) + } + let api = WordPressOrgRestApi(selfHostedSiteWPJSONURL: URL(string: "https://wordpress.org/wp-json/")!, credential: fakeCredential) + let _ = await api.post(path: "wp/v2/hello-world", parameters: ["foo": "bar"], type: AnyResponse.self) + await fulfillment(of: [expectation], timeout: 0.1) + } + + func testWPComSiteSimpleGet() async { + let expectation = expectation(description: "matches request conditions") + stub(condition: isMethodGET() && isAbsoluteURLString("https://public-api.wordpress.com/wp/v2/sites/42/hello-world?foo=bar")) { request in + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer faketoken") + expectation.fulfill() + return HTTPStubsResponse(data: Data(), statusCode: 200, headers: nil) + } + let api = WordPressOrgRestApi(dotComSiteID: 42, bearerToken: "faketoken") + let _ = await api.get(path: "wp/v2/hello-world", parameters: ["foo": "bar"], type: AnyResponse.self) + await fulfillment(of: [expectation], timeout: 0.1) + } + + func testWPComSiteSimplePost() async { + let expectation = expectation(description: "matches request conditions") + stub(condition: isMethodPOST() && isAbsoluteURLString("https://public-api.wordpress.com/wp/v2/sites/42/hello-world")) { request in + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer faketoken") + XCTAssertEqual(request.httpBodyText, "foo=bar") + expectation.fulfill() + return HTTPStubsResponse(data: Data(), statusCode: 200, headers: nil) + } + let api = WordPressOrgRestApi(dotComSiteID: 42, bearerToken: "faketoken") + let _ = await api.post(path: "wp/v2/hello-world", parameters: ["foo": "bar"], type: AnyResponse.self) + await fulfillment(of: [expectation], timeout: 0.1) + } + + func testUserAgent() async { + let expectation = expectation(description: "matches user agents") + expectation.expectedFulfillmentCount = 2 + stub(condition: { _ in true }) { request in + expectation.fulfill() + XCTAssertEqual(request.value(forHTTPHeaderField: "User-Agent"), "fake-user-agent") + return HTTPStubsResponse(data: Data(), statusCode: 200, headers: nil) + } + + let selfHostedSite = WordPressOrgRestApi(selfHostedSiteWPJSONURL: URL(string: "https://wordpress.org/wp-json/")!, credential: fakeCredential, userAgent: "fake-user-agent") + let _ = await selfHostedSite.get(path: "/wp/v2/hello-world", type: AnyResponse.self) + + let dotComSite = WordPressOrgRestApi(dotComSiteID: 42, bearerToken: "faketoken", userAgent: "fake-user-agent") + let _ = await dotComSite.get(path: "/wp/v2/hello-world", type: AnyResponse.self) + + await fulfillment(of: [expectation], timeout: 0.1) + } + + func testSelfHostedSiteAuthentication() async throws { + // The expected API call sequence. + // [/wp/v2/hello-world|401] -> [nonce-from-ajax|200] -> [/wp/v2/hello-world|200] -> END + + let unauthenticatedReuqest = expectation(description: "Call #1: call an endpoint with a unauthenticated request") + let ajaxNonceRequest = expectation(description: "Call #2: call ajax endpoint to get nonce") + let authenticatedReuqest = expectation(description: "Call #3: call an endpoint with an authenticated request") + + stub(condition: { $0.url?.lastPathComponent == "hello-world" }) { request in + if request.value(forHTTPHeaderField: "X-WP-Nonce") == "fakenonce" { + authenticatedReuqest.fulfill() + return HTTPStubsResponse(jsonObject: [String: String](), statusCode: 200, headers: nil) + } else { + unauthenticatedReuqest.fulfill() + return HTTPStubsResponse(jsonObject: [String: String](), statusCode: 401, headers: nil) + } + } + + stub(condition: { $0.url?.lastPathComponent == "admin-ajax.php" }) { _ in + ajaxNonceRequest.fulfill() + return HTTPStubsResponse(data: "fakenonce".data(using: .utf8)!, statusCode: 200, headers: nil) + } + + let selfHostedSite = WordPressOrgRestApi(selfHostedSiteWPJSONURL: URL(string: "https://wordpress.org/wp-json/")!, credential: fakeCredential) + let result = await selfHostedSite.get(path: "/wp/v2/hello-world", type: AnyResponse.self) + + // This call should not throw + _ = try result.get() + + await fulfillment(of: [unauthenticatedReuqest, ajaxNonceRequest, authenticatedReuqest], timeout: 0.1, enforceOrder: true) + + + // Remove added stubs and add a new one. + let requestsHasNonce = expectation(description: "Call an endpoint with an authenticated request") + requestsHasNonce.expectedFulfillmentCount = 3 + HTTPStubs.removeAllStubs() + stub(condition: { $0.url?.lastPathComponent == "hello-world" }) { request in + if request.value(forHTTPHeaderField: "X-WP-Nonce") == "fakenonce" { + requestsHasNonce.fulfill() + return HTTPStubsResponse(jsonObject: [String: String](), statusCode: 200, headers: nil) + } else { + XCTFail("Unexpected request: \(request)") + return HTTPStubsResponse(jsonObject: [String: String](), statusCode: 401, headers: nil) + } + } + + // Call the same API three times, which should re-use the fetched nonce without refetching requests. + let _ = await selfHostedSite.get(path: "/wp/v2/hello-world", type: AnyResponse.self) + let _ = await selfHostedSite.get(path: "/wp/v2/hello-world", type: AnyResponse.self) + let _ = await selfHostedSite.get(path: "/wp/v2/hello-world", type: AnyResponse.self) + + await fulfillment(of: [requestsHasNonce], timeout: 0.1) + } + + func testSelfHostedSiteAuthenticationFailure() async throws { + // The expected API call sequence. + // [/wp/v2/hello-world|401] -> [nonce-from-ajax|401] -> [wp-login|401] -> [post-new|401] -> [wp-login|401] -> END. + + let unauthenticatedReuqest = expectation(description: "Call #1: call an endpoint with a unauthenticated request") + let ajaxNonceRequest = expectation(description: "Call #2: call ajax endpoint to get nonce") + let newPostWebpageRequest = expectation(description: "Call #3: get nonce from new post webpage") + let loginRequest = expectation(description: "Authenticate during fetching the ajax and new post webpage") + loginRequest.expectedFulfillmentCount = 2 + let authenticatedReuqest = expectation(description: "[Should never happen] call an endpoint with an authenticated request") + authenticatedReuqest.isInverted = true + + stub(condition: { $0.url?.lastPathComponent == "hello-world" }) { request in + if request.value(forHTTPHeaderField: "X-WP-Nonce") == "fakenonce" { + authenticatedReuqest.fulfill() + return HTTPStubsResponse(jsonObject: [String: String](), statusCode: 200, headers: nil) + } else { + unauthenticatedReuqest.fulfill() + return HTTPStubsResponse(jsonObject: [String: String](), statusCode: 401, headers: nil) + } + } + + stub(condition: { $0.url?.lastPathComponent == "admin-ajax.php" }) { _ in + ajaxNonceRequest.fulfill() + return HTTPStubsResponse(jsonObject: [String: String](), statusCode: 401, headers: nil) + } + + stub(condition: { $0.url?.lastPathComponent == "post-new.php" }) { _ in + newPostWebpageRequest.fulfill() + return HTTPStubsResponse(jsonObject: [String: String](), statusCode: 401, headers: nil) + } + + stub(condition: { $0.url?.lastPathComponent == "wp-login.php" }) { _ in + loginRequest.fulfill() + return HTTPStubsResponse(jsonObject: [String: String](), statusCode: 401, headers: nil) + } + + let selfHostedSite = WordPressOrgRestApi(selfHostedSiteWPJSONURL: URL(string: "https://wordpress.org/wp-json/")!, credential: fakeCredential) + let result = await selfHostedSite.get(path: "/wp/v2/hello-world", type: AnyResponse.self) + + if case .failure = result { + // Do nothing + } else { + XCTFail("Unexpected result: \(result)") + } + + await fulfillment(of: [unauthenticatedReuqest, ajaxNonceRequest, newPostWebpageRequest, loginRequest], timeout: 0.1, enforceOrder: true) + await fulfillment(of: [authenticatedReuqest], timeout: 0.1) + } + + func testNoRetryInWPComSite() async { + let expectation = expectation(description: "matches request conditions") + stub(condition: isMethodGET() && isAbsoluteURLString("https://public-api.wordpress.com/wp/v2/sites/42/hello-world?foo=bar")) { request in + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer faketoken") + expectation.fulfill() + return HTTPStubsResponse(jsonObject: [String: String](), statusCode: 401, headers: nil) + } + let api = WordPressOrgRestApi(dotComSiteID: 42, bearerToken: "faketoken") + let _ = await api.get(path: "wp/v2/hello-world", parameters: ["foo": "bar"], type: AnyResponse.self) + await fulfillment(of: [expectation], timeout: 0.1) + } + +} + +private struct AnyResponse: Decodable {} diff --git a/WordPressKitTests/WordPressComRestApiTests.swift b/WordPressKitTests/WordPressComRestApiTests.swift index 6a1b34eb..67ba520b 100644 --- a/WordPressKitTests/WordPressComRestApiTests.swift +++ b/WordPressKitTests/WordPressComRestApiTests.swift @@ -336,23 +336,6 @@ class WordPressComRestApiTests: XCTestCase { self.waitForExpectations(timeout: 2, handler: nil) } - // MARK: - WordPressRestApi Interfaces - func testRequestPathModificationsWPV2() { - let orgPath = "/wp/v2/themes?status=active" - let expectedPath = "/wp/v2/sites/1001/themes?status=active" - let api = WordPressComRestApi(oAuthToken: "fakeToken") - let result = api.requestPath(fromOrgPath: orgPath, with: 1001) - XCTAssertEqual(result, expectedPath) - } - - func testRequestPathModificationsWPBlockEditor() { - let orgPath = "/wp-block-editor/v1/settings" - let expectedPath = "/wp-block-editor/v1/sites/1001/settings" - let api = WordPressComRestApi(oAuthToken: "fakeToken") - let result = api.requestPath(fromOrgPath: orgPath, with: 1001) - XCTAssertEqual(result, expectedPath) - } - func testSuccessfullCallCommonGETStructure() { stub(condition: isRestAPIRequest()) { _ in let stubPath = OHPathForFile("WordPressComRestApiMedia.json", type(of: self)) @@ -362,15 +345,13 @@ class WordPressComRestApiTests: XCTestCase { let expect = self.expectation(description: "One callback should be invoked") let api = WordPressComRestApi(oAuthToken: "fakeToken") - api.GET(wordPressMediaRoutePath, parameters: nil) { (result, _) in + api.GET(wordPressMediaRoutePath, parameters: nil, success: { responseObject, _ in + XCTAssert(responseObject is [String: AnyObject], "The response should be a dictionary") expect.fulfill() - switch result { - case .success(let responseObject): - XCTAssert(responseObject is [String: AnyObject], "The response should be a dictionary") - case .failure: - XCTFail("This call should be successfull") - } - } + }, failure: { _, _ in + XCTFail("This call should be successfull") + expect.fulfill() + }) self.waitForExpectations(timeout: 2, handler: nil) } @@ -379,20 +360,18 @@ class WordPressComRestApiTests: XCTestCase { let stubPath = OHPathForFile("WordPressComRestApiFailInvalidJSON.json", type(of: self)) return fixture(filePath: stubPath!, status: 200, headers: ["Content-Type" as NSObject: "application/json" as AnyObject]) } + let expect = self.expectation(description: "One callback should be invoked") let api = WordPressComRestApi(oAuthToken: "fakeToken") - - api.GET(wordPressMediaRoutePath, parameters: nil) { (result, _) in + api.GET(wordPressMediaRoutePath, parameters: nil, success: { _, _ in + XCTFail("This call should fail") expect.fulfill() - switch result { - case .success: - XCTFail("This call should fail") - case .failure(let err): - let error = err as NSError - XCTAssert(error.domain == WordPressComRestApiErrorDomain, "The error domain should be WordPressComRestApiErrorDomain") - XCTAssert(error.code == Int(WordPressComRestApiErrorCode.responseSerializationFailed.rawValue), "The code should be invalid response serialization") - } - } + }, failure: { err, _ in + let error = err as NSError + XCTAssert(error.domain == WordPressComRestApiErrorDomain, "The error domain should be WordPressComRestApiErrorDomain") + XCTAssert(error.code == Int(WordPressComRestApiErrorCode.responseSerializationFailed.rawValue), "The code should be invalid response serialization") + expect.fulfill() + }) self.waitForExpectations(timeout: 2, handler: nil) } diff --git a/WordPressKitTests/WordPressOrgRestApiTests.swift b/WordPressKitTests/WordPressOrgRestApiTests.swift index d7e00e3d..74cc432f 100644 --- a/WordPressKitTests/WordPressOrgRestApiTests.swift +++ b/WordPressKitTests/WordPressOrgRestApiTests.swift @@ -22,93 +22,64 @@ class WordPressOrgRestApiTests: XCTestCase { } } - func testUnauthorizedCall() { + func testUnauthorizedCall() async throws { stub(condition: isAPIRequest()) { _ in let stubPath = OHPathForFile("wp-forbidden.json", type(of: self)) return fixture(filePath: stubPath!, status: 401, headers: ["Content-Type" as NSObject: "application/json" as AnyObject]) } - let expect = self.expectation(description: "One callback should be invoked") let api = WordPressOrgRestApi(apiBase: apiBase) - api.GET("wp/v2/settings", parameters: nil) { (result, response) in - expect.fulfill() - switch result { - case .success: - XCTFail("This call should not suceed") - case .failure: - XCTAssertEqual(response?.statusCode, 401, "Response should be unauthorized") - } + let result = await api.get(path: "wp/v2/settings", type: AnyResponse.self) + switch result { + case .success: + XCTFail("This call should not suceed") + case let .failure(error): + XCTAssertEqual(error.response?.statusCode, 401, "Response should be unauthorized") } - waitForExpectations(timeout: 2, handler: nil) } - func testSuccessfulGetCall() { + func testSuccessfulGetCall() async throws { stub(condition: isAPIRequest()) { _ in let stubPath = OHPathForFile("wp-pages.json", type(of: self)) return fixture(filePath: stubPath!, headers: ["Content-Type" as NSObject: "application/json" as AnyObject]) } - let expect = self.expectation(description: "One callback should be invoked") let api = WordPressOrgRestApi(apiBase: apiBase) - api.GET("wp/v2/pages", parameters: nil) { (result, _) in - expect.fulfill() - switch result { - case .success(let object): - guard let pages = object as? [AnyObject] else { - XCTFail("Unexpected API result") - return - } - XCTAssertEqual(pages.count, 10, "The API should return 10 pages") - case .failure: - XCTFail("This call should not fail") - } - } - waitForExpectations(timeout: 2, handler: nil) + let pages = try await api.get(path: "wp/v2/pages", type: [AnyResponse].self).get() + XCTAssertEqual(pages.count, 10, "The API should return 10 pages") } - func testSuccessfulPostCall() { + func testSuccessfulPostCall() async throws { stub(condition: isAPIRequest()) { _ in let stubPath = OHPathForFile("wp-reusable-blocks.json", type(of: self)) return fixture(filePath: stubPath!, headers: ["Content-Type" as NSObject: "application/json" as AnyObject]) } - let expect = self.expectation(description: "One callback should be invoked") + + struct Response: Decodable { + struct Content: Decodable { + var raw: String + } + + var content: Content + } + let api = WordPressOrgRestApi(apiBase: apiBase) let blockContent = "\n

Some text

\n\n\n\n
  • Item 1
  • Item 2
  • Item 3
\n" let parameters: [String: String] = ["id": "6", "content": blockContent] - api.POST("wp/v2/blocks/6", parameters: parameters as [String: AnyObject]) { (result, _) in - expect.fulfill() - switch result { - case .success(let object): - guard - let block = object as? [String: AnyObject], - let content = block["content"] as? [String: AnyObject], - let rawContent = content["raw"] as? String - else { - XCTFail("Unexpected API result") - return - } - XCTAssertEqual(block.count, 15, "The API should return the block") - XCTAssertEqual(rawContent, blockContent, "The API should return the block") - case .failure: - XCTFail("This call should not fail") - } - } - waitForExpectations(timeout: 2, handler: nil) + let response = try await api.post(path: "wp/v2/blocks/6", parameters: parameters, type: Response.self).get() + XCTAssertEqual(response.content.raw, blockContent, "The API should return the block") } /// Verify that parameters in POST requests are sent as urlencoded form. - func testPostParametersContent() throws { + func testPostParametersContent() async throws { var req: URLRequest? stub(condition: isHost("wordpress.org")) { req = $0 return HTTPStubsResponse(error: URLError(.notConnectedToInternet)) } - let api = WordPressOrgRestApi(apiBase: apiBase) - let complete = expectation(description: "API call completed") - api.POST("/rest/v1/foo", parameters: ["arg1": "value1"] as [String: AnyObject]) { _, _ in - complete.fulfill() - } + struct Empty: Decodable {} - wait(for: [complete], timeout: 0.3) + let api = WordPressOrgRestApi(apiBase: apiBase) + let _ = await api.post(path: "/rest/v1/foo", parameters: ["arg1": "value1"], type: Empty.self) let request = try XCTUnwrap(req) XCTAssertEqual(request.httpMethod?.uppercased(), "POST") @@ -116,4 +87,31 @@ class WordPressOrgRestApiTests: XCTestCase { XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/x-www-form-urlencoded; charset=utf-8") XCTAssertEqual(request.httpBodyText, "arg1=value1") } + + func testRequestPathModificationsWPV2() async throws { + stub(condition: isPath("/wp/v2/sites/1001/themes") && containsQueryParams(["status": "active"])) { _ in + HTTPStubsResponse(jsonObject: [String: String](), statusCode: 200, headers: nil) + } + let api = WordPressOrgRestApi(site: .dotCom(siteID: 1001, bearerToken: "fakeToken")) + let _ = try await api.get(path: "/wp/v2/themes", parameters: ["status": "active"], type: AnyResponse.self).get() + } + + func testRequestPathModificationsWPBlockEditor() async throws { + stub(condition: isPath("/wp-block-editor/v1/sites/1001/settings")) { _ in + HTTPStubsResponse(jsonObject: [String: String](), statusCode: 200, headers: nil) + } + let api = WordPressOrgRestApi(site: .dotCom(siteID: 1001, bearerToken: "fakeToken")) + let _ = try await api.get(path: "/wp-block-editor/v1/settings", type: AnyResponse.self).get() + } } + +extension WordPressOrgRestApi { + convenience init(apiBase: URL) { + self.init( + selfHostedSiteWPJSONURL: apiBase, + credential: .init(loginURL: URL(string: "https://not-used.com")!, username: "user", password: "pass", adminURL: URL(string: "https://not-used.com")!) + ) + } +} + +private struct AnyResponse: Decodable {}