SwiftのWebKitを使ってHTTPリクエストやレスポンスの中身を見てみる。

Swiftでアプリを開発している際に、Webブラウザの特定のページにおいて、特定の処理を実行したい場合があったので、その方法を共有してみたいと思います。



<h2> 目次 </h2>

<ul>
<li>HTTPリクエストとレスポンスの構造</li>
<li>実装</li>
<li>実行結果</li>
<li>終わりに</li>
<li>参考記事</li>
</ul>


<h2>HTTPリクエストとレスポンスの構造</h2>

　そもそもHTTPとは、Webサーバーから情報を取り出したり、Webサーバーへ情報を送ったりするために決められた通信の手法のことです。

<figure>
<p>
<img src = "https://image.itmedia.co.jp/ait/articles/1508/31/tcpip2_5.gif">
</p>
<figcaption>図１：クライアントとサーバーの関係</figcaption>
</figure>

<h4>HTTPの流れ</h4>

<ol>
<li>クライアント側でURLを入力すると、URLで指定されたWebサーバーにリクエストを送信します。</li>
<li>サーバー側で必要な処理を行い、クライアント側にレスポンスとして返します。</li>
<li>レスポンスを受けたクライアントは、切断を行い通信を終了します。</li>
</ol>

　今回は、このクライアントとサーバーでやりとりされているリクエストとレスポンスの中身をアプリ側で見れるようにします。

<h4>HTTPリクエストの仕組み</h4>

　HTTPリクエストは大きく分けて「リクエスト行」「ヘッダーフィールド」「メッセージ本体(ボディ部)」の3つから成ります。「リクエスト行」はHTTP通信の方式を表します。(POSTやGETなど)今回はPOST方式のHTTPリクエストを扱います。また、「ヘッダーフィールド」にはサーバー名などの補足的な情報が入ります。「メッセージ本体」には、POST方式の際にサーバーにポストしたい情報が格納されます。GET方式の際はメッセージ本体は省略され、送信したい情報がURLに組み込まれることになります。<br>
&emsp;今回POST方式のHTTPリクエストを扱うので、メッセージ本体を取得してくる必要があります。(GET方式の場合はURLの取得方法を応用して下さい。)


<figure>
<p>
<img src = "https://image.itmedia.co.jp/ait/articles/1508/31/tcpip2_7.gif">
</p>
<figcaption>図2：HTTPリクエストの構造</figcaption>
</figure>


<h4>HTTPレスポンスの構造</h4>

<p>
&emsp;HTTPレスポンスはリクエストと同様に、大きく３つの部分から成ります。「ステータス行」ではリクエスト処理の結果がステータスコードに格納されています。(ex.200だと成功、404だとページが見つからなかった場合のエラー　etc.)「ヘッダーフィールド」は実行結果の情報や補足的な情報が格納されています。「メッセージ本体」にはリクエストしたファイルなどが添付されています。今回は特にファイルをリクエストするわけではないので、特にメッセージ本体が存在するわけではありません。なので、ヘッダーフィールドまでの内容を取得してみます。
</p>

<figure>
<p>
<img src = "https://image.itmedia.co.jp/ait/articles/1511/27/tcp3_1.gif">
</p>
<figcaption>図3：HTTPレスポンスの構造</figcaption>
</figure>


<h2>実装</h2>
<h4>開発環境</h4>
<ul>
<li>Xcode:14.0.1</li>
<li>Swift:5.7</li>
<li>iPadOS:16.1</li>
</ul>

<h4>DelegateメソッドとHTTPリクエスト、レスポンスの取得</h4>
下記のコードの大まかな流れとしては、以下のようになります。
<ol>
<li>サーバーとの通信のためにサーバー証明書とクライアント証明書の認証を行います。</li>
<li>webKitで表示されている画面上で操作して遷移するタイミング（リクエストの送信前）で、読み込み設定のメソッドが呼び出される</li>
<li>読み込み設定のメソッド中で、HTTPリクエストのメッセージ本体を取得する</li>
<li>サーバーからレスポンスが帰ってきた時点で、レスポンス取得後の読み込み設定のメソッドが呼び出される</li>
<li>レスポンス取得後のメソッド内でHTTPレスポンスのヘッダーフィールドを取得する</li>
</ol>
今回はWebサイトの動きが分かりやすくなるように、サーバーとの通信前後だけでなく、ページの読み込みやリダイレクトのタイミングもコンソールに出力されるようにしました。これらは別途メソッドを用意して実装しています。

```Swift:MyWebView.swift

struct MyWebView: UIViewRepresentable{

    var url: String = "WebページのURL"

    class  Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate, RequestAdapter, URLSessionDelegate {
   
    var parent: MyWebView
    var responseURL: URL?

    init(_parent: MyWebView){
        self.parent = parent
    }

    //webView表示のライフサイクルを検知するdelegate
    // MARK: - サーバ・クライアント認証（このメソッドを呼ばないと認証してくれない）
    func webView(_ webView: WKWebView,
                     didReceive challenge: URLAuthenticationChallenge,
                     completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
           
            //ここでサーバ証明書の認証を行う必要がある
            if let trust: SecTrust = challenge.protectionSpace.serverTrust {
                //サーバ証明書の信頼
                print("サーバ証明書認証")
                completionHandler(URLSession.AuthChallengeDisposition.useCredential, URLCredential(trust: trust))
                return
            } else {
                //クライアント証明書を指定する必要がある
                print("クライアント証明書認証")
                
                
                
                //loadCertificateは後ほど定義する
                //ここでは、loadCertificateでクライアント証明書の情報を読み込む
                if let identiry = self.parent.loadCertificate(from: "クライアント証明書のファイル名", passwowrd: "クライアント証明書のパスワード") {
                    //クライアント証明書の情報をキーチェーンに保存し、AppleIDと紐付けて保存する。
                    let credential = URLCredential(identity: identiry, certificates: nil, persistence: URLCredential.Persistence.forSession)
                    
                    completionHandler(.useCredential, credential)
                    return
                }
                
            }
            completionHandler(.performDefaultHandling, nil)
    }//サーバー•クライアント認証のメソッドが終了

    // MARK: - 読み込み設定（リクエスト前）とHTTPリクエストの取得
        func webView(_ webView: WKWebView,
                     decidePolicyFor navigationAction: WKNavigationAction,
                     decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
            print("リクエスト前")

            /*
             * WebView内の特定のリンクをタップした時の処理などが書ける
             */
            let url = navigationAction.request.url
            
            print("読み込もうとしているページのURLが取得できる: ", url ?? "")
            // リンクをタップしてページを読み込む前に呼ばれるので、例えば、urlをチェックして
            // ①AppStoreのリンクだったらストアに飛ばす
            // ②Deeplinkだったらアプリに戻る
            // みたいなことができる

            /*  これを設定しないとアプリがクラッシュする
             *  .allow  : 読み込み許可
             *  .cancel : 読み込みキャンセル
             */
            decisionHandler(.allow)

            // MARK: - httpリクエストの表示
            let showingURL = URL(string:"ここに見たいHTTPリクエストのURLを入れる")
            
            if(url == showingURL){
               //httpリクエストのbody部分を取得して、コンソールに表示
                let body = navigationAction.request.httpBody
                print(String(data: body!, encoding: .utf8)!)
            }
     }//読み込み設定のメソッドが終了

        //RequestAdapterに準拠させるために必要
        func adapt(_ urlRequest: URLRequest, for session: Alamofire.Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
            var urlRequest = urlRequest
            
            print(String(data:urlRequest.httpBody!, encoding: .utf8))
        }

        // MARK: - 読み込み準備開始
        func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
            print("読み込み準備開始")
        }

        // MARK: - 読み込み設定（レスポンス取得後）とHTTPレスポンスを取得するメソッド
        func webView(_ webView: WKWebView,
                     decidePolicyFor navigationResponse: WKNavigationResponse,
                     decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
            print("レスポンス取得後")

            /*  これを設定しないとアプリがクラッシュする
             *  .allow  : 読み込み許可
             *  .cancel : 読み込みキャンセル
             */
            decisionHandler(.allow)

            //レスポンスURLの取得
            responseURL = navigationResponse.response.url
            let showingURL = URL(string:"ここに取得したいHTTPレスポンスのURLを入れる")

            if(responseURL == showingURL){
                //HTTPレスポンスのヘッダーフィールドを取得してコンソールに表示
                let response = navigationResponse.response
                //HTTPレスポンスのメッセージ本体の長さを取得(今回はメッセージ本体がないので-1が出力される)
                let length = navigationResponse.response.expectedContentLength
                print(response)
                print(length)   
            }
        }//読み込み設定（レスポンス取得後）とHTTPレスポンスを取得するメソッドの終了
         
        // MARK: - 読み込み開始
        func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
            print("読み込み開始")
        }

        // MARK: - 読み込み完了
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {    
            print("読み込み完了")
        }

        // MARK: - 読み込み失敗検知 読み込み開始時にエラー
        func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError: Error) {
            print("読み込み失敗検知")
        }

        // MARK: - 読み込み失敗　読み込み途中にエラー
        func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError: Error) {
            print("読み込み失敗")
            
        }

        // MARK: - リダイレクト
        func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation:WKNavigation!) {
            print("リダイレクト")
        }
    }//class coordinatorの終了

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    //ライフサイクルの中で一度だけ最初に呼ばれ、ラップする対象のUIViewを表示する
    func makeUIView(context: Context) -> WKWebView{
        let preferences = WKPreferences()
        preferences.javaScriptCanOpenWindowsAutomatically = true
        let configuration = WKWebViewConfiguration()
        configuration.preferences = preferences
        return WKWebView(frame: .zero, configuration: configuration)
    }
    
    //Viewが更新されるたびによばれる
    func updateUIView(_ webView: WKWebView, context: Context) {
        //makeCoordinatorで生成したCoordinatorクラスのインスタンスを指定
        webView.uiDelegate = context.coordinator
        webView.navigationDelegate = context.coordinator
        //urlをロードする
        webView.load(URLRequest(url: URL(string: url)!))
    }


    //クライアント証明書を探してきて、その情報をアプリにインポートしてその情報を戻り値として返す
    private func loadCertificate(from filename: String, passwowrd: String) -> SecIdentity? {

        guard
            //ファイル名と拡張子p12で検索をかけてパスを取得する
            let path = Bundle.main.path(forResource: filename, ofType: "p12"),
            //取得してきたパスを元にURLを作成する
            let pfxData = try? Data(contentsOf: URL(fileURLWithPath: path))
        else {
            //失敗した場合の戻り値はなし
            return nil
        }
        
        var result: CFArray?
        //パスワードを配列に格納する
        let query = [
            kSecImportExportPassphrase as String: passwowrd,
        ]
        //作成したURL（クライアント証明書の情報）とパスワードを格納して結果を返す
        let status = SecPKCS12Import(pfxData as NSData, query as NSDictionary, &result)
        //失敗した場合の戻り値はなし
        if (status != errSecSuccess) {
            return nil
        }
        //成功した場合の処理（クライアント証明書とパスワードを変数に代入して戻り値として返す）
        let resultDict = result as? [[String : Any]]
        let identity = resultDict?.first?[kSecImportItemIdentity as String] as! SecIdentity
        return identity
     }


}//struct myWebViewの終了
```


<h2>実行結果</h2>
　アプリ上に表示された目的のWebページを操作してHTTP通信をします。その際のコンソールの出力を下記に示しています。最後に表示されてる-1はHTTPレスポンスのメッセージ本体のlengthが出力されています。今回は特に添付ファイル等が無かったので、メッセージ本体が省略されているため、-1が出力されています。

```:コンソールの表示

読み込み開始
読み込み完了
リクエスト前
読み込もうとしているページのURLが取得できる:  "ソースコードで設定したURLが表示される"
------WebKitFormBoundaryBU6p7uBwcsV8Iis1
Content-Disposition：　"リクエスト情報のメッセージ本体の内容が出力される"

読み込み準備開始

レスポンス取得後
<NSHTTPURLResponse: "レスポンスのコードが出力される"> { URL: "ソースコードで設定したURLが表示される"} { Status Code: 200,
 Headers {
"HTTPレスポンスのヘッダーフィールドの内容が表示される"
}
-1

```

<h2>終わりに</h2>
　　ソースコードの一部のみ記載しております。そのままコピペするだけでは動かないので、ContentViewからWebKitで目標のWebサイトにアクセスができる事が前提となっています。リファクタリングしていないので、ソースコードが綺麗ではないですが、少しでもお役に立てたら嬉しいです。

<h2>参考記事</h2>

https://atmarkit.itmedia.co.jp/ait/articles/1508/31/news016_2.html

https://atmarkit.itmedia.co.jp/ait/articles/1511/27/news013.html

https://zenn.dev/dd_sho/articles/f5bbee06133063