Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions MovieApp/Data/Network/APIClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import Foundation

// MARK: - APIClientProtocol Definition
protocol APIClientProtocol {
func request<T: Decodable>(urlRequest: URLRequest, completion: @escaping (Result<T, APIError>) -> Void)
func buildRequest(path: String, method: String, params: [String: String]?) -> URLRequest?
}

class APIClient: APIClientProtocol { // Made APIClient conform to the protocol

// MARK: - Properties

// !!!IMPORTANT!!!: This is not a secure way to store an API key.
// For a production app, this should be stored securely,
// e.g., in a configuration file not committed to version control,
// or retrieved from a server-side service.
static let apiKey = "124e713abb7dfb463142baac075d3e11"

static let shared = APIClient() // Keep shared instance for default usage
private let session: URLSession

// MARK: - Initialization
// Made init public to allow instantiation where needed, e.g. for the shared instance
// or if someone wants to create a specific session-configured client.
// For DI, we usually pass the protocol, so this direct instantiation is less common outside initial setup.
// Making it internal as 'shared' provides the default, and DI uses the protocol.
internal init(session: URLSession = .shared) {
self.session = session
}

// MARK: - Request Method

/// Performs a network request and decodes the response.
/// - Parameters:
/// - urlRequest: The URLRequest to be executed.
/// - completion: A closure to be called once the request is complete.
/// It returns a `Result` type with either the decoded data (`T`)
/// or an `APIError`.
func request<T: Decodable>(urlRequest: URLRequest, completion: @escaping (Result<T, APIError>) -> Void) {

session.dataTask(with: urlRequest) { data, response, error in
// Handle client-side errors (e.g., network connectivity issues)
if let error = error {
completion(.failure(.networkError(error)))
return
}

// Ensure we have an HTTP response
guard let httpResponse = response as? HTTPURLResponse else {
completion(.failure(.invalidResponse(statusCode: 0))) // Or a more specific error
return
}

// Check for successful HTTP status codes
guard (200...299).contains(httpResponse.statusCode) else {
completion(.failure(.invalidResponse(statusCode: httpResponse.statusCode)))
return
}

// Ensure data is present
guard let data = data else {
// This case should ideally be covered by the HTTP status code check,
// but as a safeguard:
completion(.failure(.invalidResponse(statusCode: httpResponse.statusCode))) // Or a new error case e.g., .noData
return
}

// Attempt to decode the JSON response
do {
let decoder = JSONDecoder()
// Convention for converting snake_case keys from API to camelCase
decoder.keyDecodingStrategy = .convertFromSnakeCase
let decodedObject = try decoder.decode(T.self, from: data)
completion(.success(decodedObject))
} catch let decodingError {
completion(.failure(.decodingError(decodingError)))
}
}.resume()
}

/// Helper to create a URLRequest for The Movie Database (TMDB) API.
/// - Parameters:
/// - path: The endpoint path (e.g., "/movie/popular").
/// - method: The HTTP method (default is "GET").
/// - params: Optional query parameters for the request.
/// - Returns: A URLRequest? or nil if URL construction fails.
func buildRequest(path: String, method: String = "GET", params: [String: String]? = nil) -> URLRequest? {
var components = URLComponents()
components.scheme = "https"
components.host = "api.themoviedb.org"
components.path = "/3" + path // API version 3

var queryItems = [URLQueryItem(name: "api_key", value: APIClient.apiKey)]
if let params = params {
queryItems.append(contentsOf: params.map { URLQueryItem(name: $0.key, value: $0.value) })
}
components.queryItems = queryItems

guard let url = components.url else {
return nil
}

var request = URLRequest(url: url)
request.httpMethod = method
// Add any common headers here if needed, e.g.:
// request.setValue("application/json", forHTTPHeaderField: "Accept")
// request.setValue("application/json", forHTTPHeaderField: "Content-Type")

return request
}
}
23 changes: 23 additions & 0 deletions MovieApp/Data/Network/APIError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import Foundation

enum APIError: Error {
case networkError(Error)
case invalidResponse(statusCode: Int)
case decodingError(Error)
case invalidURL
}

extension APIError: LocalizedError {
var errorDescription: String? {
switch self {
case .networkError(let error):
return "Network error: \(error.localizedDescription)"
case .invalidResponse(let statusCode):
return "Invalid response with status code: \(statusCode)"
case .decodingError(let error):
return "Decoding error: \(error.localizedDescription)"
case .invalidURL:
return "The provided URL was invalid."
}
}
}
162 changes: 162 additions & 0 deletions MovieApp/Data/Repositories/MovieRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import Foundation

// APIClientProtocol (and APIClient) are expected to be in Data/Network
// MovieRepositoryProtocol is expected to be in Domain/Interfaces/Repositories
// PaginatedResponse, Movie, APIError are expected to be defined appropriately

class MovieRepository: MovieRepositoryProtocol {

private let apiClient: APIClientProtocol
private let userDefaults: UserDefaults

// Cache Keys
private static let nowPlayingCacheKey = "nowPlayingMoviesCache"
private static let popularCacheKey = "popularMoviesCache"
private static let upcomingCacheKey = "upcomingMoviesCache"
private static func movieDetailCacheKey(id: Int) -> String { "movieDetailCache_\(id)" }

init(apiClient: APIClientProtocol = APIClient.shared, userDefaults: UserDefaults = .standard) {
self.apiClient = apiClient
self.userDefaults = userDefaults
}

// MARK: - Caching Helper Methods
private func cacheData<T: Encodable>(_ data: T, forKey key: String) {
do {
let encodedData = try JSONEncoder().encode(data)
userDefaults.set(encodedData, forKey: key)
} catch {
// Log error or handle it as appropriate for your app
print("Caching error: Failed to encode data for key \(key). Error: \(error)")
}
}

private func loadData<T: Decodable>(forKey key: String) -> T? {
guard let savedData = userDefaults.data(forKey: key) else {
return nil
}
do {
let decodedData = try JSONDecoder().decode(T.self, from: savedData)
return decodedData
} catch {
print("Caching error: Failed to decode data for key \(key). Error: \(error)")
// Consider removing corrupted data: userDefaults.removeObject(forKey: key)
return nil
}
}

// MARK: - Data Fetching Methods with Caching

func getNowPlayingMovies(page: Int, completion: @escaping (Result<PaginatedResponse<Movie>, Error>) -> Void) {
let cacheKey = MovieRepository.nowPlayingCacheKey
let cachedResponse: PaginatedResponse<Movie>? = loadData(forKey: cacheKey)

let path = "/movie/now_playing"
let params = ["page": String(page)]

guard let urlRequest = apiClient.buildRequest(path: path, params: params) else {
guard let urlRequest = apiClient.buildRequest(path: path, params: params) else {
guard let urlRequest = apiClient.buildRequest(path: path, params: params) else {
completion(.failure(APIError.invalidURL))
return
}

apiClient.request(urlRequest: urlRequest) { [weak self] (result: Result<PaginatedResponse<Movie>, APIError>) in
guard let self = self else { return }
switch result {
case .success(let networkResponse):
self.cacheData(networkResponse, forKey: cacheKey)
completion(.success(networkResponse))
case .failure(let error):
if let cachedData = cachedResponse {
completion(.success(cachedData))
} else {
completion(.failure(error as Error))
}
}
}
}

func getPopularMovies(page: Int, completion: @escaping (Result<PaginatedResponse<Movie>, Error>) -> Void) {
let cacheKey = MovieRepository.popularCacheKey
let cachedResponse: PaginatedResponse<Movie>? = loadData(forKey: cacheKey)

let path = "/movie/popular"
let params = ["page": String(page)]

guard let urlRequest = apiClient.buildRequest(path: path, method: "GET", params: params) else {
completion(.failure(APIError.invalidURL))
return
}

apiClient.request(urlRequest: urlRequest) { [weak self] (result: Result<PaginatedResponse<Movie>, APIError>) in
guard let self = self else { return }
switch result {
case .success(let networkResponse):
self.cacheData(networkResponse, forKey: cacheKey)
completion(.success(networkResponse))
case .failure(let error):
if let cachedData = cachedResponse {
completion(.success(cachedData))
} else {
completion(.failure(error as Error))
}
}
}
}

func getUpcomingMovies(page: Int, completion: @escaping (Result<PaginatedResponse<Movie>, Error>) -> Void) {
let cacheKey = MovieRepository.upcomingCacheKey
let cachedResponse: PaginatedResponse<Movie>? = loadData(forKey: cacheKey)

let path = "/movie/upcoming"
let params = ["page": String(page)]

guard let urlRequest = apiClient.buildRequest(path: path, method: "GET", params: params) else {
completion(.failure(APIError.invalidURL))
return
}

apiClient.request(urlRequest: urlRequest) { [weak self] (result: Result<PaginatedResponse<Movie>, APIError>) in
guard let self = self else { return }
switch result {
case .success(let networkResponse):
self.cacheData(networkResponse, forKey: cacheKey)
completion(.success(networkResponse))
case .failure(let error):
if let cachedData = cachedResponse {
completion(.success(cachedData))
} else {
completion(.failure(error as Error))
}
}
}
}

func getMovieDetails(id: Int, completion: @escaping (Result<Movie, Error>) -> Void) {
let cacheKey = MovieRepository.movieDetailCacheKey(id: id)
let cachedMovie: Movie? = loadData(forKey: cacheKey)

let path = "/movie/\(id)"

guard let urlRequest = apiClient.buildRequest(path: path, params: nil) else {
completion(.failure(APIError.invalidURL))
return
}

apiClient.request(urlRequest: urlRequest) { [weak self] (result: Result<Movie, APIError>) in
guard let self = self else { return }
switch result {
case .success(let networkMovie):
self.cacheData(networkMovie, forKey: cacheKey)
completion(.success(networkMovie))
case .failure(let error):
if let cachedData = cachedMovie {
completion(.success(cachedData))
} else {
completion(.failure(error as Error))
}
}
}
}
}
78 changes: 78 additions & 0 deletions MovieApp/Domain/Entities/Movie.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Foundation

// MARK: - Genre

struct Genre: Decodable, Identifiable, Hashable {
let id: Int
let name: String
}

// MARK: - Movie

struct Movie: Decodable, Identifiable, Hashable {
let id: Int
let title: String
let releaseDate: String?
let posterPath: String?
let overview: String?
let genres: [Genre]? // This will be populated if fetching movie details
let runtime: Int?
let popularity: Double?
let voteAverage: Double?
let voteCount: Int?

enum CodingKeys: String, CodingKey {
case id
case title
case releaseDate = "release_date"
case posterPath = "poster_path"
case overview
case genres
case runtime
case popularity
case voteAverage = "vote_average"
case voteCount = "vote_count"
}

var posterURL: URL? {
guard let path = posterPath else { return nil }
return URL(string: "https://image.tmdb.org/t/p/w500\(path)")
}
}

// MARK: - PaginatedResponse

struct PaginatedResponse<T: Decodable>: Decodable {
let page: Int
let results: [T]
let totalPages: Int
let totalResults: Int

enum CodingKeys: String, CodingKey {
case page
case results
case totalPages = "total_pages"
case totalResults = "total_results"
}
}

// Example Usage (for compilation check, can be removed later)
/*
struct MovieService {
func fetchPopularMovies(completion: @escaping (Result<PaginatedResponse<Movie>, APIError>) -> Void) {
guard let urlRequest = APIClient.shared.buildRequest(path: "/movie/popular") else {
completion(.failure(.invalidURL))
return
}
APIClient.shared.request(urlRequest: urlRequest, completion: completion)
}

func fetchMovieDetails(movieId: Int, completion: @escaping (Result<Movie, APIError>) -> Void) {
guard let urlRequest = APIClient.shared.buildRequest(path: "/movie/\(movieId)") else {
completion(.failure(.invalidURL))
return
}
APIClient.shared.request(urlRequest: urlRequest, completion: completion)
}
}
*/
Loading