Swift・iOS

Swiftを中心に学んだことを記録に残すブログです。技術に関係ない記事もたまに書いています。

【Swift】GitHubのREST APIを使ってユーザーを検索する

 

はじめに

アーキテクチャやテストを自分で実装しながら学ぶためのサンプル(あえてアーキテクチャやテストを無視して実装したもの)が欲しかったため、GitHubREST APIを使ってユーザーを検索するサンプルを用意しました。

アーキテクチャやテストが考慮された、PEAKS(ピークス)|iOSアプリ設計パターン入門のサンプルコード(GitHub - peaks-cc/iOS_architecture_samplecode)を参考にしています。そのため、アーキテクチャやテストが考慮されたものについては、上記書籍と書籍のサンプルコードをご確認ください。

 

開発環境

 

実装

Main.storyboard

UISearchBarとUITableViewを配置。

※下の画像の通り、UITableViewはdelegateとdataSourceを設定してください。

f:id:hfoasi8fje3:20210302180631p:plain

 

ViewController.swift

import UIKit

class ViewController: UIViewController {
    
    @IBOutlet weak var searchBar: UISearchBar!
    @IBOutlet weak var tableView: UITableView!
    
    private(set) var users: [User] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(UINib(nibName: "TableViewCell", bundle: nil), forCellReuseIdentifier: "TableViewCell")
    }
    
    // ユーザー情報を取得
    func fetchUser(query: String, completion: @escaping (Result<[User]>) -> ()) {
        let request = SearchUsersRequest(query: query)
        
        Session().send(request) { result in
            switch result {
            case .success(let response):
                completion(.success(response.items))
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
}

extension ViewController: UISearchBarDelegate {
    // 編集開始時の処理
    func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
        // キャンセルボタンを表示
        searchBar.showsCancelButton = true
        
        return true
    }
    
    // キャンセルボタン選択時の処理
    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        // キャンセルボタンを非表示に変更
        searchBar.showsCancelButton = false
        // キーボードを下げる
        searchBar.resignFirstResponder()
    }
    
    // 検索実行時の処理
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        guard let query = searchBar.text else { return }
        guard !query.isEmpty else { return }
        
        fetchUser(query: query) { [weak self] result in
            switch result {
            case .success(let users):
                self?.users = users
                
                DispatchQueue.main.async {
                    self?.tableView.reloadData()
                }
            case .failure(let error):
                // TODO: Error Handling
                ()
            }
        }
    }
}

extension ViewController: UITableViewDataSource {
    // セルの個数
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return users.count
    }
    
    // セルを作成
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "TableViewCell", for: indexPath) as! TableViewCell
        cell.configure(user: users[indexPath.row])
        
        return cell
    }
}

 

TableViewCell.xib

UIImageViewとUILabelを配置しています。

f:id:hfoasi8fje3:20210302181156p:plain

 

TableViewCell.swift

import UIKit

class TableViewCell: UITableViewCell {
    
    @IBOutlet weak var icon: UIImageView!
    @IBOutlet weak var name: UILabel!
    
    private var task: URLSessionTask?
    
    // セルの初期化処理
    override func prepareForReuse() {
        super.prepareForReuse()
        
        task?.cancel()
        task = nil
        imageView?.image = nil
    }
    
    func configure(user: User) {
        // 画像URLからユーザー画像を取得して表示
        task = {
            let url = user.avatarURL
            let task = URLSession.shared.dataTask(with: url) { data, response, error in
                guard let imageData = data else {
                    return
                }
                
                DispatchQueue.global().async { [weak self] in
                    guard let image = UIImage(data: imageData) else {
                        return
                    }
                    
                    DispatchQueue.main.async {
                        self?.icon?.image = image
                        self?.setNeedsLayout()
                    }
                }
            }
            task.resume()
            return task
        }()
        
        name.text = user.login
    }
}

 

Session.swift

import Foundation

final class Session {
    private let additionalHeaderFields: () -> [String: String]?
    private let session: URLSession
    
    init(additionalHeaderFields: @escaping () -> [String: String]? = { nil }, session: URLSession = .shared) {
        self.additionalHeaderFields = additionalHeaderFields
        self.session = session
    }
    
    @discardableResult
    func send<T: Request>(_ request: T, completion: @escaping (Result<T.Response>) -> ()) -> URLSessionTask? {
        let url = request.baseURL.appendingPathComponent(request.path)

        guard var componets = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
            completion(.failure(SessionError.failedToCreateComponents(url)))
            return nil
        }
        componets.queryItems = request.queryParameters?.compactMap(URLQueryItem.init)
        
        guard var urlRequest = componets.url.map({ URLRequest(url: $0) }) else {
            completion(.failure(SessionError.failedToCreateURL(componets)))
            return nil
        }
        urlRequest.httpMethod = request.method.rawValue
        
        let headerFields: [String: String]
        if let additionalHeaderFields = additionalHeaderFields() {
            headerFields = request.headerFields.merging(additionalHeaderFields, uniquingKeysWith: +)
        } else {
            headerFields = request.headerFields
        }
        
        urlRequest.allHTTPHeaderFields = headerFields
        
        let task = session.dataTask(with: urlRequest) { data, response, error in
            if let error = error {
                completion(.failure(error))
                return
            }
            
            guard let response = response as? HTTPURLResponse else {
                completion(.failure(SessionError.noResponse))
                return
            }
            
            guard let data = data else {
                completion(.failure(SessionError.noData(response)))
                return
            }
            
            guard  200..<300 ~= response.statusCode else {
                let message = try? JSONDecoder().decode(SessionError.Message.self, from: data)
                completion(.failure(SessionError.unacceptableStatusCode(response.statusCode, message)))
                return
            }
            
            do {
                let object = try JSONDecoder().decode(T.Response.self, from: data)
                completion(.success(object))
            } catch {
                completion(.failure(error))
            }
        }
        
        task.resume()
        
        return task
    }
}

enum SessionError: Error {
    case noData(HTTPURLResponse)
    case noResponse
    case unacceptableStatusCode(Int, Message?)
    case failedToCreateComponents(URL)
    case failedToCreateURL(URLComponents)
}

extension SessionError {
    struct Message: Decodable {
        let documentationURL: URL
        let message: String
        
        private enum CodingKeys: String, CodingKey {
            case documentationURL = "documentation_url"
            case message
        }
    }
}

 

Request.swift

import Foundation

protocol Request {
    associatedtype Response: Decodable
    
    var baseURL: URL { get }
    var method: HttpMethod { get }
    var path: String { get }
    var headerFields: [String: String] { get }
    var queryParameters: [String: String]? { get }
}

extension Request {
    var baseURL: URL {
        return URL(string: "https://api.github.com")!
    }
    
    var headerFields: [String: String] {
        return ["Accept": "application/json"]
    }
    
    var queryParameters: [String: String]? {
        return nil
    }
}

enum HttpMethod: String {
    case get = "GET"
    case post = "POST"
}

 

SearchUsersRequest.swift

import Foundation

struct SearchUsersRequest: Request {
    typealias Response = ItemsResponse<User>
    
    let method: HttpMethod = .get
    let path = "/search/users"
    
    var queryParameters: [String: String]? {
        let params: [String: String] = ["q": query]
        return params
    }
    
    let query: String
    
    init(query: String) {
        self.query = query
    }
}

 

ItemsResponse.swift

import Foundation

struct ItemsResponse<Item: Decodable>: Decodable {
    let items: [Item]
    
    init(items: [Item]) {
        self.items = items
    }
}

 

Result.swift

import Foundation

enum Result<T> {
    case success(T)
    case failure(Error)
}

 

User.swift

import Foundation

struct User: Codable {
    let login: String
    let avatarURL: URL
    
    private enum CodingKeys: String, CodingKey {
        case login
        case avatarURL = "avatar_url"
    }
    
    init(login: String, avatarURL: URL) {
        self.login = login
        self.avatarURL = avatarURL
    }
}

 

おわりに

サンプルを使って、今後以下の順番でアーキテクチャやテストの理解を深めたいと思います。

  1. アーキテクチャやテストの観点からサンプルを自分で修正してみる
  2. iOSアプリ設計パターン入門のサンプルコードを見ながら修正

 

※記事にまとめながら気づいたのですが、リストのUIはiOS14を意識してUITableViewではなくUICollectionViewで実装すればよかったですね・・・。

UICollectionViewでUITableViewのUIを表現する実装は別途試してみたいと思います。

 

参考