Server-Side Swift VaporでDate FormatterをカスタマイズしてJSON.stringify()に対応する

Last Updated on 2023年8月16日 by lemonade

はじめに

VaporではJavaScript標準のDate型のtoJSON()形式に対応していません。これによりJavaScriptやnativeアプリのSwiftからリクエストを送った際にbad requestが返されることになります。

JavaScriptではJSON.stringifyという関数を使用してオブジェクトをJSONに変換してWeb APIを叩くことが多いです。このときのDate型の変換なのですが、以下のようになります。

const object = {
    name: 'John',
    age: 30,
    birthday: new Date('1990-01-01'),
}
const json = JSON.stringify(object)
console.log(json)
// {"name":"John","age":30,"birthday":"1990-01-01T00:00:00.000Z"}

しかしJavaScriptではこれに対してさらにミリ秒単位が付きます。

Date型はISO 8601で定められている形式に直すのが標準的になっています。つまりyyyyMMddThhmmss+0900またはYYYYMMDDThhmmss+0900が正しい形式と言えると思います。+0900は日本が標準時より+9時間であるためです。JavaScriptで変換された場合のZは標準時であることを示しています。

VaporのContentで対応しているDate型を調査する

ではVaporではどのようなDateの形式が対応しているのかを調べたいと思います。以下のようなエンドポイントを用意します。

import Vapor

func routes(_ app: Application) throws {
  
  struct DateContent: Content {
    var date: Date
  }
  
  app.post { req async throws -> String in
    let content = try req.content.decode(DateContent.self)
    
    var formatter = DateFormatter()
    formatter.locale = .init(identifier: "ja_JP")
    formatter.dateFormat = "yyyy年MM月dd日hh時mm分ss秒SSSミリ秒"
  
    return formatter.string(from: content.date)
  }
  
}

POSTリクエストを受け取ったら内容のJSONから日付をdecodeしてそれを日本語形式の文字列に直して返す感じにしています。

このエンドポイントを用いてテストしてみると、以下の結果になりました

@testable import App
import XCTVapor

final class AppTests: XCTestCase {
  
  /// 失敗
  func test標準時のISO8601基本形式() async throws {
    let app = Application(.testing)
    defer { app.shutdown() }
    try await configure(app)
    
    try app.test(.POST, "") { req in
      req.body = .init(string: "{\"date\":\"20060102T150405Z\"}")
      req.headers.contentType = .json
    } afterResponse: { res in
      XCTAssertEqual(res.status, .ok)
      XCTAssertEqual(res.body.string, "2006年01月03日12時04分05秒000ミリ秒")
    }
  }
  
  /// 失敗
  func test日本時間のISO8601基本形式() async throws {
    let app = Application(.testing)
    defer { app.shutdown() }
    try await configure(app)
    
    try app.test(.POST, "") { req in
      req.body = .init(string: "{\"date\":\"20060102T150405+0900\"}")
      req.headers.contentType = .json
    } afterResponse: { res in
      XCTAssertEqual(res.status, .ok)
      XCTAssertEqual(res.body.string, "2006年01月03日12時04分05秒000ミリ秒")
    }
  }
  
  /// 成功
  func test標準時のISO8601拡張形式() async throws {
    let app = Application(.testing)
    defer { app.shutdown() }
    try await configure(app)
    
    try app.test(.POST, "") { req in
      req.body = .init(string: "{\"date\":\"2006-01-02T15:04:05Z\"}")
      req.headers.contentType = .json
    } afterResponse: { res in
      XCTAssertEqual(res.status, .ok)
      XCTAssertEqual(res.body.string, "2006年01月03日12時04分05秒000ミリ秒")
    }
  }
  
  /// 成功
  func test日本時間のISO8601拡張形式() async throws {
    let app = Application(.testing)
    defer { app.shutdown() }
    try await configure(app)
    
    try app.test(.POST, "") { req in
      req.body = .init(string: "{\"date\":\"2006-01-02T15:04:05+0900\"}")
      req.headers.contentType = .json
    } afterResponse: { res in
      XCTAssertEqual(res.status, .ok)
      XCTAssertEqual(res.body.string, "2006年01月02日03時04分05秒000ミリ秒")
    }
  }
  
  /// 失敗
  func test標準時のJavaScript形式() async throws {
    let app = Application(.testing)
    defer { app.shutdown() }
    try await configure(app)
    
    try app.test(.POST, "") { req in
      req.body = .init(string: "{\"date\":\"2006-01-02T15:04:05.000Z\"}")
      req.headers.contentType = .json
    } afterResponse: { res in
      XCTAssertEqual(res.status, .ok)
      XCTAssertEqual(res.body.string, "2006年01月03日12時04分05秒000ミリ秒")
    }
  }
}

拡張形式でのISO8601形式以外は失敗してしまっています。拡張形式に対応すればいいのですが、JSON.stringifyを使えないのは面倒です。Date.prototype.toJSONを書き換えるのも可能ですがやりたくありません。

Vapor側でJavaScriptのJSON.stringifyに対応する

ミリ秒付きに対応するためには、JSONDecoderをカスタマイズする必要があります。以下のようにconfigure.swiftを変更します。

import Vapor

// configures your application
public func configure(_ app: Application) async throws {
  // uncomment to serve files from /Public folder
  // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
  
  let decoder = try ContentConfiguration.global.requireDecoder(for: .json) as! JSONDecoder
  decoder.dateDecodingStrategy = .custom { decoder throws -> Date in
    let string = try decoder.singleValueContainer().decode(String.self)
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
    guard let date = formatter.date(from: string) else {
      throw Abort(.badRequest, reason: "Data corrupted at path '\(decoder.codingPath[0].stringValue)'. Expected date string to be \(formatter.dateFormat!)")
    }
    return date
  }

  // register routes
  try routes(app)
}

変更すると先ほどのテストのうちJavaScriptのJSON.stringifyのものだけが通るようになりました。

@testable import App
import XCTVapor

final class AppTests: XCTestCase {
  
  /// 失敗
  func test標準時のISO8601基本形式() async throws {
    let app = Application(.testing)
    defer { app.shutdown() }
    try await configure(app)
    
    try app.test(.POST, "") { req in
      req.body = .init(string: "{\"date\":\"20060102T150405Z\"}")
      req.headers.contentType = .json
    } afterResponse: { res in
      XCTAssertEqual(res.status, .ok)
      XCTAssertEqual(res.body.string, "2006年01月03日12時04分05秒000ミリ秒")
    }
  }
  
  /// 失敗
  func test日本時間のISO8601基本形式() async throws {
    let app = Application(.testing)
    defer { app.shutdown() }
    try await configure(app)
    
    try app.test(.POST, "") { req in
      req.body = .init(string: "{\"date\":\"20060102T150405+0900\"}")
      req.headers.contentType = .json
    } afterResponse: { res in
      XCTAssertEqual(res.status, .ok)
      XCTAssertEqual(res.body.string, "2006年01月03日12時04分05秒000ミリ秒")
    }
  }
  
  /// 失敗
  func test標準時のISO8601拡張形式() async throws {
    let app = Application(.testing)
    defer { app.shutdown() }
    try await configure(app)
    
    try app.test(.POST, "") { req in
      req.body = .init(string: "{\"date\":\"2006-01-02T15:04:05Z\"}")
      req.headers.contentType = .json
    } afterResponse: { res in
      XCTAssertEqual(res.status, .ok)
      XCTAssertEqual(res.body.string, "2006年01月03日12時04分05秒000ミリ秒")
    }
  }
  
  /// 失敗
  func test日本時間のISO8601拡張形式() async throws {
    let app = Application(.testing)
    defer { app.shutdown() }
    try await configure(app)
    
    try app.test(.POST, "") { req in
      req.body = .init(string: "{\"date\":\"2006-01-02T15:04:05+0900\"}")
      req.headers.contentType = .json
    } afterResponse: { res in
      XCTAssertEqual(res.status, .ok)
      XCTAssertEqual(res.body.string, "2006年01月02日03時04分05秒000ミリ秒")
    }
  }
  
  /// 成功
  func test標準時のJavaScript形式() async throws {
    let app = Application(.testing)
    defer { app.shutdown() }
    try await configure(app)
    
    try app.test(.POST, "") { req in
      req.body = .init(string: "{\"date\":\"2006-01-02T15:04:05.000Z\"}")
      req.headers.contentType = .json
    } afterResponse: { res in
      XCTAssertEqual(res.status, .ok)
      XCTAssertEqual(res.body.string, "2006年01月03日12時04分05秒000ミリ秒")
    }
  }
}

また、このようにcontent.decode(Protocol, using: JSONDecoder)を用いることで一部のハンドラにだけ適用させることも可能です。

import Vapor

func routes(_ app: Application) throws {
  
  struct DateContent: Content {
    var date: Date
  }
  
  let decoder = JSONDecoder()
  decoder.dateDecodingStrategy = .custom { decoder throws -> Date in
    let string = try decoder.singleValueContainer().decode(String.self)
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ"
    guard let date = formatter.date(from: string) else {
      throw Abort(.badRequest, reason: "Data corrupted at path '\(decoder.codingPath[0].stringValue)'. Expected date string to be \(formatter.dateFormat!)")
    }
    return date
  }
  
  app.post { req async throws -> String in
    let content = try req.content.decode(DateContent.self, using: decoder)
    
    let formatter = DateFormatter()
    formatter.locale = .init(identifier: "ja_JP")
    formatter.dateFormat = "yyyy年MM月dd日hh時mm分ss秒SSSミリ秒"
  
    return formatter.string(from: content.date)
  }
  
}

最後に

ミリ秒付きとISO 8601拡張形式の両方に対応させたいので以下のようにしました。

import Vapor

// configures your application
public func configure(_ app: Application) async throws {
  // uncomment to serve files from /Public folder
  // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
  
  let jsonDecoder = try ContentConfiguration.global.requireDecoder(for: .json) as! JSONDecoder
  jsonDecoder.dateDecodingStrategy = .custom { decoder throws -> Date in
    let string = try decoder.singleValueContainer().decode(String.self)
    
    // ISO8601 拡張形式
    if let date = ISO8601DateFormatter().date(from: string) {
      return date
    }
    
    // ミリ秒
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
    formatter.calendar = Calendar(identifier: .iso8601)
    formatter.timeZone = TimeZone(secondsFromGMT: 0)
    formatter.locale = Locale(identifier: "en_US_POSIX")
    if let date = formatter.date(from: string) {
      return date
    }
    
    throw Abort(.badRequest, reason: "Data corrupted at path '\(decoder.codingPath[0].stringValue)'. Expected date string to be ISO8601")
  }
  
  // register routes
  try routes(app)
}

Leave a Comment

CAPTCHA