From de7a9716799beba2807bed6c69482172cdc6179c Mon Sep 17 00:00:00 2001 From: Vincent Tourraine Date: Thu, 7 Dec 2023 23:02:54 +0100 Subject: [PATCH 1/3] Get license from GitHub API --- CHANGELOG.md | 6 ++ .../AcknowList/AcknowListViewController.swift | 52 ++++++++++--- Sources/AcknowList/GitHubAPI.swift | 76 +++++++++++++++++++ Tests/AcknowListTests/GitHubAPITests.swift | 31 ++++++++ 4 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 Sources/AcknowList/GitHubAPI.swift create mode 100644 Tests/AcknowListTests/GitHubAPITests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 23847a0..ae4b174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 3.1 (work in progress) + +- Add `GitHubAPI` to get licenses from GitHub API +- Update `AcknowListViewController` to get missing licenses from GitHub API, with new `canFetchLicenseFromGitHub` property to disable this behavior + + ## 3.0.1 (24 November 2022) - Update `AcknowListSwiftUIView` to fix navigation to repository URL diff --git a/Sources/AcknowList/AcknowListViewController.swift b/Sources/AcknowList/AcknowListViewController.swift index 6a354d7..7b84dd1 100644 --- a/Sources/AcknowList/AcknowListViewController.swift +++ b/Sources/AcknowList/AcknowListViewController.swift @@ -37,6 +37,9 @@ open class AcknowListViewController: UITableViewController { /// The represented array of `Acknow`. open var acknowledgements: [Acknow] = [] + /// Indicates if the view controller should try to fetch missing licenses from the GitHub API. + open var canFetchLicenseFromGitHub = true + /** Header text to be displayed above the list of the acknowledgements. It needs to get set before `viewDidLoad` gets called. @@ -385,18 +388,30 @@ open class AcknowListViewController: UITableViewController { - indexPath: An index path locating the new selected row in `tableView`. */ open override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if let acknowledgement = acknowledgements[(indexPath as NSIndexPath).row] as Acknow?, + if let acknowledgement = acknowledgements[indexPath.row] as Acknow?, let navigationController = navigationController { if acknowledgement.text != nil { let viewController = AcknowViewController(acknowledgement: acknowledgement) navigationController.pushViewController(viewController, animated: true) } - else if canOpenRepository(for: acknowledgement), - let repository = acknowledgement.repository { -#if !os(tvOS) - let safariViewController = SFSafariViewController(url: repository) - present(safariViewController, animated: true) -#endif + else if canFetchLicenseFromGitHub, + let repository = acknowledgement.repository, + GitHubAPI.isGitHubRepository(repository) { + GitHubAPI.getLicense(for: repository) { [weak self] result in + switch result { + case .success(let text): + let updatedAcknowledgement = Acknow(title: acknowledgement.title, text: text, license: acknowledgement.license, repository: acknowledgement.repository) + self?.acknowledgements[indexPath.row] = updatedAcknowledgement + let viewController = AcknowViewController(acknowledgement: updatedAcknowledgement) + navigationController.pushViewController(viewController, animated: true) + + case .failure: + self?.openRepository(repository) + } + } + } + else if let repository = acknowledgement.repository { + openRepository(repository) } } } @@ -415,16 +430,33 @@ open class AcknowListViewController: UITableViewController { // MARK: - Navigation private func canOpen(_ acknowledgement: Acknow) -> Bool { - return acknowledgement.text != nil || canOpenRepository(for: acknowledgement) + if acknowledgement.text != nil { + return true + } + else if let repository = acknowledgement.repository { + return canOpenRepository(repository) + } + else { + return false + } } - private func canOpenRepository(for acknowledgement: Acknow) -> Bool { - guard let scheme = acknowledgement.repository?.scheme else { + private func canOpenRepository(_ repository: URL) -> Bool { + guard let scheme = repository.scheme else { return false } return scheme == "http" || scheme == "https" } + + private func openRepository(_ repository: URL) { +#if !os(tvOS) + if canOpenRepository(repository) { + let safariViewController = SFSafariViewController(url: repository) + present(safariViewController, animated: true) + } +#endif + } } #endif diff --git a/Sources/AcknowList/GitHubAPI.swift b/Sources/AcknowList/GitHubAPI.swift new file mode 100644 index 0000000..eba9dc2 --- /dev/null +++ b/Sources/AcknowList/GitHubAPI.swift @@ -0,0 +1,76 @@ +// +// GitHubAPI.swift +// +// Copyright (c) 2015-2023 Vincent Tourraine (https://www.vtourraine.net) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Foundation + +/// An object that interacts with the GitHub API. +public class GitHubAPI { + + /** + Gets the repository license. + - Parameters: + - repository: The GitHub URL for the repository. For example: `https://github.com/vtourraine/AcknowList.git` + - completionHandler: The completion handler to call when the load request is complete. This handler is executed on the main queue. It takes a `Result` parameter, with either the body of the license, or an error object that indicates why the request failed. + */ + @discardableResult public static func getLicense(for repository: URL, completionHandler: @escaping (Result) -> Void) -> URLSessionDataTask { + // GitHub API documentation + // https://docs.github.com/en/rest/licenses/licenses#get-the-license-for-a-repository + + let request = getLicenseRequest(for: repository) + let task = URLSession.shared.dataTask(with: request) { (data, response, error) in + DispatchQueue.main.async { + if (response as? HTTPURLResponse)?.statusCode == 200, + let data, + let text = String(data: data, encoding: .utf8) { + completionHandler(.success(text)) + } + else { + completionHandler(.failure(error ?? URLError(URLError.Code.unknown))) + } + } + } + + task.resume() + return task + } + + /** + Returns a Boolean value indicating whether a URL is a valid GitHub repository URL. + - Parameter repository: The GitHub URL for the repository. For example: `https://github.com/vtourraine/AcknowList.git` + */ + public static func isGitHubRepository(_ repository: URL) -> Bool { + return repository.absoluteString.hasPrefix("https://github.com/") + } + + internal static func getLicenseRequest(for repository: URL) -> URLRequest { + let path = pathWithoutExtension(for: repository) + let url = "https://api.github.com/repos\(path)/license" + var request = URLRequest(url: URL(string: url)!) + request.allHTTPHeaderFields = ["Accept": "application/vnd.github.raw"] + return request + } + + internal static func pathWithoutExtension(for repository: URL) -> String { + return repository.path.replacingOccurrences(of: ".git", with: "") + } +} diff --git a/Tests/AcknowListTests/GitHubAPITests.swift b/Tests/AcknowListTests/GitHubAPITests.swift new file mode 100644 index 0000000..3822013 --- /dev/null +++ b/Tests/AcknowListTests/GitHubAPITests.swift @@ -0,0 +1,31 @@ +// +// GitHubAPITests.swift +// AcknowExampleTests +// +// Created by Vincent Tourraine on 07/12/2023. +// Copyright © 2015-2022 Vincent Tourraine. All rights reserved. +// + +import XCTest + +@testable import AcknowList + +class GitHubAPITests: XCTestCase { + + func testRecognizeGitHubRepository() { + let repoURL = URL(string: "https://github.com/vtourraine/AcknowList.git")! + XCTAssertTrue(GitHubAPI.isGitHubRepository(repoURL)) + + let otherURL = URL(string: "https://www.website.com")! + XCTAssertFalse(GitHubAPI.isGitHubRepository(otherURL)) + } + + func testGetLicenseRequest() { + let repoURL = URL(string: "https://github.com/vtourraine/AcknowList.git")! + let request = GitHubAPI.getLicenseRequest(for: repoURL) + + XCTAssertEqual(request.url?.absoluteString, "https://api.github.com/repos/vtourraine/AcknowList/license") + XCTAssertEqual(request.allHTTPHeaderFields, ["Accept": "application/vnd.github.raw"]) + XCTAssertEqual(request.httpMethod, "GET") + } +} From 06500faa35cda3a7db269e6c45145624b869c0d3 Mon Sep 17 00:00:00 2001 From: Vincent Tourraine Date: Thu, 7 Dec 2023 23:14:55 +0100 Subject: [PATCH 2/3] Adjust access control --- Sources/AcknowList/GitHubAPI.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AcknowList/GitHubAPI.swift b/Sources/AcknowList/GitHubAPI.swift index eba9dc2..feea5a2 100644 --- a/Sources/AcknowList/GitHubAPI.swift +++ b/Sources/AcknowList/GitHubAPI.swift @@ -24,7 +24,7 @@ import Foundation /// An object that interacts with the GitHub API. -public class GitHubAPI { +open class GitHubAPI { /** Gets the repository license. From 9eafde2653717350b47ceb52b4736e8cab5edbf0 Mon Sep 17 00:00:00 2001 From: Vincent Tourraine Date: Thu, 7 Dec 2023 23:23:14 +0100 Subject: [PATCH 3/3] Fix file inclusion --- AcknowList.xcodeproj/project.pbxproj | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/AcknowList.xcodeproj/project.pbxproj b/AcknowList.xcodeproj/project.pbxproj index 3ea1901..c59469b 100644 --- a/AcknowList.xcodeproj/project.pbxproj +++ b/AcknowList.xcodeproj/project.pbxproj @@ -18,6 +18,8 @@ 32A5DE4625C7DD3D00ED11BB /* AcknowListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32A5DE3F25C7DD3D00ED11BB /* AcknowListViewController.swift */; }; 32A5DE4825C7DD3D00ED11BB /* AcknowLocalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32A5DE4125C7DD3D00ED11BB /* AcknowLocalization.swift */; }; 503E61E9212997CE00322F6C /* Pods-acknowledgements-RegexTesting.plist in Resources */ = {isa = PBXBuildFile; fileRef = 503E61E8212997CD00322F6C /* Pods-acknowledgements-RegexTesting.plist */; }; + BB8545732B227D39001BF421 /* GitHubAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB8545722B227D39001BF421 /* GitHubAPI.swift */; }; + BB8545752B227D49001BF421 /* GitHubAPITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB8545742B227D49001BF421 /* GitHubAPITests.swift */; }; D70473D225CC4DB0004F2BEC /* Pods-acknowledgements.plist in Resources */ = {isa = PBXBuildFile; fileRef = D70473D125CC4DB0004F2BEC /* Pods-acknowledgements.plist */; }; D70473E525CC530E004F2BEC /* AcknowListTestsHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70473E425CC530E004F2BEC /* AcknowListTestsHelpers.swift */; }; D705FE2B268A0B0600B501D7 /* AcknowList.docc in Sources */ = {isa = PBXBuildFile; fileRef = D705FE2A268A0B0600B501D7 /* AcknowList.docc */; }; @@ -60,6 +62,8 @@ 32A5DE4125C7DD3D00ED11BB /* AcknowLocalization.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AcknowLocalization.swift; sourceTree = ""; }; 32A5DE5225C7E2FB00ED11BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = SOURCE_ROOT; }; 503E61E8212997CD00322F6C /* Pods-acknowledgements-RegexTesting.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Pods-acknowledgements-RegexTesting.plist"; sourceTree = ""; }; + BB8545722B227D39001BF421 /* GitHubAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitHubAPI.swift; sourceTree = ""; }; + BB8545742B227D49001BF421 /* GitHubAPITests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GitHubAPITests.swift; sourceTree = ""; }; D70473D125CC4DB0004F2BEC /* Pods-acknowledgements.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Pods-acknowledgements.plist"; sourceTree = ""; }; D70473E425CC530E004F2BEC /* AcknowListTestsHelpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AcknowListTestsHelpers.swift; sourceTree = ""; }; D705FE2A268A0B0600B501D7 /* AcknowList.docc */ = {isa = PBXFileReference; lastKnownFileType = folder.documentationcatalog; path = AcknowList.docc; sourceTree = ""; }; @@ -145,6 +149,7 @@ 1420A2E31CAEC92200D98F9C /* AcknowLocalizationTests.swift */, 1A7421731CAC3685007E44FD /* AcknowParserTests.swift */, 1A7421741CAC3685007E44FD /* AcknowViewControllerTests.swift */, + BB8545742B227D49001BF421 /* GitHubAPITests.swift */, D70B4286212B2EFF007B6A81 /* Resources */, 1AD73B8F1CAC3AEB0084F8CA /* Supporting Files */, ); @@ -182,6 +187,7 @@ D7A68B462833C2D30064C2C0 /* AcknowPodDecoder.swift */, D78335EE2628934800A380A3 /* AcknowSwiftUI.swift */, 32A5DE3E25C7DD3D00ED11BB /* AcknowViewController.swift */, + BB8545722B227D39001BF421 /* GitHubAPI.swift */, D705FE2A268A0B0600B501D7 /* AcknowList.docc */, ); name = Sources; @@ -349,6 +355,7 @@ 32A5DE4625C7DD3D00ED11BB /* AcknowListViewController.swift in Sources */, D7A68B482833C2D30064C2C0 /* AcknowPodDecoder.swift in Sources */, D705FE2B268A0B0600B501D7 /* AcknowList.docc in Sources */, + BB8545732B227D39001BF421 /* GitHubAPI.swift in Sources */, D738F2AE2640342B001BA008 /* AcknowListSwiftUI.swift in Sources */, D7A68B452833C2C90064C2C0 /* AcknowList.swift in Sources */, D7A68B492833C2D30064C2C0 /* AcknowPackageDecoder.swift in Sources */, @@ -363,6 +370,7 @@ 1A7421771CAC3685007E44FD /* AcknowViewControllerTests.swift in Sources */, 1A7421751CAC3685007E44FD /* AcknowListViewControllerTests.swift in Sources */, D70473E525CC530E004F2BEC /* AcknowListTestsHelpers.swift in Sources */, + BB8545752B227D49001BF421 /* GitHubAPITests.swift in Sources */, 1A7421761CAC3685007E44FD /* AcknowParserTests.swift in Sources */, 1420A2E41CAEC92200D98F9C /* AcknowLocalizationTests.swift in Sources */, );