diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9dcc5e1f..2430ce10 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,7 +22,7 @@ env: jobs: analyze: name: Analyze - runs-on: macos-13 + runs-on: macos-14 timeout-minutes: 120 permissions: actions: read diff --git a/Sources/TMDb/Helpers/LocaleProvider.swift b/Sources/TMDb/Adapters/LocaleProvider.swift similarity index 100% rename from Sources/TMDb/Helpers/LocaleProvider.swift rename to Sources/TMDb/Adapters/LocaleProvider.swift diff --git a/Sources/TMDb/Networking/HTTPClient/URLSessionHTTPClientAdapter.swift b/Sources/TMDb/Adapters/URLSessionHTTPClientAdapter.swift similarity index 90% rename from Sources/TMDb/Networking/HTTPClient/URLSessionHTTPClientAdapter.swift rename to Sources/TMDb/Adapters/URLSessionHTTPClientAdapter.swift index 31e80607..8ff62e6a 100644 --- a/Sources/TMDb/Networking/HTTPClient/URLSessionHTTPClientAdapter.swift +++ b/Sources/TMDb/Adapters/URLSessionHTTPClientAdapter.swift @@ -30,10 +30,11 @@ final class URLSessionHTTPClientAdapter: HTTPClient { self.urlSession = urlSession } - func get(url: URL, headers: [String: String]) async throws -> HTTPResponse { - var urlRequest = URLRequest(url: url) - urlRequest.httpMethod = "GET" - for header in headers { + func perform(request: HTTPRequest) async throws -> HTTPResponse { + var urlRequest = URLRequest(url: request.url) + urlRequest.httpMethod = request.method.rawValue + urlRequest.httpBody = request.body + for header in request.headers { urlRequest.addValue(header.value, forHTTPHeaderField: header.key) } diff --git a/Sources/TMDb/Extensions/JSONEncoder+TMDb.swift b/Sources/TMDb/Extensions/JSONEncoder+TMDb.swift new file mode 100644 index 00000000..bd3594dd --- /dev/null +++ b/Sources/TMDb/Extensions/JSONEncoder+TMDb.swift @@ -0,0 +1,38 @@ +// +// JSONEncoder+TMDb.swift +// TMDb +// +// Copyright © 2024 Adam Young. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an AS IS BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +extension JSONEncoder { + + static var theMovieDatabase: JSONEncoder { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + encoder.dateEncodingStrategy = .formatted(.theMovieDatabase) + return encoder + } + + static var theMovieDatabaseAuth: JSONEncoder { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + encoder.dateEncodingStrategy = .formatted(.theMovieDatabaseAuth) + return encoder + } + +} diff --git a/Sources/TMDb/Models/Session.swift b/Sources/TMDb/Models/Session.swift new file mode 100644 index 00000000..01db2d43 --- /dev/null +++ b/Sources/TMDb/Models/Session.swift @@ -0,0 +1,58 @@ +// +// Session.swift +// TMDb +// +// Copyright © 2024 Adam Young. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an AS IS BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// +/// A model representing a TMDb session. +/// +public struct Session: Codable, Equatable, Hashable { + + /// + /// Was session creation successful. + /// + public let success: Bool + + /// + /// The session identifier. + /// + public let sessionID: String + + /// + /// Creates a TMDb session object.. + /// + /// - Parameters: + /// - success: Was token creation successful. + /// - sessionID: The session identifier. + /// + public init(success: Bool, sessionID: String) { + self.success = success + self.sessionID = sessionID + } + +} + +extension Session { + + private enum CodingKeys: String, CodingKey { + case success + case sessionID = "sessionId" + } + +} diff --git a/Sources/TMDb/Models/TMDbError+TMDbAPIError.swift b/Sources/TMDb/Models/TMDbError+TMDbAPIError.swift index fe6d45db..3e729038 100644 --- a/Sources/TMDb/Models/TMDbError+TMDbAPIError.swift +++ b/Sources/TMDb/Models/TMDbError+TMDbAPIError.swift @@ -31,6 +31,9 @@ extension TMDbError { case .notFound: self = .notFound + case let .unauthorised(message): + self = .unauthorised(message) + case let .network(error): self = .network(error) diff --git a/Sources/TMDb/Models/TMDbError.swift b/Sources/TMDb/Models/TMDbError.swift index f9768085..10f4e853 100644 --- a/Sources/TMDb/Models/TMDbError.swift +++ b/Sources/TMDb/Models/TMDbError.swift @@ -24,6 +24,8 @@ public enum TMDbError: Equatable, LocalizedError { /// An error indicating the resource could not be found. case notFound + case unauthorised(String?) + /// An error indicating there was a network problem. case network(Error) @@ -35,6 +37,9 @@ public enum TMDbError: Equatable, LocalizedError { case (.notFound, .notFound): true + case let (.unauthorised(lhsMessage), .unauthorised(rhsMessage)): + lhsMessage == rhsMessage + case (.network, .network): true @@ -56,6 +61,9 @@ public extension TMDbError { case .notFound: "Not found" + case .unauthorised: + "Unauthorised" + case .network: "Network error" diff --git a/Sources/TMDb/Networking/APIClient/TMDbStatusResponse.swift b/Sources/TMDb/Models/TMDbStatusResponse.swift similarity index 100% rename from Sources/TMDb/Networking/APIClient/TMDbStatusResponse.swift rename to Sources/TMDb/Models/TMDbStatusResponse.swift diff --git a/Sources/TMDb/Models/Token.swift b/Sources/TMDb/Models/Token.swift index 01ce5c14..1e58e3d5 100644 --- a/Sources/TMDb/Models/Token.swift +++ b/Sources/TMDb/Models/Token.swift @@ -40,7 +40,7 @@ public struct Token: Codable, Equatable, Hashable { public let expiresAt: Date /// - /// Creates an internediate request token. + /// Creates an internediate request token object. /// /// - Parameters: /// - success: Was token creation successful. diff --git a/Sources/TMDb/Networking/APIClient/HTTPClient.swift b/Sources/TMDb/Networking/HTTPClient.swift similarity index 78% rename from Sources/TMDb/Networking/APIClient/HTTPClient.swift rename to Sources/TMDb/Networking/HTTPClient.swift index e1716f8e..46446ce1 100644 --- a/Sources/TMDb/Networking/APIClient/HTTPClient.swift +++ b/Sources/TMDb/Networking/HTTPClient.swift @@ -25,14 +25,13 @@ import Foundation public protocol HTTPClient { /// - /// Performs an HTTP GET request. + /// Performs an HTTP request. /// /// - Parameters: - /// - url: The URL to use for the request. - /// - headers: Additional HTTP headers to use in the request. + /// - request: The HTTP request. /// /// - Returns: An HTTP response object. /// - func get(url: URL, headers: [String: String]) async throws -> HTTPResponse + func perform(request: HTTPRequest) async throws -> HTTPResponse } diff --git a/Sources/TMDb/Networking/HTTPRequest.swift b/Sources/TMDb/Networking/HTTPRequest.swift new file mode 100644 index 00000000..01389944 --- /dev/null +++ b/Sources/TMDb/Networking/HTTPRequest.swift @@ -0,0 +1,50 @@ +// +// HTTPRequest.swift +// TMDb +// +// Copyright © 2024 Adam Young. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an AS IS BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public struct HTTPRequest { + + public let url: URL + public let method: HTTPRequest.Method + public let headers: [String: String] + public let body: Data? + + public init( + url: URL, + method: HTTPRequest.Method = .get, + headers: [String: String] = [:], + body: Data? = nil + ) { + self.url = url + self.method = method + self.headers = headers + self.body = body + } + +} + +public extension HTTPRequest { + + enum Method: String { + case get = "GET" + case post = "POST" + } + +} diff --git a/Sources/TMDb/Networking/APIClient/HTTPResponse.swift b/Sources/TMDb/Networking/HTTPResponse.swift similarity index 100% rename from Sources/TMDb/Networking/APIClient/HTTPResponse.swift rename to Sources/TMDb/Networking/HTTPResponse.swift diff --git a/Sources/TMDb/Networking/APIClient/Serialiser.swift b/Sources/TMDb/Networking/Serialiser.swift similarity index 79% rename from Sources/TMDb/Networking/APIClient/Serialiser.swift rename to Sources/TMDb/Networking/Serialiser.swift index f7acdf52..6e4dbd97 100644 --- a/Sources/TMDb/Networking/APIClient/Serialiser.swift +++ b/Sources/TMDb/Networking/Serialiser.swift @@ -22,13 +22,19 @@ import Foundation actor Serialiser { private let decoder: JSONDecoder + private let encoder: JSONEncoder - init(decoder: JSONDecoder) { + init(decoder: JSONDecoder, encoder: JSONEncoder) { self.decoder = decoder + self.encoder = encoder } func decode(_ type: T.Type, from data: Data) async throws -> T { try decoder.decode(type, from: data) } + func encode(_ value: some Encodable) async throws -> Data { + try encoder.encode(value) + } + } diff --git a/Sources/TMDb/Networking/APIClient/TMDbAPIClient.swift b/Sources/TMDb/Networking/TMDbAPIClient.swift similarity index 71% rename from Sources/TMDb/Networking/APIClient/TMDbAPIClient.swift rename to Sources/TMDb/Networking/TMDbAPIClient.swift index 8a38e7b3..429131ad 100644 --- a/Sources/TMDb/Networking/APIClient/TMDbAPIClient.swift +++ b/Sources/TMDb/Networking/TMDbAPIClient.swift @@ -47,34 +47,49 @@ final class TMDbAPIClient: APIClient { "Accept": "application/json" ] - let response: HTTPResponse + let request = HTTPRequest(url: url, headers: headers) + let responseObject: Response = try await perform(request: request) + + return responseObject + } + func post(path: URL, body: some Encodable) async throws -> Response { + let url = urlFromPath(path) + let headers = [ + "Content-Type": "application/json", + "Accept": "application/json" + ] + let data: Data do { - response = try await httpClient.get(url: url, headers: headers) + data = try await serialiser.encode(body) } catch let error { - throw TMDbAPIError.network(error) + throw TMDbAPIError.encode(error) } - try await validate(response: response) + let request = HTTPRequest(url: url, method: .post, headers: headers, body: data) + let responseObject: Response = try await perform(request: request) - guard let data = response.data else { - throw TMDbAPIError.unknown - } + return responseObject + } + +} + +extension TMDbAPIClient { + + private func perform(request: HTTPRequest) async throws -> Response { + let response: HTTPResponse - let decodedResponse: Response do { - decodedResponse = try await serialiser.decode(Response.self, from: data) + response = try await httpClient.perform(request: request) } catch let error { - throw TMDbAPIError.decode(error) + throw TMDbAPIError.network(error) } + let decodedResponse: Response = try await decodeResponse(response: response) + return decodedResponse } -} - -extension TMDbAPIClient { - private func urlFromPath(_ path: URL) -> URL { guard var urlComponents = URLComponents(url: path, resolvingAgainstBaseURL: true) else { return path @@ -89,6 +104,23 @@ extension TMDbAPIClient { .appendingLanguage(localeProvider.languageCode) } + private func decodeResponse(response: HTTPResponse) async throws -> Response { + try await validate(response: response) + + guard let data = response.data else { + throw TMDbAPIError.unknown + } + + let decodedResponse: Response + do { + decodedResponse = try await serialiser.decode(Response.self, from: data) + } catch let error { + throw TMDbAPIError.decode(error) + } + + return decodedResponse + } + private func validate(response: HTTPResponse) async throws { let statusCode = response.statusCode if (200 ... 299).contains(statusCode) { diff --git a/Sources/TMDb/Networking/APIClient/TMDbAPIError+HTTPStatusCode.swift b/Sources/TMDb/Networking/TMDbAPIError+HTTPStatusCode.swift similarity index 100% rename from Sources/TMDb/Networking/APIClient/TMDbAPIError+HTTPStatusCode.swift rename to Sources/TMDb/Networking/TMDbAPIError+HTTPStatusCode.swift diff --git a/Sources/TMDb/Networking/APIClient/TMDbAPIError.swift b/Sources/TMDb/Networking/TMDbAPIError.swift similarity index 96% rename from Sources/TMDb/Networking/APIClient/TMDbAPIError.swift rename to Sources/TMDb/Networking/TMDbAPIError.swift index c340e831..49da3c32 100644 --- a/Sources/TMDb/Networking/APIClient/TMDbAPIError.swift +++ b/Sources/TMDb/Networking/TMDbAPIError.swift @@ -94,6 +94,11 @@ enum TMDbAPIError: Error { /// case gatewayTimeout(String?) + /// + /// Data encode error. + /// + case encode(Error) + /// /// Data decode error. /// diff --git a/Sources/TMDb/Services/APIClient.swift b/Sources/TMDb/Services/APIClient.swift index 8ca4a22a..c462fe7a 100644 --- a/Sources/TMDb/Services/APIClient.swift +++ b/Sources/TMDb/Services/APIClient.swift @@ -23,6 +23,8 @@ protocol APIClient { func get(path: URL) async throws -> Response + func post(path: URL, body: Body) async throws -> Response + } extension APIClient { @@ -31,4 +33,8 @@ extension APIClient { try await get(path: endpoint.path) } + func post(endpoint: Endpoint, body: some Encodable) async throws -> Response { + try await post(path: endpoint.path, body: body) + } + } diff --git a/Sources/TMDb/Helpers/AuthenticateURLBuilder.swift b/Sources/TMDb/Services/Authentication/AuthenticateURLBuilder.swift similarity index 100% rename from Sources/TMDb/Helpers/AuthenticateURLBuilder.swift rename to Sources/TMDb/Services/Authentication/AuthenticateURLBuilder.swift diff --git a/Sources/TMDb/Services/Authentication/AuthenticationEndpoint.swift b/Sources/TMDb/Services/Authentication/AuthenticationEndpoint.swift index 23c2a4dc..0c328f9e 100644 --- a/Sources/TMDb/Services/Authentication/AuthenticationEndpoint.swift +++ b/Sources/TMDb/Services/Authentication/AuthenticationEndpoint.swift @@ -23,6 +23,7 @@ enum AuthenticationEndpoint { case createGuestSession case createRequestToken + case createSession } @@ -41,6 +42,11 @@ extension AuthenticationEndpoint: Endpoint { Self.basePath .appendingPathComponent("token") .appendingPathComponent("new") + + case .createSession: + Self.basePath + .appendingPathComponent("session") + .appendingPathComponent("new") } } diff --git a/Sources/TMDb/Services/Authentication/AuthenticationService.swift b/Sources/TMDb/Services/Authentication/AuthenticationService.swift index 03308b18..8239473d 100644 --- a/Sources/TMDb/Services/Authentication/AuthenticationService.swift +++ b/Sources/TMDb/Services/Authentication/AuthenticationService.swift @@ -83,6 +83,8 @@ public final class AuthenticationService { /// /// [TMDb API - Authentication: Create Request Token](https://developer.themoviedb.org/reference/authentication-create-request-token) /// + /// - Throws: TMDb error ``TMDbError``. + /// /// - Returns: An intermediate request token. /// public func requestToken() async throws -> Token { @@ -113,4 +115,29 @@ public final class AuthenticationService { return url } + /// + /// Creates a TMDb session with a valid request token. + /// + /// - Note: Ensure this request token has been authorised in a web browser by taking the user to the URL generated + /// by ``authenticateURL(for:redirectURL:)``. + /// + /// - Parameter requestToken: An authorised request token. + /// + /// - Throws: TMDb error ``TMDbError``. + /// + /// - Returns: A TMDb session. + /// + public func createSession(withRequestToken requestToken: String) async throws -> Session { + let body = CreateSessionRequestBody(requestToken: requestToken) + + let session: Session + do { + session = try await apiClient.post(endpoint: AuthenticationEndpoint.createSession, body: body) + } catch let error { + throw TMDbError(error: error) + } + + return session + } + } diff --git a/Sources/TMDb/Services/Authentication/RequestModels/CreateSessionRequestBody.swift b/Sources/TMDb/Services/Authentication/RequestModels/CreateSessionRequestBody.swift new file mode 100644 index 00000000..8ee775c3 --- /dev/null +++ b/Sources/TMDb/Services/Authentication/RequestModels/CreateSessionRequestBody.swift @@ -0,0 +1,26 @@ +// +// CreateSessionRequestBody.swift +// TMDb +// +// Copyright © 2024 Adam Young. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an AS IS BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct CreateSessionRequestBody: Encodable, Equatable { + + let requestToken: String + +} diff --git a/Sources/TMDb/Networking/APIClient/Endpoint.swift b/Sources/TMDb/Services/Endpoint.swift similarity index 100% rename from Sources/TMDb/Networking/APIClient/Endpoint.swift rename to Sources/TMDb/Services/Endpoint.swift diff --git a/Sources/TMDb/TMDb.docc/Extensions/AuthenticationService.md b/Sources/TMDb/TMDb.docc/Extensions/AuthenticationService.md index c8f9d72f..ba59b36a 100644 --- a/Sources/TMDb/TMDb.docc/Extensions/AuthenticationService.md +++ b/Sources/TMDb/TMDb.docc/Extensions/AuthenticationService.md @@ -2,7 +2,7 @@ ## Topics -### Creating am Authentication Service +### Creating an Authentication Service - ``init()`` @@ -11,3 +11,4 @@ - ``guestSession()`` - ``requestToken()`` - ``authenticateURL(for:redirectURL:)`` +- ``createSession(withRequestToken:)`` diff --git a/Sources/TMDb/TMDb.docc/GettingStarted/ConfiguringTMDb.md b/Sources/TMDb/TMDb.docc/GettingStarted/ConfiguringTMDb.md index 810f64fb..b7969483 100644 --- a/Sources/TMDb/TMDb.docc/GettingStarted/ConfiguringTMDb.md +++ b/Sources/TMDb/TMDb.docc/GettingStarted/ConfiguringTMDb.md @@ -28,7 +28,7 @@ The adapter should conform to ``HTTPClient``. ```swift class MyHTTPClient: HTTPClient { - func get(url: URL, headers: [String: String]) async throws -> HTTPResponse { + func perform(request: HTTPRequest) async throws -> HTTPResponse { // Implement performing a network request. } } diff --git a/Sources/TMDb/TMDbFactory.swift b/Sources/TMDb/TMDbFactory.swift index f4f497e1..c8abe2c5 100644 --- a/Sources/TMDb/TMDbFactory.swift +++ b/Sources/TMDb/TMDbFactory.swift @@ -92,11 +92,11 @@ extension TMDbFactory { #endif private static var serialiser: some Serialiser { - Serialiser(decoder: .theMovieDatabase) + Serialiser(decoder: .theMovieDatabase, encoder: .theMovieDatabase) } private static var authSerialiser: some Serialiser { - Serialiser(decoder: .theMovieDatabaseAuth) + Serialiser(decoder: .theMovieDatabaseAuth, encoder: .theMovieDatabaseAuth) } } diff --git a/Tests/TMDbTests/Networking/APIClient/SerialiserTests.swift b/Tests/TMDbTests/APIClient/SerialiserTests.swift similarity index 69% rename from Tests/TMDbTests/Networking/APIClient/SerialiserTests.swift rename to Tests/TMDbTests/APIClient/SerialiserTests.swift index 92431173..70ee42fa 100644 --- a/Tests/TMDbTests/Networking/APIClient/SerialiserTests.swift +++ b/Tests/TMDbTests/APIClient/SerialiserTests.swift @@ -26,7 +26,7 @@ final class SerialiserTests: XCTestCase { override func setUp() { super.setUp() - serialiser = Serialiser(decoder: JSONDecoder()) + serialiser = Serialiser(decoder: JSONDecoder(), encoder: JSONEncoder()) } override func tearDown() { @@ -37,14 +37,14 @@ final class SerialiserTests: XCTestCase { func testDecodeWhenDataCannotBeDecodedThrowsDecodeError() async throws { let data = Data("aaa".utf8) + var error: Error? do { _ = try await serialiser.decode(MockObject.self, from: data) - } catch { - XCTAssertTrue(true) - return + } catch let decodeError { + error = decodeError } - XCTFail("Expected decode error to be thrown") + XCTAssertNotNil(error) } func testDecodeWhenDataCanBeDecodedReturnsDecodedObject() async throws { @@ -56,6 +56,28 @@ final class SerialiserTests: XCTestCase { XCTAssertEqual(result, expectedResult) } + func testEncodeWhenDataCannotBeEncodedThrowsEncodeError() async throws { + let data = Data() + + var error: Error? + do { + _ = try await serialiser.decode(MockObject.self, from: data) + } catch let decodeError { + error = decodeError + } + + XCTAssertNotNil(error) + } + + func testEncodeWhenDataCanBeEncodedReturnsData() async throws { + let value = MockObject() + let expectedResult = value.data + + let result = try await serialiser.encode(value) + + XCTAssertEqual(result, expectedResult) + } + } extension SerialiserTests { diff --git a/Tests/TMDbTests/Networking/APIClient/TMDbAPIClientTests.swift b/Tests/TMDbTests/APIClient/TMDbAPIClientTests.swift similarity index 75% rename from Tests/TMDbTests/Networking/APIClient/TMDbAPIClientTests.swift rename to Tests/TMDbTests/APIClient/TMDbAPIClientTests.swift index 1c0c77e9..7cbe2683 100644 --- a/Tests/TMDbTests/Networking/APIClient/TMDbAPIClientTests.swift +++ b/Tests/TMDbTests/APIClient/TMDbAPIClientTests.swift @@ -40,7 +40,7 @@ final class TMDbAPIClientTests: XCTestCase { let configuration = URLSessionConfiguration.default configuration.protocolClasses = [MockURLProtocol.self] httpClient = HTTPMockClient() - serialiser = Serialiser(decoder: .theMovieDatabase) + serialiser = Serialiser(decoder: .theMovieDatabase, encoder: .theMovieDatabase) localeProvider = LocaleMockProvider(languageCode: "en", regionCode: "GB") apiClient = TMDbAPIClient( apiKey: apiKey, @@ -133,7 +133,7 @@ final class TMDbAPIClientTests: XCTestCase { _ = try? await apiClient.get(path: URL(string: "/object")!) as String - let result = httpClient.lastHeaders?["Accept"] + let result = httpClient.lastRequest?.headers["Accept"] XCTAssertEqual(result, expectedResult) } @@ -147,7 +147,46 @@ final class TMDbAPIClientTests: XCTestCase { _ = try? await apiClient.get(path: URL(string: path)!) as String - let result = httpClient.lastURL + let result = httpClient.lastRequest?.url + + XCTAssertEqual(result, expectedResult) + } + + func testPostURLRequestAcceptHeaderSetToApplicationJSON() async throws { + httpClient.result = .success(HTTPResponse()) + let expectedResult = "application/json" + let pathURL = try XCTUnwrap(URL(string: "/object")) + + _ = try? await apiClient.post(path: pathURL, body: "adam") as String + + let result = httpClient.lastRequest?.headers["Accept"] + + XCTAssertEqual(result, expectedResult) + } + + func testPostURLRequestContentTypeHeaderSetToApplicationJSON() async throws { + httpClient.result = .success(HTTPResponse()) + let expectedResult = "application/json" + let pathURL = try XCTUnwrap(URL(string: "/object")) + + _ = try? await apiClient.post(path: pathURL, body: "adam") as String + + let result = httpClient.lastRequest?.headers["Content-Type"] + + XCTAssertEqual(result, expectedResult) + } + + func testPostURLRequestHasCorrectURL() async throws { + httpClient.result = .success(HTTPResponse()) + let path = "/object" + let pathURL = try XCTUnwrap(URL(string: path)) + let language = "en" + let urlString = "\(baseURL.absoluteURL)\(path)?api_key=\(apiKey!)&language=\(language)" + let expectedResult = try XCTUnwrap(URL(string: urlString)) + + _ = try? await apiClient.post(path: pathURL, body: "adam") as String + + let result = httpClient.lastRequest?.url XCTAssertEqual(result, expectedResult) } diff --git a/Tests/TMDbTests/Networking/APIClient/TMDbAPIErrorHTTPStatusCodeTests.swift b/Tests/TMDbTests/APIClient/TMDbAPIErrorHTTPStatusCodeTests.swift similarity index 100% rename from Tests/TMDbTests/Networking/APIClient/TMDbAPIErrorHTTPStatusCodeTests.swift rename to Tests/TMDbTests/APIClient/TMDbAPIErrorHTTPStatusCodeTests.swift diff --git a/Tests/TMDbTests/Helpers/LocaleProviderTests.swift b/Tests/TMDbTests/Adapters/LocaleProviderTests.swift similarity index 100% rename from Tests/TMDbTests/Helpers/LocaleProviderTests.swift rename to Tests/TMDbTests/Adapters/LocaleProviderTests.swift diff --git a/Tests/TMDbTests/Networking/HTTPClient/URLSessionHTTPClientAdapterTests.swift b/Tests/TMDbTests/Adapters/URLSessionHTTPClientAdapterTests.swift similarity index 72% rename from Tests/TMDbTests/Networking/HTTPClient/URLSessionHTTPClientAdapterTests.swift rename to Tests/TMDbTests/Adapters/URLSessionHTTPClientAdapterTests.swift index ba9366ad..639f0c00 100644 --- a/Tests/TMDbTests/Networking/HTTPClient/URLSessionHTTPClientAdapterTests.swift +++ b/Tests/TMDbTests/Adapters/URLSessionHTTPClientAdapterTests.swift @@ -41,18 +41,20 @@ final class URLSessionHTTPClientAdapterTests: XCTestCase { override func tearDown() { httpClient = nil + urlSession = nil baseURL = nil MockURLProtocol.reset() super.tearDown() } - func testGetWhenResponseStatusCodeIs401ReturnsUnauthorisedError() async throws { + func testPerformWhenResponseStatusCodeIs401ReturnsUnauthorisedError() async throws { MockURLProtocol.responseStatusCode = 401 + let url = try XCTUnwrap(URL(string: "/error")) + let request = HTTPRequest(url: url) let response: HTTPResponse do { - let url = try XCTUnwrap(URL(string: "/error")) - response = try await httpClient.get(url: url, headers: [:]) + response = try await httpClient.perform(request: request) } catch { XCTFail("Unexpected error thrown") return @@ -61,13 +63,14 @@ final class URLSessionHTTPClientAdapterTests: XCTestCase { XCTAssertEqual(response.statusCode, 401) } - func testGetWhenResponseStatusCodeIs404ReturnsNotFoundError() async throws { + func testPerformWhenResponseStatusCodeIs404ReturnsNotFoundError() async throws { MockURLProtocol.responseStatusCode = 404 + let url = try XCTUnwrap(URL(string: "/error")) + let request = HTTPRequest(url: url) let response: HTTPResponse do { - let url = try XCTUnwrap(URL(string: "/error")) - response = try await httpClient.get(url: url, headers: [:]) + response = try await httpClient.perform(request: request) } catch { XCTFail("Unexpected error thrown") return @@ -76,15 +79,16 @@ final class URLSessionHTTPClientAdapterTests: XCTestCase { XCTAssertEqual(response.statusCode, 404) } - func testGetWhenResponseStatusCodeIs404AndHasStatusMessageErrorThrowsNotFoundErrorWithMessage() async throws { + func testPerformWhenResponseStatusCodeIs404AndHasStatusMessageErrorThrowsNotFoundErrorWithMessage() async throws { MockURLProtocol.responseStatusCode = 404 let expectedData = try Data(fromResource: "error-status-response", withExtension: "json") MockURLProtocol.data = expectedData + let url = try XCTUnwrap(URL(string: "/error")) + let request = HTTPRequest(url: url) let response: HTTPResponse do { - let url = try XCTUnwrap(URL(string: "/error")) - response = try await httpClient.get(url: url, headers: [:]) + response = try await httpClient.perform(request: request) } catch { XCTFail("Unexpected error thrown") return @@ -98,40 +102,45 @@ final class URLSessionHTTPClientAdapterTests: XCTestCase { let expectedStatusCode = 200 let expectedData = Data("abc".utf8) MockURLProtocol.data = expectedData - let url = try XCTUnwrap(URL(string: "/object")) - let response = try await httpClient.get(url: url, headers: [:]) + let request = HTTPRequest(url: url) + + let response = try await httpClient.perform(request: request) XCTAssertEqual(response.statusCode, expectedStatusCode) XCTAssertEqual(response.data, expectedData) } - #if !canImport(FoundationNetworking) - func testGetURLRequestHasCorrectURL() async throws { +} + +#if !canImport(FoundationNetworking) + extension URLSessionHTTPClientAdapterTests { + + func testPerformURLRequestHasCorrectURL() async throws { let path = "/object?key1=value1&key2=value2" let expectedURL = try XCTUnwrap(URL(string: path)) + let request = HTTPRequest(url: expectedURL) - _ = try? await httpClient.get(url: expectedURL, headers: [:]) + _ = try? await httpClient.perform(request: request) let result = MockURLProtocol.lastRequest?.url XCTAssertEqual(result, expectedURL) } - #endif - #if !canImport(FoundationNetworking) - func testGetWhenHeaderSetShouldBePresentInURLRequest() async throws { + func testPerformWhenHeaderSetShouldBePresentInURLRequest() async throws { + let url = try XCTUnwrap(URL(string: "/object")) let header1Name = "Accept" let header1Value = "application/json" let header2Name = "Content-Type" let header2Value = "text/html" - - let url = try XCTUnwrap(URL(string: "/object")) let headers = [ header1Name: header1Value, header2Name: header2Value ] - _ = try? await httpClient.get(url: url, headers: headers) + let request = HTTPRequest(url: url, headers: headers) + + _ = try? await httpClient.perform(request: request) let lastURLRequest = try XCTUnwrap(MockURLProtocol.lastRequest) let result1 = lastURLRequest.value(forHTTPHeaderField: header1Name) @@ -140,6 +149,6 @@ final class URLSessionHTTPClientAdapterTests: XCTestCase { XCTAssertEqual(result1, header1Value) XCTAssertEqual(result2, header2Value) } - #endif -} + } +#endif diff --git a/Tests/TMDbTests/Extensions/JSONEncoder+TMDbTests.swift b/Tests/TMDbTests/Extensions/JSONEncoder+TMDbTests.swift new file mode 100644 index 00000000..0058c21f --- /dev/null +++ b/Tests/TMDbTests/Extensions/JSONEncoder+TMDbTests.swift @@ -0,0 +1,62 @@ +// +// JSONEncoder+TMDbTests.swift +// TMDb +// +// Copyright © 2024 Adam Young. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an AS IS BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +@testable import TMDb +import XCTest + +final class JSONEncoderTMDbTests: XCTestCase { + + var jsonEncoder: JSONEncoder! + var dateFormatter: DateFormatter! + + override func setUp() { + super.setUp() + dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-ddd" + jsonEncoder = JSONEncoder.theMovieDatabase + } + + func testTheMovieDatabaseEncoderEncodesObject() throws { + let value = SomeThing( + id: "abc123", + firstName: "Adam", + dateOfBirth: dateFormatter.date(from: "1990-01-02")! + ) + + let expectedIDResult = "\"id\":\"abc123\"" + let expectedFirstNameResult = "\"first_name\":\"Adam\"" + let expectedDataOfBirthResult = "\"date_of_birth\":\"1990-01-02\"" + + let data = try jsonEncoder.encode(value) + let dataAsString = try XCTUnwrap(String(decoding: data, as: UTF8.self)) + + XCTAssertTrue(dataAsString.contains(expectedIDResult)) + XCTAssertTrue(dataAsString.contains(expectedFirstNameResult)) + XCTAssertTrue(dataAsString.contains(expectedDataOfBirthResult)) + } + + private struct SomeThing: Encodable, Equatable { + + let id: String + let firstName: String + let dateOfBirth: Date + + } + +} diff --git a/Tests/TMDbTests/Extensions/URL+TMDbTests.swift b/Tests/TMDbTests/Extensions/URL+TMDbTests.swift index b81bc888..7e783c85 100644 --- a/Tests/TMDbTests/Extensions/URL+TMDbTests.swift +++ b/Tests/TMDbTests/Extensions/URL+TMDbTests.swift @@ -30,4 +30,12 @@ final class URLTMDbTests: XCTestCase { XCTAssertEqual(result, expectedResult) } + func testTMDbWebSiteBaseURLReturnsCorrectURL() throws { + let expectedResult = try XCTUnwrap(URL(string: "https://www.themoviedb.org")) + + let result = URL.tmdbWebSiteURL + + XCTAssertEqual(result, expectedResult) + } + } diff --git a/Tests/TMDbTests/Mocks/Networking/APIClient/MockAPIClient.swift b/Tests/TMDbTests/Mocks/Networking/APIClient/MockAPIClient.swift index 6cac484a..51714acc 100644 --- a/Tests/TMDbTests/Mocks/Networking/APIClient/MockAPIClient.swift +++ b/Tests/TMDbTests/Mocks/Networking/APIClient/MockAPIClient.swift @@ -24,11 +24,19 @@ final class MockAPIClient: APIClient { static var apiKey: String? - var result: Result? var requestTime: UInt64 = 0 + + var result: Result? private(set) var lastPath: URL? private(set) var getCount = 0 + var postResult: Result? + private(set) var lastPostPath: URL? + private(set) var lastPostBody: (any Encodable)? + private(set) var postCount = 0 + + init() {} + static func setAPIKey(_ apiKey: String) { Self.apiKey = apiKey } @@ -58,6 +66,36 @@ final class MockAPIClient: APIClient { } } + func post(path: URL, body: some Encodable) async throws -> Response { + lastPostPath = path + lastPostBody = body + postCount += 1 + + if requestTime > 0 { + try await Task.sleep(nanoseconds: requestTime * 1_000_000_000) + } + + guard let postResult else { + throw TMDbAPIError.unknown + } + + do { + guard let value = try postResult.get() as? Response else { + preconditionFailure("Can't cast response to type \(String(describing: Response.self))") + } + + return value + } catch let error as TMDbAPIError { + throw error + } catch { + throw TMDbAPIError.unknown + } + } + +} + +extension MockAPIClient { + func reset() { result = nil lastPath = nil diff --git a/Tests/TMDbTests/Mocks/Networking/HTTPClient/HTTPMockClient.swift b/Tests/TMDbTests/Mocks/Networking/HTTPClient/HTTPMockClient.swift index 561de5de..f27c92d6 100644 --- a/Tests/TMDbTests/Mocks/Networking/HTTPClient/HTTPMockClient.swift +++ b/Tests/TMDbTests/Mocks/Networking/HTTPClient/HTTPMockClient.swift @@ -23,14 +23,14 @@ import XCTest final class HTTPMockClient: HTTPClient { var result: Result? - private(set) var lastURL: URL? - private(set) var lastHeaders: [String: String]? - private(set) var getCount = 0 - - func get(url: URL, headers: [String: String]) async throws -> HTTPResponse { - lastURL = url - lastHeaders = headers - getCount += 1 + private(set) var lastRequest: HTTPRequest? + private(set) var performCount = 0 + + init() {} + + func perform(request: HTTPRequest) async throws -> HTTPResponse { + lastRequest = request + performCount += 1 guard let result else { preconditionFailure("Result not set.") diff --git a/Tests/TMDbTests/Models/SessionTests.swift b/Tests/TMDbTests/Models/SessionTests.swift new file mode 100644 index 00000000..ef72769b --- /dev/null +++ b/Tests/TMDbTests/Models/SessionTests.swift @@ -0,0 +1,37 @@ +// +// SessionTests.swift +// TMDb +// +// Copyright © 2024 Adam Young. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an AS IS BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +@testable import TMDb +import XCTest + +final class SessionTests: XCTestCase { + + func testDecodeReturnsSession() throws { + let expectedResult = Session( + success: true, + sessionID: "5f038ae0ee88737033fb7371dfbf6e3f386e9c78" + ) + + let result = try JSONDecoder.theMovieDatabaseAuth.decode(Session.self, fromResource: "session") + + XCTAssertEqual(result.success, expectedResult.success) + XCTAssertEqual(result.sessionID, expectedResult.sessionID) + } + +} diff --git a/Tests/TMDbTests/Models/TMDbErrorTMDbAPIErrorTests.swift b/Tests/TMDbTests/Models/TMDbErrorTMDbAPIErrorTests.swift new file mode 100644 index 00000000..f7854f26 --- /dev/null +++ b/Tests/TMDbTests/Models/TMDbErrorTMDbAPIErrorTests.swift @@ -0,0 +1,49 @@ +// +// TMDbErrorTMDbAPIErrorTests.swift +// TMDb +// +// Copyright © 2024 Adam Young. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an AS IS BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +@testable import TMDb +import XCTest + +final class TMDbErrorTMDbAPIErrorTests: XCTestCase { + + func testInitWithNonTMDbAPIErrorReturnsUnknownError() { + let error = NSError(domain: "test", code: -1) + + let tmdbError = TMDbError(error: error) + + XCTAssertEqual(tmdbError, .unknown) + } + + func testInitWithNotFoundTMDbAPIErrorReturnsNotFoundError() { + let error = TMDbAPIError.notFound(nil) + + let tmdbError = TMDbError(error: error) + + XCTAssertEqual(tmdbError, .notFound) + } + + func testInitWithUnauthorisedTMDbAPIErrorReturnsNotFoundError() { + let error = TMDbAPIError.notFound(nil) + + let tmdbError = TMDbError(error: error) + + XCTAssertEqual(tmdbError, .notFound) + } + +} diff --git a/Tests/TMDbTests/Networking/APIClient/TMDbStatusResponseTests.swift b/Tests/TMDbTests/Models/TMDbStatusResponseTests.swift similarity index 100% rename from Tests/TMDbTests/Networking/APIClient/TMDbStatusResponseTests.swift rename to Tests/TMDbTests/Models/TMDbStatusResponseTests.swift diff --git a/Tests/TMDbTests/Resources/json/session.json b/Tests/TMDbTests/Resources/json/session.json new file mode 100644 index 00000000..3eb3ebc9 --- /dev/null +++ b/Tests/TMDbTests/Resources/json/session.json @@ -0,0 +1,4 @@ +{ + "success": true, + "session_id": "5f038ae0ee88737033fb7371dfbf6e3f386e9c78" +} diff --git a/Tests/TMDbTests/Helpers/AuthenticateURLBuilderTests.swift b/Tests/TMDbTests/Services/Authentication/AuthenticateURLBuilderTests.swift similarity index 100% rename from Tests/TMDbTests/Helpers/AuthenticateURLBuilderTests.swift rename to Tests/TMDbTests/Services/Authentication/AuthenticateURLBuilderTests.swift diff --git a/Tests/TMDbTests/Services/Authentication/Endpoints/AuthenticationEndpointTests.swift b/Tests/TMDbTests/Services/Authentication/AuthenticationEndpointTests.swift similarity index 83% rename from Tests/TMDbTests/Services/Authentication/Endpoints/AuthenticationEndpointTests.swift rename to Tests/TMDbTests/Services/Authentication/AuthenticationEndpointTests.swift index 9e91e693..f4e08e4a 100644 --- a/Tests/TMDbTests/Services/Authentication/Endpoints/AuthenticationEndpointTests.swift +++ b/Tests/TMDbTests/Services/Authentication/AuthenticationEndpointTests.swift @@ -38,4 +38,12 @@ final class AuthenticationEndpointTests: XCTestCase { XCTAssertEqual(url, expectedURL) } + func testCreateSessionEndpointReturnsURL() throws { + let expectedURL = try XCTUnwrap(URL(string: "/authentication/session/new")) + + let url = AuthenticationEndpoint.createSession.path + + XCTAssertEqual(url, expectedURL) + } + } diff --git a/Tests/TMDbTests/Services/Authentication/AuthenticationServiceTests.swift b/Tests/TMDbTests/Services/Authentication/AuthenticationServiceTests.swift index d4d06084..0c05b2a5 100644 --- a/Tests/TMDbTests/Services/Authentication/AuthenticationServiceTests.swift +++ b/Tests/TMDbTests/Services/Authentication/AuthenticationServiceTests.swift @@ -119,4 +119,32 @@ final class AuthenticationServiceTests: XCTestCase { XCTAssertEqual(authenticateURLBuilder.lastRedirectURL, redirectURL) } + func testCreateSessionReturnsSession() async throws { + let requestToken = "abc123" + let expectedRequestBody = CreateSessionRequestBody(requestToken: requestToken) + let expectedResult = Session(success: true, sessionID: "987yxz") + apiClient.postResult = .success(expectedResult) + + let result = try await service.createSession(withRequestToken: requestToken) + + XCTAssertEqual(result, expectedResult) + XCTAssertEqual(apiClient.lastPostPath, AuthenticationEndpoint.createSession.path) + XCTAssertEqual(apiClient.lastPostBody as? CreateSessionRequestBody, expectedRequestBody) + } + + func testCreateErrorWhenErrorsThrowsError() async throws { + apiClient.result = .failure(.unknown) + + var error: Error? + do { + _ = try await service.requestToken() + } catch let err { + error = err + } + + let tmdbAPIError = try XCTUnwrap(error as? TMDbError) + + XCTAssertEqual(tmdbAPIError, .unknown) + } + } diff --git a/Tests/TMDbTests/TMDbTests.swift b/Tests/TMDbTests/TMDbTests.swift index 31b52253..6d850e64 100644 --- a/Tests/TMDbTests/TMDbTests.swift +++ b/Tests/TMDbTests/TMDbTests.swift @@ -59,9 +59,10 @@ extension TMDbTest { init() {} - func get(url _: URL, headers _: [String: String]) async throws -> HTTPResponse { + func perform(request _: HTTPRequest) async throws -> HTTPResponse { HTTPResponse() } + } } diff --git a/Tests/TMDbTests/TestUtils/JSONEncoder+TMDb.swift b/Tests/TMDbTests/TestUtils/JSONEncoder+TMDb.swift index 5cfbcb41..edf83c44 100644 --- a/Tests/TMDbTests/TestUtils/JSONEncoder+TMDb.swift +++ b/Tests/TMDbTests/TestUtils/JSONEncoder+TMDb.swift @@ -17,19 +17,38 @@ // limitations under the License. // -import Foundation -import TMDb - -extension JSONEncoder { - - static var theMovieDatabase: JSONEncoder { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd" - - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - encoder.dateEncodingStrategy = .formatted(dateFormatter) - return encoder - } - -} +//// +//// JSONEncoder+TMDb.swift +//// TMDb +//// +//// Copyright © 2024 Adam Young. +//// +//// Licensed under the Apache License, Version 2.0 (the "License"); +//// you may not use this file except in compliance with the License. +//// You may obtain a copy of the License at +//// +//// http://www.apache.org/licenses/LICENSE-2.0 +//// +//// Unless required by applicable law or agreed to in writing, software +//// distributed under the License is distributed on an AS IS BASIS, +//// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//// See the License for the specific language governing permissions and +//// limitations under the License. +//// +// +// import Foundation +// import TMDb +// +// extension JSONEncoder { +// +// static var theMovieDatabase: JSONEncoder { +// let dateFormatter = DateFormatter() +// dateFormatter.dateFormat = "yyyy-MM-dd" +// +// let encoder = JSONEncoder() +// encoder.keyEncodingStrategy = .convertToSnakeCase +// encoder.dateEncodingStrategy = .formatted(dateFormatter) +// return encoder +// } +// +// }