diff --git a/Sources/TMDb/Models/MovieExternalLinksCollection.swift b/Sources/TMDb/Models/MovieExternalLinksCollection.swift index eabb6e76..f7068357 100644 --- a/Sources/TMDb/Models/MovieExternalLinksCollection.swift +++ b/Sources/TMDb/Models/MovieExternalLinksCollection.swift @@ -73,11 +73,11 @@ public struct MovieExternalLinksCollection: Identifiable, Codable, Equatable, Ha public static func == (lhs: MovieExternalLinksCollection, rhs: MovieExternalLinksCollection) -> Bool { lhs.id == rhs.id - && lhs.imdb?.id == rhs.imdb?.id - && lhs.wikiData?.id == rhs.wikiData?.id - && lhs.facebook?.id == rhs.facebook?.id - && lhs.instagram?.id == rhs.instagram?.id - && lhs.twitter?.id == rhs.twitter?.id + && lhs.imdb == rhs.imdb + && lhs.wikiData == rhs.wikiData + && lhs.facebook == rhs.facebook + && lhs.instagram == rhs.instagram + && lhs.twitter == rhs.twitter } } diff --git a/Sources/TMDb/Models/PersonExternalLinksCollection.swift b/Sources/TMDb/Models/PersonExternalLinksCollection.swift new file mode 100644 index 00000000..df656e32 --- /dev/null +++ b/Sources/TMDb/Models/PersonExternalLinksCollection.swift @@ -0,0 +1,149 @@ +import Foundation + +/// +/// A model representing a collection of media databases and social IDs and links for a person. +/// +public struct PersonExternalLinksCollection: Identifiable, Codable, Equatable, Hashable { + + /// + /// The TMDb person identifier. + /// + public let id: Person.ID + + /// + /// IMDb link. + /// + public let imdb: IMDbLink? + + /// + /// WikiData link. + /// + public let wikiData: WikiDataLink? + + /// + /// Facebook link. + /// + public let facebook: FacebookLink? + + /// + /// Instagram link. + /// + public let instagram: InstagramLink? + + /// + /// Twitter link. + /// + public let twitter: TwitterLink? + + /// + /// TikTok llink. + /// + public let tikTok: TikTokLink? + + /// + /// Creates an external links collection for a movie. + /// + /// - Parameters: + /// - id: The TMDb person identifier. + /// - imdb: IMDb link. + /// - wikiData: WikiData link. + /// - facebook: Facebook link. + /// - instagram: Instagram link. + /// - twitter: Twitter link. + /// - tikTok: TikTok link. + /// + public init( + id: Movie.ID, + imdb: IMDbLink? = nil, + wikiData: WikiDataLink? = nil, + facebook: FacebookLink? = nil, + instagram: InstagramLink? = nil, + twitter: TwitterLink? = nil, + tikTok: TikTokLink? = nil + ) { + self.id = id + self.imdb = imdb + self.wikiData = wikiData + self.facebook = facebook + self.instagram = instagram + self.twitter = twitter + self.tikTok = tikTok + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(imdb?.id) + hasher.combine(wikiData?.id) + hasher.combine(facebook?.id) + hasher.combine(instagram?.id) + hasher.combine(twitter?.id) + hasher.combine(tikTok?.id) + } + + public static func == (lhs: PersonExternalLinksCollection, rhs: PersonExternalLinksCollection) -> Bool { + lhs.id == rhs.id + && lhs.imdb == rhs.imdb + && lhs.wikiData == rhs.wikiData + && lhs.facebook == rhs.facebook + && lhs.instagram == rhs.instagram + && lhs.twitter == rhs.twitter + && lhs.tikTok == rhs.tikTok + } + +} + +extension PersonExternalLinksCollection { + + private enum CodingKeys: String, CodingKey { + case id + case imdbID = "imdbId" + case wikiDataID = "wikidataId" + case facebookID = "facebookId" + case instagramID = "instagramId" + case twitterID = "twitterId" + case tikTokID = "tiktokId" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let id = try container.decode(Movie.ID.self, forKey: .id) + + let imdbID = try container.decodeIfPresent(String.self, forKey: .imdbID) + let wikiDataID = try container.decodeIfPresent(String.self, forKey: .wikiDataID) + let facebookID = try container.decodeIfPresent(String.self, forKey: .facebookID) + let instagramID = try container.decodeIfPresent(String.self, forKey: .instagramID) + let twitterID = try container.decodeIfPresent(String.self, forKey: .twitterID) + let tikTokID = try container.decodeIfPresent(String.self, forKey: .tikTokID) + + let imdbLink = IMDbLink(imdbNameID: imdbID) + let wikiDataLink = WikiDataLink(wikiDataID: wikiDataID) + let facebookLink = FacebookLink(facebookID: facebookID) + let instagramLink = InstagramLink(instagramID: instagramID) + let twitterLink = TwitterLink(twitterID: twitterID) + let tikTokLink = TikTokLink(tikTokID: tikTokID) + + self.init( + id: id, + imdb: imdbLink, + wikiData: wikiDataLink, + facebook: facebookLink, + instagram: instagramLink, + twitter: twitterLink, + tikTok: tikTokLink + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encodeIfPresent(imdb?.id, forKey: .imdbID) + try container.encodeIfPresent(wikiData?.id, forKey: .wikiDataID) + try container.encodeIfPresent(facebook?.id, forKey: .facebookID) + try container.encodeIfPresent(instagram?.id, forKey: .instagramID) + try container.encodeIfPresent(twitter?.id, forKey: .twitterID) + try container.encodeIfPresent(tikTok?.id, forKey: .tikTokID) + } + +} diff --git a/Sources/TMDb/Models/TikTokLink.swift b/Sources/TMDb/Models/TikTokLink.swift new file mode 100644 index 00000000..983a60a3 --- /dev/null +++ b/Sources/TMDb/Models/TikTokLink.swift @@ -0,0 +1,53 @@ +import Foundation + +/// +/// A TikTok external link. +/// +/// e.g. to a person's TikTok profile. +/// +public struct TikTokLink: ExternalLink { + + /// + /// TikTok profile identifier. + /// + public let id: String + + /// + /// URL of the TikTok profile page. + /// + public let url: URL + + /// + /// Creates a TikTok link object using a TikTok user identifier. + /// + /// - Parameter tikTokID: The TikTok user identifier. + /// + public init?(tikTokID: String?) { + guard + let tikTokID, + let url = Self.tikTokURL(for: tikTokID) + else { + return nil + } + + self.init(id: tikTokID, url: url) + } + +} + +extension TikTokLink { + + private init(id: String, url: URL) { + self.id = id + self.url = url + } + +} + +extension TikTokLink { + + private static func tikTokURL(for tikTokID: String) -> URL? { + URL(string: "https://www.tiktok.com/@\(tikTokID)") + } + +} diff --git a/Sources/TMDb/Movies/MovieService.swift b/Sources/TMDb/Movies/MovieService.swift index e88bdaae..1403d57a 100644 --- a/Sources/TMDb/Movies/MovieService.swift +++ b/Sources/TMDb/Movies/MovieService.swift @@ -331,14 +331,14 @@ public final class MovieService { /// Returns a collection of media databases and social links for a movie. /// /// - Parameters: - /// - id: The identifier of the movie. + /// - movieID: The identifier of the movie. /// /// - Returns: A collection of external links for the specificed movie. /// - public func externalLinks(forMovie id: Movie.ID) async throws -> MovieExternalLinksCollection { + public func externalLinks(forMovie movieID: Movie.ID) async throws -> MovieExternalLinksCollection { let linksCollection: MovieExternalLinksCollection do { - linksCollection = try await apiClient.get(endpoint: MoviesEndpoint.externalIDs(movieID: id)) + linksCollection = try await apiClient.get(endpoint: MoviesEndpoint.externalIDs(movieID: movieID)) } catch let error { throw TMDbError(error: error) } diff --git a/Sources/TMDb/People/Endpoints/PeopleEndpoint.swift b/Sources/TMDb/People/Endpoints/PeopleEndpoint.swift index b54fbe88..b8fbcbd4 100644 --- a/Sources/TMDb/People/Endpoints/PeopleEndpoint.swift +++ b/Sources/TMDb/People/Endpoints/PeopleEndpoint.swift @@ -8,6 +8,7 @@ enum PeopleEndpoint { case tvSeriesCredits(personID: Person.ID) case images(personID: Person.ID) case popular(page: Int? = nil) + case externalIDs(personID: Person.ID) } @@ -46,6 +47,10 @@ extension PeopleEndpoint: Endpoint { .appendingPathComponent("popular") .appendingPage(page) + case .externalIDs(let personID): + return Self.basePath + .appendingPathComponent(personID) + .appendingPathComponent("external_ids") } } diff --git a/Sources/TMDb/People/PersonService.swift b/Sources/TMDb/People/PersonService.swift index 911d29ca..c1658526 100644 --- a/Sources/TMDb/People/PersonService.swift +++ b/Sources/TMDb/People/PersonService.swift @@ -189,4 +189,23 @@ public final class PersonService { return personList } + /// + /// Returns a collection of media databases and social links for a person. + /// + /// - Parameters: + /// - personID: The identifier of the person. + /// + /// - Returns: A collection of external links for the specificed person. + /// + public func externalLinks(forPerson personID: Person.ID) async throws -> PersonExternalLinksCollection { + let linksCollection: PersonExternalLinksCollection + do { + linksCollection = try await apiClient.get(endpoint: PeopleEndpoint.externalIDs(personID: personID)) + } catch let error { + throw TMDbError(error: error) + } + + return linksCollection + } + } diff --git a/Sources/TMDb/TMDb.docc/Extensions/PersonService.md b/Sources/TMDb/TMDb.docc/Extensions/PersonService.md index 1a658860..2d7c017c 100644 --- a/Sources/TMDb/TMDb.docc/Extensions/PersonService.md +++ b/Sources/TMDb/TMDb.docc/Extensions/PersonService.md @@ -24,3 +24,4 @@ - ``knownFor(forPerson:)`` - ``popular(page:)`` +- ``externalLinks(forPerson:)`` diff --git a/Tests/TMDbIntegrationTests/PersonIntegrationTests.swift b/Tests/TMDbIntegrationTests/PersonIntegrationTests.swift index 121629c2..a816164a 100644 --- a/Tests/TMDbIntegrationTests/PersonIntegrationTests.swift +++ b/Tests/TMDbIntegrationTests/PersonIntegrationTests.swift @@ -78,4 +78,18 @@ final class PersonIntegrationTests: XCTestCase { XCTAssertFalse(personList.results.isEmpty) } + func testExternalLinks() async throws { + let personID = 115440 + + let linksCollection = try await personService.externalLinks(forPerson: personID) + + XCTAssertEqual(linksCollection.id, personID) + XCTAssertNotNil(linksCollection.imdb) + XCTAssertNotNil(linksCollection.wikiData) + XCTAssertNil(linksCollection.facebook) + XCTAssertNotNil(linksCollection.instagram) + XCTAssertNotNil(linksCollection.twitter) + XCTAssertNotNil(linksCollection.tikTok) + } + } diff --git a/Tests/TMDbTests/Mocks/Models/PersonExternalLinksCollection+Mocks.swift b/Tests/TMDbTests/Mocks/Models/PersonExternalLinksCollection+Mocks.swift new file mode 100644 index 00000000..98e7c9fb --- /dev/null +++ b/Tests/TMDbTests/Mocks/Models/PersonExternalLinksCollection+Mocks.swift @@ -0,0 +1,38 @@ +import Foundation +@testable import TMDb + +extension PersonExternalLinksCollection { + + static func mock( + id: Person.ID, + imdb: IMDbLink? = nil, + wikiData: WikiDataLink? = nil, + facebook: FacebookLink? = nil, + instagram: InstagramLink? = nil, + twitter: TwitterLink? = nil, + tikTok: TikTokLink? = nil + ) -> PersonExternalLinksCollection { + PersonExternalLinksCollection( + id: id, + imdb: imdb, + wikiData: wikiData, + facebook: facebook, + instagram: instagram, + twitter: twitter, + tikTok: tikTok + ) + } + + static var sydneySweeney: PersonExternalLinksCollection { + .mock( + id: 346698, + imdb: IMDbLink(imdbNameID: "nm2858875"), + wikiData: WikiDataLink(wikiDataID: "Q49561909"), + facebook: FacebookLink(facebookID: "sydney_sweeney"), + instagram: InstagramLink(instagramID: "sydney_sweeney"), + twitter: TwitterLink(twitterID: "sydney_sweeney"), + tikTok: TikTokLink(tikTokID: "syds_garage") + ) + } + +} diff --git a/Tests/TMDbTests/Models/PersonExternalLinksCollectionTests.swift b/Tests/TMDbTests/Models/PersonExternalLinksCollectionTests.swift new file mode 100644 index 00000000..93fbe4b6 --- /dev/null +++ b/Tests/TMDbTests/Models/PersonExternalLinksCollectionTests.swift @@ -0,0 +1,43 @@ +@testable import TMDb +import XCTest + +final class PersonExternalLinksCollectionTests: XCTestCase { + + func testDecodeReturnsPersonExternalLinksCollection() throws { + let expectedResult = PersonExternalLinksCollection( + id: 115440, + imdb: IMDbLink(imdbNameID: "nm2858875"), + wikiData: WikiDataLink(wikiDataID: "Q49561909"), + facebook: FacebookLink(facebookID: "sydney_sweeney"), + instagram: InstagramLink(instagramID: "sydney_sweeney"), + twitter: TwitterLink(twitterID: "sydney_sweeney"), + tikTok: TikTokLink(tikTokID: "syds_garage") + ) + + let result = try JSONDecoder.theMovieDatabase.decode( + PersonExternalLinksCollection.self, + fromResource: "person-external-ids" + ) + + XCTAssertEqual(result, expectedResult) + } + + func testEncodeAndDecodeReturnsMovieExternalLinksCollection() throws { + let linksCollection = PersonExternalLinksCollection( + id: 115440, + imdb: IMDbLink(imdbNameID: "nm2858875"), + wikiData: WikiDataLink(wikiDataID: "Q49561909"), + facebook: FacebookLink(facebookID: "sydney_sweeney"), + instagram: InstagramLink(instagramID: "sydney_sweeney"), + twitter: TwitterLink(twitterID: "sydney_sweeney"), + tikTok: TikTokLink(tikTokID: "syds_garage") + ) + + let data = try JSONEncoder.theMovieDatabase.encode(linksCollection) + + let result = try JSONDecoder.theMovieDatabase.decode(PersonExternalLinksCollection.self, from: data) + + XCTAssertEqual(result, linksCollection) + } + +} diff --git a/Tests/TMDbTests/Models/TikTokLinkTests.swift b/Tests/TMDbTests/Models/TikTokLinkTests.swift new file mode 100644 index 00000000..90bf914f --- /dev/null +++ b/Tests/TMDbTests/Models/TikTokLinkTests.swift @@ -0,0 +1,19 @@ +@testable import TMDb +import XCTest + +final class TikTokLinkTests: XCTestCase { + + func testInitWithTikTokIDWhenIDIsNilReturnsNil() { + XCTAssertNil(TikTokLink(tikTokID: nil)) + } + + func testURL() throws { + let tikTokID = "jasonstatham" + let expectedURL = try XCTUnwrap(URL(string: "https://www.tiktok.com/@\(tikTokID)")) + + let tikTokLink = TikTokLink(tikTokID: tikTokID) + + XCTAssertEqual(tikTokLink?.url, expectedURL) + } + +} diff --git a/Tests/TMDbTests/People/Endpoints/PeopleEndpointTests.swift b/Tests/TMDbTests/People/Endpoints/PeopleEndpointTests.swift index 71a5f0e3..2eaaee25 100644 --- a/Tests/TMDbTests/People/Endpoints/PeopleEndpointTests.swift +++ b/Tests/TMDbTests/People/Endpoints/PeopleEndpointTests.swift @@ -59,4 +59,12 @@ final class PeopleEndpointTests: XCTestCase { XCTAssertEqual(url, expectedURL) } + func testExternalIDsEndpointReturnsURL() throws { + let expectedURL = try XCTUnwrap(URL(string: "/person/1/external_ids")) + + let url = PeopleEndpoint.externalIDs(personID: 1).path + + XCTAssertEqual(url, expectedURL) + } + } diff --git a/Tests/TMDbTests/People/PersonServiceTests.swift b/Tests/TMDbTests/People/PersonServiceTests.swift index 03cf2d6d..509e7468 100644 --- a/Tests/TMDbTests/People/PersonServiceTests.swift +++ b/Tests/TMDbTests/People/PersonServiceTests.swift @@ -133,4 +133,15 @@ final class PersonServiceTests: XCTestCase { XCTAssertEqual(apiClient.lastPath, PeopleEndpoint.popular(page: page).path) } + func testExternalLinksReturnsExternalLinks() async throws { + let expectedResult = PersonExternalLinksCollection.sydneySweeney + let personID = 115440 + apiClient.result = .success(expectedResult) + + let result = try await service.externalLinks(forPerson: personID) + + XCTAssertEqual(result, expectedResult) + XCTAssertEqual(apiClient.lastPath, PeopleEndpoint.externalIDs(personID: personID).path) + } + } diff --git a/Tests/TMDbTests/Resources/json/person-external-ids.json b/Tests/TMDbTests/Resources/json/person-external-ids.json new file mode 100644 index 00000000..44ddf58f --- /dev/null +++ b/Tests/TMDbTests/Resources/json/person-external-ids.json @@ -0,0 +1,13 @@ +{ + "id": 115440, + "freebase_mid": "/m/07zkn5z", + "freebase_id": null, + "imdb_id": "nm2858875", + "tvrage_id": null, + "wikidata_id": "Q49561909", + "facebook_id": "sydney_sweeney", + "instagram_id": "sydney_sweeney", + "tiktok_id": "syds_garage", + "twitter_id": "sydney_sweeney", + "youtube_id": null +}