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)
}