SwiftでHTTPリクエスト

今日はふとGoogle Domainsで管理していたドメインを移管してみることにした。 2件分で3,300円かかった。 加えてドメインの更新にSSL証明書の更新も加えて今日だけで2万円もかかってしまった。 なかなかの出費である。

閑話休題。今日はちょうど私がSwiftを学習してから1ヶ月経った日であった。 チュートリアルが現在Swift UIのあたりで止まっているのだけれども、個人的にはまだStoryboardのアプリケーションも作ってみたい気が残っているのでなんとなく箸休めをしていた。 このまま放置していたらいよいよ本格的に忘れてしまう気がしたので、とりあえず簡単なサーバーを作ってそこでHTTP通信をしてみることにした。

iOSの開発自体もライセンスの問題が解決していないので正直気が重いのだけれども、触る機会を増やすには正直これしかないと思っている。

Rails側は特になんの変哲もない感じだ:

class HelloController < ApplicationController
  # GET /messages
  def greet
    render json: { greet: "Happy world!" }
  end
end

レスポンスの文言も特に意味はないが、配列だと少々面倒なので単純な文字列にした。 私の開発環境はXcodeとこのRailsサーバーがそもそも違うので、http://localhost:3000とかではなくわざわざHTTPS環境でデプロイしてアクセスできるようにしている。 SSL証明書が出てきたのもこれが理由である。 通常Xcodeで作成したアプリケーションでHTTP通信をする場合はInfo.plistを変更する必要があったはずだ。

import Foundation

struct Greeter: Decodable {
    var greet: String
}

まずは上記のレスポンスにあわせてGreeterという構造体を用意する。

let url = URL(string: "https://example.com")!
let task = URLSession.shared.dataTask(with: url) { data, response, error in
    if let error = error {
        print("error: \(error)")
        return
    }
    guard let data = data else { return }
    let greeter = try? JSONDecoder().decode(Greeter.self, from: data)
    guard let greeter = greeter else { return }
    print("greet: \(greeter.greet)")
}
task.resume()

おそらく簡単な書き方はこうだ。

これは仕方ないことであるけれども、SwiftはOptionalがよく出てくるのでインデントが深くなりがちなのでこのように直線っぽく書くようにした。 Xcode上のエミュレータで確認するとgreet: Happy world!の出力を確認できる。

Rails側のログを確認してみると:

I, [2024-02-04T08:42:26.903094 #10]  INFO -- : [3848cee9-9ba8-4208-8fc1-424991febf27] Started GET "/" for 128.66.0.0 at 2024-02-04 08:42:26 +0000
I, [2024-02-04T08:42:26.903460 #10]  INFO -- : [3848cee9-9ba8-4208-8fc1-424991febf27] Processing by HelloController#greet as */*
I, [2024-02-04T08:42:26.903682 #10]  INFO -- : [3848cee9-9ba8-4208-8fc1-424991febf27] Completed 200 OK in 0ms (Views: 0.1ms | ActiveRecord: 0.0ms | Allocations: 128)

本当に単純なHTTPリクエストだと思うので

もしUILabelなどのテキストを更新したい場合はこうする:

@IBAction func greetPressed(_ sender: UIButton) {
    DispatchQueue.main.async {
        self.greetLabel.text = greeter.greet
    }
}

次はJSON形式のPOSTについて。 サーバー側はまず簡単なモデルを用意した。

class CreateMessages < ActiveRecord::Migration[7.1]
  def change
    create_table :messages do |t|
      t.string :text

      t.timestamps
    end
  end
end
class MessagesController < ApplicationController
  # POST /messages
  def create
    @message = Message.new(message_params)

    if @message.save
      render json: @message, status: :created
    else
      render json: @message.errors, status: :unprocessable_entity
    end
  end

  private

  # Only allow a list of trusted parameters through.
  def message_params
    params.require(:message).permit(:text)
  end
end

このあたりはscaffoldから流用しているのですぐに終わる。 POSTもSwiftのスニペットを貼っておしまいと思ったが、少し厄介である。

というのも、Railsとの通信では主に2つ気をつけておきたいことがある:

  • Rubyはsnake_caseでSwiftはcamelCaseであること
  • RailsはStrong Parametersで通信すること

レスポンスはサーバー側をクライアントにあわせてcamelで返すべきか、あるいはクライアント側でcamelに変換すべきかは人によって意見はわかれるかもしれないが、私はクライアント側で対応するほうが好ましいと思っている。

NodeJSであればNPMでパッケージをインストールすれば済むのだけれども、Swiftの場合はJSONDecoderというクラスにあらかじめ用意されているのでそれを使えば問題ない。

import Foundation

struct MessageParams: Codable {
    let text: String
}

struct MessageContainer: Codable {
    let messageParams: MessageParams
}

struct Message: Codable {
    let id: Int64
    let text: String
    let createdAt: String
    let updatedAt: String
}

まずはそれぞれのリクエストとレスポンスで使うための構造体を用意しておく。 今回はDecodableではなくCodableを使っているが、これは特に深い意味はない。 厳密に言えばDecodeとEncodeでそれぞれに分けるべきかもしれないが、今回は単純にしている。

MessageContainerクラスを使うことでJSONのStrong Parametersを表現しているのだが、わざわざ2つ構造体を用意しなければいけないのは正直あまりいけていない。 このあたりはもう少しうまくやる方法があるかもしれない。

let messageParams = MessageParams(text: text)
let messageContainer = MessageContainer(messageParams: messageParams)
guard let uploadData = try? JSONEncoder().encode(messageContainer) else { return }
let url = URL(string: "https://example.com/messages")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let task = URLSession.shared.uploadTask(with: request, from: uploadData) { data, response, error in
    if let error = error {
        print("error: \(error)")
        return
    }
    guard let data = data else { return }
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase // rails
    let message = try? decoder.decode(Message.self, from: data)
    guard let message = message else { return }
    print("ID: \(message.id)")
}
task.resume()

非常に長ったらしいコードになってしまったが、どのコードにも意味があると考えれば仕方がない。 HTTP POSTする場合はhttpMethodを設定する必要があったり、JSONDecoderにkeyDecodingStrategyを設定するなどいろいろと静的型付き言語の回りくどいところは出ているけれども、特にライブラリなどを使わずともサーバーにデータを届けることができた。

エラー処理も現状はprintするだけなのでこのあたりも見直していきたいが、今回はこれくらいに留めておく。

I, [2024-02-04T08:42:35.565112 #10]  INFO -- : [968276ea-6fb1-460d-a8d5-7407a7a0c4ff] Started POST "/messages" for 128.66.0.0 at 2024-02-04 08:42:35 +0000
I, [2024-02-04T08:42:35.565483 #10]  INFO -- : [968276ea-6fb1-460d-a8d5-7407a7a0c4ff] Processing by MessagesController#create as */*
I, [2024-02-04T08:42:35.565503 #10]  INFO -- : [968276ea-6fb1-460d-a8d5-7407a7a0c4ff]   Parameters: {"message"=>{"text"=>"Hello"}}
I, [2024-02-04T08:42:35.566428 #10]  INFO -- : [968276ea-6fb1-460d-a8d5-7407a7a0c4ff] Completed 201 Created in 1ms (Views: 0.1ms | ActiveRecord: 0.2ms | Allocations: 391)

ログも想定した通り。 非常によい。

おまけでPlaygroundではなくシミュレーター上で動かしてみたスクリーンショットを添付してみる:

スクリーンショット

——レイアウトは今後の課題である。