diff --git a/Package.swift b/Package.swift index 4d1e46b..1def820 100644 --- a/Package.swift +++ b/Package.swift @@ -5,6 +5,7 @@ import PackageDescription let package = Package( name: "LetterboxdAPI", + platforms: [.iOS(.v13), .macOS(.v10_15), .tvOS(.v13), .watchOS(.v6)], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( diff --git a/Sources/LetterboxdAPI/LetterboxdAPI+Film.swift b/Sources/LetterboxdAPI/LetterboxdAPI+Film.swift new file mode 100644 index 0000000..10062ee --- /dev/null +++ b/Sources/LetterboxdAPI/LetterboxdAPI+Film.swift @@ -0,0 +1,106 @@ +// +// LetterboxdAPI+Film.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public extension LetterboxdAPI { + /// A cursored window over the list of films. + func getFilms(parameters: [String: String] = [:], completion: @escaping (Result) -> Void) { + let url = URLBuilder.url(path: "/films", body: nil, params: parameters) + + guard let request = generateRequest(url: url, method: .get) else { + completion(.failure(LetterboxdAPIError.generatingRequest)) + return + } + + processRequest(request: request, completion: completion) + } + + /// Get details about a film by ID. + func getFilm(withId id: String, completion: @escaping (Result) -> Void) { + let url = URLBuilder.url(path: "/film/\(id)", body: nil) + + guard let request = generateRequest(url: url, method: .get) else { + completion(.failure(LetterboxdAPIError.generatingRequest)) + return + } + + processRequest(request: request, completion: completion) + } + + /// Get availability data for a film by ID. Only available to first-party API clients. + func getFilmAvailability(withId id: String, completion: @escaping (Result) -> Void) { + let url = URLBuilder.url(path: "/film/\(id)/availability", body: nil) + + guard let request = generateRequest(url: url, method: .get) else { + completion(.failure(LetterboxdAPIError.generatingRequest)) + return + } + + processRequest(request: request, completion: completion) + } + + /// Get statistical data about a film by ID. + func getFilmStatistics(withId id: String, completion: @escaping (Result) -> Void) { + let url = URLBuilder.url(path: "/film/\(id)/statistics", body: nil) + + guard let request = generateRequest(url: url, method: .get) else { + completion(.failure(LetterboxdAPIError.generatingRequest)) + return + } + + processRequest(request: request, completion: completion) + } + + /// Get a list of countries supported by the /films endpoint + func getCountries(completion: @escaping (Result) -> Void) { + let url = URLBuilder.url(path: "/films/countries", body: nil) + + guard let request = generateRequest(url: url, method: .get) else { + completion(.failure(LetterboxdAPIError.generatingRequest)) + return + } + + processRequest(request: request, completion: completion) + } + + /// Get a list of services supported by the /films endpoint. + func getFilmServices(completion: @escaping (Result) -> Void) { + let url = URLBuilder.url(path: "/films/film-services", body: nil) + + guard let request = generateRequest(url: url, method: .get) else { + completion(.failure(LetterboxdAPIError.generatingRequest)) + return + } + + processRequest(request: request, completion: completion) + } + + /// Get a list of genres supported by the /films endpoint. + func getFilmGenres(completion: @escaping (Result) -> Void) { + let url = URLBuilder.url(path: "/films/genres", body: nil) + + guard let request = generateRequest(url: url, method: .get) else { + completion(.failure(LetterboxdAPIError.generatingRequest)) + return + } + + processRequest(request: request, completion: completion) + } + + /// Get a list of languages supported by the /films endpoint + func getFilmLanguages(completion: @escaping (Result) -> Void) { + let url = URLBuilder.url(path: "/films/languages", body: nil) + + guard let request = generateRequest(url: url, method: .get) else { + completion(.failure(LetterboxdAPIError.generatingRequest)) + return + } + + processRequest(request: request, completion: completion) + } +} diff --git a/Sources/LetterboxdAPI/LetterboxdAPI.swift b/Sources/LetterboxdAPI/LetterboxdAPI.swift index 92afc91..3bcf155 100644 --- a/Sources/LetterboxdAPI/LetterboxdAPI.swift +++ b/Sources/LetterboxdAPI/LetterboxdAPI.swift @@ -45,7 +45,7 @@ public class LetterboxdAPI { task.resume() } - private func generateRequest(url: URL, method: HTTPMethod) -> URLRequest? { + internal func generateRequest(url: URL, method: HTTPMethod) -> URLRequest? { var request = URLRequest(url: url) request.httpMethod = method.rawValue request.setValue("application/json", forHTTPHeaderField: "Content-Type") @@ -53,7 +53,7 @@ public class LetterboxdAPI { } @discardableResult - private func processRequest(request: URLRequest, completion: @escaping (Result) -> Void) -> URLSessionTask { + internal func processRequest(request: URLRequest, completion: @escaping (Result) -> Void) -> URLSessionTask { let task = URLSession.shared.dataTask(with: request) { (data, response, error) in guard error == nil, let data = data else { completion(.failure(error!)) diff --git a/Sources/LetterboxdAPI/Models/ContributionSummary.swift b/Sources/LetterboxdAPI/Models/ContributionSummary.swift new file mode 100644 index 0000000..b388210 --- /dev/null +++ b/Sources/LetterboxdAPI/Models/ContributionSummary.swift @@ -0,0 +1,15 @@ +// +// ContributorSummary.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct ContributorSummary: Decodable { + public var id: String + public var name: String + public var characterName: String? + public var tmdbid: String? +} diff --git a/Sources/LetterboxdAPI/Models/Country.swift b/Sources/LetterboxdAPI/Models/Country.swift new file mode 100644 index 0000000..bb6af58 --- /dev/null +++ b/Sources/LetterboxdAPI/Models/Country.swift @@ -0,0 +1,16 @@ +// +// Country.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct Country: Decodable { + /// The ISO 3166-1 defined code of the country. + public var code: String + + /// The name of the country. + public var name: String +} diff --git a/Sources/LetterboxdAPI/Models/Film.swift b/Sources/LetterboxdAPI/Models/Film.swift new file mode 100644 index 0000000..aed7801 --- /dev/null +++ b/Sources/LetterboxdAPI/Models/Film.swift @@ -0,0 +1,59 @@ +// +// Film.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct Film: Decodable { + public var id: String + public var name: String + public var originalName: String + + /// The other names by which the film is known (including alternative titles and/or foreign translations). + public var alternativeNames: [String] + public var releaseYear: Int + public var directors: [ContributorSummary] + public var poster: Image + public var adultPoster: Image + + /// The film’s position in the official Letterboxd Top 250 list of narrative feature films, `nil` if the film is not in the list. + public var top250Position: Int? + + /// `true` if the film is in TMDb’s ‘Adult’ category. + public var adult: Bool + + /// The LID of the collection containing this film. + public var filmCollectionId: String + + /// A list of relevant URLs for this entity, on Letterboxd and external sites. + public var links: [Link] + + /// The tagline for the film. + public var tagline: String + + /// A synopsis of the film. + public var description: String + + /// The film’s duration (in minutes). + public var runTime: Int + + /// The film’s backdrop image (16:9 ratio in multiple sizes). + public var backdrop: Image + + /// The backdrop’s vertical focal point, expressed as a proportion of the image’s height, using values between 0.0 and 1.0. Use when cropping the image into a shorter space, such as in the page for a film on the Letterboxd site. + public var backdropFocalPoint: Float + + /// The film’s trailer. + public var trailer: FilmTrailer + + /// The film’s genres. + public var genres: [Genre] + public var countries: [Country] + public var languages: [Language] + public var contributions: [FilmContributions] + public var news: [NewsItem] + public var recentStories: [LetterboxdStory] +} diff --git a/Sources/LetterboxdAPI/Models/FilmAvailability.swift b/Sources/LetterboxdAPI/Models/FilmAvailability.swift new file mode 100644 index 0000000..87aef2b --- /dev/null +++ b/Sources/LetterboxdAPI/Models/FilmAvailability.swift @@ -0,0 +1,152 @@ +// +// File.swift +// +// +// Created by Gianpiero Spinelli on 01/10/21. +// + +import Foundation + +public struct FilmAvailability: Decodable { + public enum Country: String, Decodable { + case AIA = "AIA" + case ARE = "ARE" + case ARG = "ARG" + case ARM = "ARM" + case ATG = "ATG" + case AUS = "AUS" + case AUT = "AUT" + case AZE = "AZE" + case BEL = "BEL" + case BFA = "BFA" + case BGR = "BGR" + case BHR = "BHR" + case BHS = "BHS" + case BLR = "BLR" + case BLZ = "BLZ" + case BMU = "BMU" + case BOL = "BOL" + case BRA = "BRA" + case BRB = "BRB" + case BRN = "BRN" + case BWA = "BWA" + case CAN = "CAN" + case CHE = "CHE" + case CHL = "CHL" + case CHN = "CHN" + case COL = "COL" + case CPV = "CPV" + case CRI = "CRI" + case CYM = "CYM" + case CYP = "CYP" + case CZE = "CZE" + case DEU = "DEU" + case DMA = "DMA" + case DNK = "DNK" + case DOM = "DOM" + case ECU = "ECU" + case EGY = "EGY" + case ESP = "ESP" + case EST = "EST" + case FIN = "FIN" + case FJI = "FJI" + case FRA = "FRA" + case FSM = "FSM" + case GBR = "GBR" + case GHA = "GHA" + case GMB = "GMB" + case GNB = "GNB" + case GRC = "GRC" + case GRD = "GRD" + case GTM = "GTM" + case HKG = "HKG" + case HND = "HND" + case HUN = "HUN" + case IDN = "IDN" + case IND = "IND" + case IRL = "IRL" + case ISR = "ISR" + case ITA = "ITA" + case JOR = "JOR" + case JPN = "JPN" + case KAZ = "KAZ" + case KEN = "KEN" + case KGZ = "KGZ" + case KHM = "KHM" + case KNA = "KNA" + case KOR = "KOR" + case LAO = "LAO" + case LBN = "LBN" + case LKA = "LKA" + case LTU = "LTU" + case LUX = "LUX" + case LVA = "LVA" + case MAC = "MAC" + case MDA = "MDA" + case MEX = "MEX" + case MLT = "MLT" + case MNG = "MNG" + case MOZ = "MOZ" + case MUS = "MUS" + case MYS = "MYS" + case NAM = "NAM" + case NER = "NER" + case NGA = "NGA" + case NIC = "NIC" + case NLD = "NLD" + case NOR = "NOR" + case NPL = "NPL" + case NZL = "NZL" + case OMN = "OMN" + case PAN = "PAN" + case PER = "PER" + case PHL = "PHL" + case PNG = "PNG" + case POL = "POL" + case PRT = "PRT" + case PRY = "PRY" + case QAT = "QAT" + case ROU = "ROU" + case RUS = "RUS" + case SAU = "SAU" + case SGP = "SGP" + case SLV = "SLV" + case SVK = "SVK" + case SVN = "SVN" + case SWE = "SWE" + case SWZ = "SWZ" + case THA = "THA" + case TJK = "TJK" + case TKM = "TKM" + case TTO = "TTO" + case TUR = "TUR" + case TWN = "TWN" + case UGA = "UGA" + case UKR = "UKR" + case USA = "USA" + case UZB = "UZB" + case VEN = "VEN" + case VGB = "VGB" + case VNM = "VNM" + case ZAF = "ZAF" + case ZWE = "ZWE" + } + + /// The name of the service. + public var displayName: String + + /// The URL of the thumbnail image for the service. + public var icon: String + + /// The regional store for the service. Not all countries are supported on all services. + public var country: Country + + /// The unique ID (if any) for the film on this service. + public var id: String? + + /// The URL for the film on this service. + public var url: String + + /// The types of the availability, possible options included buy, rent and stream + public var types: [String] +} diff --git a/Sources/LetterboxdAPI/Models/FilmContributions.swift b/Sources/LetterboxdAPI/Models/FilmContributions.swift new file mode 100644 index 0000000..c70f2c0 --- /dev/null +++ b/Sources/LetterboxdAPI/Models/FilmContributions.swift @@ -0,0 +1,15 @@ +// +// FilmContributions.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct FilmContributions: Decodable { + public var type: String + + /// The list of contributors of the specified type for the film. + public var contributors: [ContributorSummary] +} diff --git a/Sources/LetterboxdAPI/Models/FilmIdentifier.swift b/Sources/LetterboxdAPI/Models/FilmIdentifier.swift new file mode 100644 index 0000000..6fc669f --- /dev/null +++ b/Sources/LetterboxdAPI/Models/FilmIdentifier.swift @@ -0,0 +1,12 @@ +// +// FilmIdentifier.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct FilmIdentifier: Decodable { + public var id: String +} diff --git a/Sources/LetterboxdAPI/Models/FilmStatistics.swift b/Sources/LetterboxdAPI/Models/FilmStatistics.swift new file mode 100644 index 0000000..3c6f6eb --- /dev/null +++ b/Sources/LetterboxdAPI/Models/FilmStatistics.swift @@ -0,0 +1,18 @@ +// +// FilmStatistics.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct FilmStatistics: Decodable { + public var film: FilmIdentifier + + /// The weighted average rating of the film between 0.5 and 5.0. Will not be present if the film has not received sufficient ratings. + public var rating: Float? + + /// The number of watches, ratings, likes, etc. for the film. + public var counts: FilmStatisticsCounts +} diff --git a/Sources/LetterboxdAPI/Models/FilmStatisticsCount.swift b/Sources/LetterboxdAPI/Models/FilmStatisticsCount.swift new file mode 100644 index 0000000..bee9043 --- /dev/null +++ b/Sources/LetterboxdAPI/Models/FilmStatisticsCount.swift @@ -0,0 +1,17 @@ +// +// FilmStatisticsCounts.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct FilmStatisticsCounts: Decodable { + public var watches: Int + public var likes: Int + public var ratings: Int + public var fans: Int + public var lists: Int + public var reviews: Int +} diff --git a/Sources/LetterboxdAPI/Models/FilmTrailer.swift b/Sources/LetterboxdAPI/Models/FilmTrailer.swift new file mode 100644 index 0000000..18bc281 --- /dev/null +++ b/Sources/LetterboxdAPI/Models/FilmTrailer.swift @@ -0,0 +1,16 @@ +// +// FilmTrailer.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct FilmTrailer: Decodable { + /// The YouTube ID of the trailer. + public var id: String + + /// The YouTube URL for the trailer. + public var url: String +} diff --git a/Sources/LetterboxdAPI/Models/FilmsSummary.swift b/Sources/LetterboxdAPI/Models/FilmsSummary.swift new file mode 100644 index 0000000..01a1679 --- /dev/null +++ b/Sources/LetterboxdAPI/Models/FilmsSummary.swift @@ -0,0 +1,19 @@ +// +// FilmSummary.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct FilmSummary: Decodable { + public var id: String + public var name: String + public var releaseYear: Int? + public var directors: [ContributorSummary]? + public var poster: Image + public var adultPoster: Image? + + public var links: [Link] +} diff --git a/Sources/LetterboxdAPI/Models/Genre.swift b/Sources/LetterboxdAPI/Models/Genre.swift new file mode 100644 index 0000000..ec41923 --- /dev/null +++ b/Sources/LetterboxdAPI/Models/Genre.swift @@ -0,0 +1,16 @@ +// +// Genre.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct Genre: Decodable { + /// The LID of the genre. + public var id: String + + /// The name of the genre. + public var name: String +} diff --git a/Sources/LetterboxdAPI/Models/Image.swift b/Sources/LetterboxdAPI/Models/Image.swift new file mode 100644 index 0000000..0345a09 --- /dev/null +++ b/Sources/LetterboxdAPI/Models/Image.swift @@ -0,0 +1,12 @@ +// +// Image.swift +// +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct Image: Decodable { + public var sizes: [ImageSize] +} diff --git a/Sources/LetterboxdAPI/Models/ImageSize.swift b/Sources/LetterboxdAPI/Models/ImageSize.swift new file mode 100644 index 0000000..cddb5e0 --- /dev/null +++ b/Sources/LetterboxdAPI/Models/ImageSize.swift @@ -0,0 +1,18 @@ +// +// ImageSize.swift +// +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct ImageSize: Decodable { + public var width: Int + public var height: Int + public var url: String + + func getURL() -> URL { + return URL(string: url)! + } +} diff --git a/Sources/LetterboxdAPI/Models/Language.swift b/Sources/LetterboxdAPI/Models/Language.swift new file mode 100644 index 0000000..7db5b42 --- /dev/null +++ b/Sources/LetterboxdAPI/Models/Language.swift @@ -0,0 +1,16 @@ +// +// Language.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct Language: Decodable { + /// The ISO 639-1 defined code of the language. + public var code: String + + /// The name of the language. + public var name: String +} diff --git a/Sources/LetterboxdAPI/Models/LetterboxdStory.swift b/Sources/LetterboxdAPI/Models/LetterboxdStory.swift new file mode 100644 index 0000000..3bb3b5f --- /dev/null +++ b/Sources/LetterboxdAPI/Models/LetterboxdStory.swift @@ -0,0 +1,22 @@ +// +// LetterboxdStory.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct LetterboxdStory: Decodable { + public var id: String + public var name: String + public var author: MemberSummary + public var url: String + public var source: String + public var videoUrl: String + public var bodyHtml: String + public var bodyLbml: String + public var whenUpdated: String + public var whenCreated: String + public var image: Image +} diff --git a/Sources/LetterboxdAPI/Models/Link.swift b/Sources/LetterboxdAPI/Models/Link.swift new file mode 100644 index 0000000..59d52ff --- /dev/null +++ b/Sources/LetterboxdAPI/Models/Link.swift @@ -0,0 +1,14 @@ +// +// Link.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct Link: Decodable { + public var type: String + public var id: String + public var url: String +} diff --git a/Sources/LetterboxdAPI/Models/MemberSummary.swift b/Sources/LetterboxdAPI/Models/MemberSummary.swift new file mode 100644 index 0000000..9523bf3 --- /dev/null +++ b/Sources/LetterboxdAPI/Models/MemberSummary.swift @@ -0,0 +1,18 @@ +// +// MemberSummary.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct MemberSummary: Decodable { + public var id: String + public var username: String + public var displayName: String? + public var avatar: Image? + + /// Can be one of `Crew`, `Alum`, `Hq`, `Patron`, `Pro`, `Member` + public var memberStatus: String +} diff --git a/Sources/LetterboxdAPI/Models/NewsItem.swift b/Sources/LetterboxdAPI/Models/NewsItem.swift new file mode 100644 index 0000000..3bcbe92 --- /dev/null +++ b/Sources/LetterboxdAPI/Models/NewsItem.swift @@ -0,0 +1,25 @@ +// +// NewsItem.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct NewsItem: Decodable { + /// The title of the news item. + public var title: String + + /// The image. + public var image: Image + + /// The URL of the news item. + public var url: String + + /// A short description of the news item in LBML. May contain the following HTML tags:
. + public var shortDescription: String + + /// A long description of the news item in LBML. May contain the following HTML tags:
. + public var longDescription: String +} diff --git a/Sources/LetterboxdAPI/Models/Responses/CountryResponse.swift b/Sources/LetterboxdAPI/Models/Responses/CountryResponse.swift new file mode 100644 index 0000000..b855da3 --- /dev/null +++ b/Sources/LetterboxdAPI/Models/Responses/CountryResponse.swift @@ -0,0 +1,12 @@ +// +// CountryResponse.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct CountryResponse: Decodable { + var items: [Country] +} diff --git a/Sources/LetterboxdAPI/Models/Responses/FilmAvailabilityResponse.swift b/Sources/LetterboxdAPI/Models/Responses/FilmAvailabilityResponse.swift new file mode 100644 index 0000000..2ac715c --- /dev/null +++ b/Sources/LetterboxdAPI/Models/Responses/FilmAvailabilityResponse.swift @@ -0,0 +1,12 @@ +// +// FilmAvailabilityResponse.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct FilmAvailabilityResponse: Decodable { + public var items: [FilmAvailability] +} diff --git a/Sources/LetterboxdAPI/Models/Responses/FilmResponse.swift b/Sources/LetterboxdAPI/Models/Responses/FilmResponse.swift new file mode 100644 index 0000000..66c36ea --- /dev/null +++ b/Sources/LetterboxdAPI/Models/Responses/FilmResponse.swift @@ -0,0 +1,13 @@ +// +// FilmResponse.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct FilmResponse: Decodable { + var next: String? + var items: [FilmSummary] +} diff --git a/Sources/LetterboxdAPI/Models/Responses/FilmServicesResponse.swift b/Sources/LetterboxdAPI/Models/Responses/FilmServicesResponse.swift new file mode 100644 index 0000000..76f87c6 --- /dev/null +++ b/Sources/LetterboxdAPI/Models/Responses/FilmServicesResponse.swift @@ -0,0 +1,12 @@ +// +// FilmServicesResponse.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct FilmServicesResponse: Decodable { + var items: [Service] +} diff --git a/Sources/LetterboxdAPI/Models/Responses/GenresResponse.swift b/Sources/LetterboxdAPI/Models/Responses/GenresResponse.swift new file mode 100644 index 0000000..988e039 --- /dev/null +++ b/Sources/LetterboxdAPI/Models/Responses/GenresResponse.swift @@ -0,0 +1,12 @@ +// +// LanguagesResponse.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct LanguagesResponse: Decodable { + public var items: [Language] +} diff --git a/Sources/LetterboxdAPI/Models/Responses/LanguagesResponse.swift b/Sources/LetterboxdAPI/Models/Responses/LanguagesResponse.swift new file mode 100644 index 0000000..602dc57 --- /dev/null +++ b/Sources/LetterboxdAPI/Models/Responses/LanguagesResponse.swift @@ -0,0 +1,12 @@ +// +// GenresResponse.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation + +public struct GenresResponse: Decodable { + public var items: [Genre] +} diff --git a/Sources/LetterboxdAPI/Models/Service.swift b/Sources/LetterboxdAPI/Models/Service.swift new file mode 100644 index 0000000..87c6d9f --- /dev/null +++ b/Sources/LetterboxdAPI/Models/Service.swift @@ -0,0 +1,19 @@ +// +// File.swift +// +// +// Created by Gianpiero Spinelli on 01/10/21. +// + +import Foundation + +public struct Service: Decodable { + /// The LID of the service. + public var id: String + + /// The name of the service. + public var name: String + + /// The URL of the thumbnail image for the service. + public var icon: String +} diff --git a/Sources/LetterboxdAPI/Support/URLBuilder.swift b/Sources/LetterboxdAPI/Support/URLBuilder.swift new file mode 100644 index 0000000..ee5ca29 --- /dev/null +++ b/Sources/LetterboxdAPI/Support/URLBuilder.swift @@ -0,0 +1,93 @@ +// +// URLBuilder.swift +// LetterboxdAPI +// +// Created by Gianpiero Spinelli. +// + +import Foundation +import CryptoKit + +public class URLBuilder { + private var scheme: String { + return "https" + } + + private var host: String { + return "api.letterboxd.com" + } + + private lazy var baseURL: URL? = { + var components = URLComponents() + components.scheme = scheme + components.host = host + components.path = "/api/v0" + return components.url + }() + + private static let shared = URLBuilder() + + private init() {} + + public static func url(path: String, body: Data?, method: HTTPMethod = .get, params: [String: String] = [:]) -> URL { + guard let url = URL(string: path, relativeTo: shared.baseURL) else { + fatalError("Unable to create a URL with \(path)") + } + + return buildURL(url: url, body: body, method: method.rawValue, params: params) + } + + private static func buildURL(url: URL, body: Data?, method: String, params: [String: String]) -> URL { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + fatalError("Invalid url, unable to build components path") + } + + guard !Private.publicAPIKey.isEmpty, !Private.privateAPIKey.isEmpty else { + fatalError("Please insert API Keys via LetterboxdAPI.setUpAPIKeys(publicAPI: String, privateAPI: String) before calling the API") + } + + var parameters = params + parameters["apikey"] = Private.publicAPIKey + parameters["nonce"] = UUID().uuidString + parameters["timestamp"] = "\(Int(Date().timeIntervalSince1970))" + + if !parameters.isEmpty { + components.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) } + } + + guard let result = components.url else { + fatalError("Unable to create url from components (\(components)") + } + + guard let signature = sign(method: method, url: result.absoluteString, body: body) else { + fatalError("Unable to create the signature") + } + + components.queryItems?.append(URLQueryItem(name: "signature", value: signature)) + guard let resultURL = components.url else { + fatalError("Unable to create url from components after signature") + } + + return resultURL + } + + private static func sign(method: String, url: String, body: Data?) -> String? { + guard let methodData = method.data(using: .utf8), let urlData = url.data(using: .utf8) else { return nil } + + var data: Data = methodData + data.append(0) + data.append(urlData) + data.append(0) + if let body = body { + data.append(body) + } + + guard let privateKeyData = Private.privateAPIKey.data(using: .utf8) else { + fatalError("No API private key") + } + + let key = SymmetricKey(data: privateKeyData) + let authenticationCode = HMAC.authenticationCode(for: data, using: key) + return authenticationCode.compactMap { String(format: "%02x", $0) }.joined() + } +}