LoginSignup
3
2

More than 1 year has passed since last update.

「The Ultimate Guide to WKWebView」をSwiftUIで実装する #11 - Reading and deleting cookies -

Last updated at Posted at 2022-03-15

「The Ultimate Guide to WKWebView」をSwiftUIで実装してみるの、
11こ目になります。

WKWebViewの中では鬼門・・・と勝手に思っているcookieを扱います!
といっても多分鬼門なのはcookieの設定なのだと思っています。今回はタイトルの通り、ReadとDeleteをしているのみになります。

目次

シリーズ化していこうと思うので、全体の目次を置いておきます。
リンクが貼られていないタイトルは、記事作成中または未作成のものになります。

# タイトル
01 Making a web view fill the screen
(WebViewを画面に表示する)
02 Loading remote content
(リモートのコンテンツを読み込む)
03 Loading local content
(ローカルのコンテンツを読み込む)
04 Loading HTML fragments
(HTMLフラグメントの読み込み)
05 Controlling which sites can be visited
(訪問可能なサイトの制御)
06 Opening a link in the external browser
(外部ブラウザでリンクを開く)
07 Monitoring page loads
(ページの読み込みを監視する)
08 Reading a web page’s title as it changes
(Webページのタイトルの変化を読み取る)
09 Reading pages the user has visited
(ユーザーが閲覧したページを読み取る)
10 Injecting JavaScript into a page
(JavaScriptをページに注入する)
11 Reading and deleting cookies
(cookieの読み取りと削除)
12 Providing a custom user agent
(カスタムUser Agentを提供する)
13 Showing custom UI
(カスタムUIを表示する)
14 Snapshot part of the page
(ページの一部のスナップショットを撮る)
15 Detecting data
(データの探索)

環境

【Xcode】13.1
【Swift】5.5
【iOS】15.0
【macOS】Big Sur バージョン 11.4

実現したいこと

今回は、色々やっていますが、流れとしてはこうです。

  1. WebViewを開いてログインする(「ログインしました」のWebページを開いた時にネイティブ側で認証用cookieを取得)
  2. そのcookieを使用して、データ取得のAPIをリクエストする
  3. その結果を「取得結果:」の下に反映する

認証用cookieがない状態でリクエストしても、データが取得できないことを確認するため、
一番最初に 「情報取得」のボタンを押しています。「error」という文字が表示されていますね。

認証用cookieを受け取った後は、ちゃんと正常レスポンスが返ってきており、「kamimi」が表示されています。

app.gif

まあだいぶUIはダサダサというか何も考えてないですが、よくあるんじゃないかなーという流れを再現してみました。

用意したAPIについて、少し補足します。以下2つを用意しています。

  1. http://localhost:3000/set-cookieのレスポンスヘッダにauthentication=authentication_infoを含めて、HTMLを返す
  2. http://localhost:3000/userauthentication=authentication_infoがcookieのリクエストヘッダにあった場合に、200正常ステータスで{"username": "kamimi"}を返す。それ以外の場合は、403Forbiddenステータスで、{"error": "error"}を返す。

これがざっくりとしたI/Fです。実装は、APIの用意の方に書いてあるので、そちらをご確認ください。

実現方法

ではまずWebViewです。

少し長いのですが、メインはwebView(_:didFinish:)メソッドにあるメソッド2つ。

  • getAllCookies(_:)
  • delete(_:completionHandler:)

その名の通り、cookieの取得と削除をするメソッドです。

ちなみにコメントにも書いているのですが、「なぜここでcookieを削除?」と思われたかと思うのですが、
記事のタイトルに合わせて取得と削除両方をやりたかったためです。
本当は、どっちも必要になるユースケースを考えたかったのですが、残念ながらそういうユースケースが思いつかなかった・・・ :sob:

WebView.swift
import SwiftUI
import WebKit

struct WebView: UIViewRepresentable {
    let url: URL
    private let webView = WKWebView()

    func makeUIView(context: Context) -> WKWebView {
        webView.navigationDelegate = context.coordinator
        let request = URLRequest(url: url)
        webView.load(request)
        return webView
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
    }

    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }
}

extension WebView {
    class Coordinator: NSObject, WKNavigationDelegate {
        var parent: WebView

        init(_ parent: WebView) {
            self.parent = parent
        }

        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            let cookieName = "authentication"
            // Cookie取得
            webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in
                for cookie in cookies {
                    print("cookie:", cookie.name, "\nvalue:", cookie.value)
                    if cookie.name == cookieName {
                        // UserDefaultsに保存
                        UserDefaults.standard.set(cookie.value, forKey: cookieName)

                        // Cookieを削除(実際はここで削除する意味はないが、cookieの取得と削除の記事を一緒に書きたいという都合上)
                         webView.configuration.websiteDataStore.httpCookieStore.delete(cookie) {
                            print("削除成功")
                        }
                        webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in
                            print("cookies1:", cookies)
                        }
                        return
                    }
                }
            }
        }
    }
}

では詳細を見ていきます。

まず、getAllCookies(_:)についてです。

func getAllCookies(_ completionHandler: @escaping ([HTTPCookie]) -> Void)

説明するまでも・・・という感じではありますが、一応ドキュメントを見ますと

「すべてのcookieを非同期で取得し、指定されたcompletionHandlerで送り出す」
とのこと。

Fetches all stored cookies asynchronously and delivers them to the specified completion handler.

completionHandlerのブロックでは、cookieArrayというHTTPCookieの配列を受け取ります。

今回は深入りしないのですが、どうやら、HTTPCookieクラスは、以下2種類のCookieをサポートしているそうですね。知らなかった。
そしてAppleの公式ドキュメントにRFCという言葉が出てくるのを初めて見た。(これは私が今までドキュメントを見なすぎたせい)

Version 0: The original cookie format defined by Netscape. Most cookies are in this format.
Version 1: The cookie format defined in RFC 6265, HTTP State Management Mechanism.

今度調べてみたいので、下書きに追加だこりゃ。

少し話がそれましたが今回取得するのは、cookieのnamevalueだけです。
nameauthenticationになっているcookieのみをUserDefaultsに格納します。あとで使います。

次に、delete(_:completionHandler:)です。

func delete(_ cookie: HTTPCookie, completionHandler: (() -> Void)? = nil)

これもシンプルですね。

削除したいcookieを第一引数に渡します。
削除が成功すると、completionHandlerのブロックが実行されます。ここはデフォルト引数がnilで設定されているので、なくても大丈夫です。Hacking with Swiftの例ではなかったです。

削除が成功すれば「削除成功」の文字列がコンソールに表示されます。
しつこいですが、その後再度getAllCookies(_:)を使用してcookieを取得しようとしています。ですが削除されているので、空の配列となります。


次にアプリを起動して最初に表示される画面のViewです。

ContentView.swift
import SwiftUI

struct ContentView: View {
    @ObservedObject private var viewModel = ContentViewModel()
    private let url = URL(string: "http://localhost:3000")!
    @State private var onLogin = false

    var body: some View {
        VStack {
            Button(action: {
                onLogin.toggle()
            }) {
                Text("ログイン")
            }
            Divider()
                .foregroundColor(.black)
            Button(action: {
                viewModel.getUserInfo()
            }) {
                Text("情報取得")
            }
            Text("取得結果:\n\(viewModel.result)")
        }
        .sheet(isPresented: $onLogin) {
            WebView(url: url)
        }
    }
}

主な要素は以下です。

  • ログインのボタン:押下すると、モーダルを開いてWebページ(http://localhost:3000/)を表示する
  • 情報取得用のボタン:http://localhost:3000/userをリクエストしてデータ取得
  • 取得結果のテキスト:情報取得用のボタンを押下して取得してレスポンスを、表示する

上記のコードを見るとわかると思いますが、情報取得のためのAPIリクエストは、ViewModelクラスでやっています。


というわけで次はViewModelを見ます。
最近MVVMの議論が盛り上がっていますが、そこには触れません・・・

呼び方が適切でなかったらすみません。本題はそこではないです。:pray:

ContentViewModel.swift
import Foundation

final class ContentViewModel: ObservableObject {
    @Published var result = ""

    func getUserInfo() {
        // 本来ならここで取得できなければreturnすべきだと思うが、今回はサーバーにエラーを返させたかったので、returnしていない
        let cookie = UserDefaults.standard.string(forKey: "authentication")

        let url = URL(string: "http://localhost:3000/user")!
        var request = URLRequest(url: url)
        request.setValue(cookie, forHTTPHeaderField: "Cookie")

        let session = URLSession.shared
        let task = session.dataTask(with: request) { [weak self] (data, response, error) in
            guard let self = self,
                  let data = data
            else {
                return
            }
            do {
                let object = try JSONSerialization.jsonObject(with: data) as! Dictionary<String, Any>
                DispatchQueue.main.async {
                    if let userName = object["username"] as? String {
                        self.result = userName
                        return
                    }
                    if let error = object["error"] as? String {
                        self.result = error
                        return
                    }
                }
            } catch {
                print("fail JSONSerialization")
            }
        }
        task.resume()
    }
}

ここは本題ではないので、結構適当な実装になっています。
やっていることは、以下の通り。

  1. UserDefaultsから、Webページを開いた時に保存しておいたcookieを取得
  2. そのcookieを使って、http://localhost:3000/userというAPIをリクエスト
  3. レスポンスをresultに代入(Viewはその変更を監視しているので、値が変わり次第、即時反映する)

2つ目のcookieをリクエストヘッダに設定するときに使っているのは、
setValue(_:forHTTPHeaderField:)メソッドです。

このメソッドでできることは、以下の定義の通り、リクエストヘッダに値を設定することです。
今回はCookieヘッダに値を設定します。

mutating func setValue(_ value: String?, forHTTPHeaderField field: String)

このメソッドについては、気になることがあったのですが、
WebViewやSwiftUIとは違う話題なので、setValueメソッドの方に書きます。

一旦iOSの実装としては以上です!

コード全体は以下に上がっています。

その他調べたこと

WebViewやSwiftUI自体とは関係ないですが、調べたこと、やったことを書いておきます。

setValueメソッド

ではこのメソッドについて、気になったことを少し深堀します。

気になったのは、公式ドキュメントのDiscussionの記載でした。

「特定のヘッダフィールドは予約されている。そのようなヘッダを設定するためにこのメソッドは使用してはいけない。特にContent-Lengthヘッダを設定する必要はない。詳細はReserved HTTP Headersを確認するように。」と。

Certain header fields are reserved. Do not use this method to set such headers. Specifically, there is no need for you to set the Content-Length header. See Reserved HTTP Headers.

ではどのようなリクエストヘッダが予約済みなのかというと、現時点では以下の通り。

  • Content-Length
  • Authorization
  • Connection
  • Host
  • Proxy-Authenticate
  • Proxy-Authorization
  • WWW-Authenticate

もしも、上記のヘッダに開発者側が自分で値を設定した場合、
URL Loading Systemはその値を無視する、または上書きする、または送信自体おこなわない
可能性があるのだそうです。

とまあとにかく、不安定な挙動になるので、直接このヘッダを指定しないでください
ということでした。

Content-Lengthに関しては、リクエストボディに基づいて勝手に設定してくれるとか。

ではここで疑問。
Authorizationヘッダは自分で設定したいというケースはないのか?
あるのだとしても、ドキュメントの記述を見る限りAuthorizationヘッダに自分で設定するのは推奨されていなそうなので、設計自体を変更すべきなのか? :thinking:

パッと調べた感じだと、自分で設定したいというケースはありそうでした。

このAppleの開発者フォーラムで同じことを質問してくださっている方がおりまして、Accepted Answerとしては、
「サーバー側の設計を変更することができるのであれば、Authorization以外のカスタムヘッダから認証情報を取得するように変更するそれが不可能な場合は、Authorizationヘッダを手動で設定する以上に良い方法はない。URLProtocolクラスのサブクラスを作って対応する方法もあるが、それをするくらいならAuthorizationヘッダに手動設定する方が良い」
とのことでした。

なるほど!個人的には、大変勉強になりました。

クライアントがモバイルアプリの場合に限らない話なのかもしれないけど、
APIの設計に関わる際には念頭においておくべきことですよね。きっと。

せっかくなので、実際どんなリクエストヘッダがやってきているのか、サーバー側で出力してみました。

{
  host: 'localhost:3000',
  accept: '*/*',
  'if-none-match': 'W/"15-mJshblAb26/FvThKtmGa6MZiGbU"',
  cookie: 'authentication_info',
  'user-agent': 'ReadingAndDeletingCookies/1 CFNetwork/1312 Darwin/20.5.0',
  'accept-language': 'en-US,en;q=0.9',
  'accept-encoding': 'gzip, deflate',
  connection: 'keep-alive'
}

''で囲われているキーと囲われていないキーがあるのが気になるが、そこは一旦無視。

cookie以外は、iOSのコードでは指定していませんので、
確かにURL Loading Systemが勝手に設定してくれているヘッダがいくつかあることがわかりました。

今回久しぶりに、MDN Web Docsを見た。いつの間にUI変わっててびっくりです。

APIの用意

実現したいことでI/Fについては説明したのですが、実装はこんな感じになっています。
説明するより、見た方が早いかもしれません。笑

index.js
const express = require("express");
const app = express();

// cookieを付与して返すAPI
app.get('/', (req, res) => {
  res.cookie('authentication', 'authentication_info', {
    maxAge: 60000,
    httpOnly: false
  });
  res.sendFile(__dirname + '/index.html')
});

// cookie認証付きAPI
app.get('/user', (req, res) => {
  if (req.headers.cookie === 'authentication_info') {
    res.json({ username: 'kamimi' });
    return
  };

  res.status(403);
  res.send({ error: 'error'});
});

// ポート3000番でlistenする
app.listen(3000);

本当に全然関係ないのですが、APIの検証自体は
最近コミュニティの方に教えていただいたHTTPIE FOR TERMINALを使ってやってみてました。

POSTMAN、Thunder Client、cURLが私の中での巨頭だったのですが、新たなツールが仲間入りしました。
ちなみにHTTPie自体は2012年頃から存在しているそうなので、私が長らく知らなかっただけではあります。:sweat:

参考

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2