-
Notifications
You must be signed in to change notification settings - Fork 27
/
Copy pathFormatAPIController.swift
294 lines (264 loc) · 11.8 KB
/
FormatAPIController.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
import Foundation
import Vapor
class FormatAPIController: RouteCollection {
// These identifiers match the CLDR calendar identifiers
// https://github.com/unicode-org/icu/blob/main/icu4c/source/i18n/ucal.cpp#L694
private let calendars = [
"gregorian": Calendar.Identifier.gregorian,
"japanese": .japanese,
"buddhist": .buddhist,
"roc": .republicOfChina,
"persian": .persian,
"islamic-civil": .islamicCivil,
"islamic": .islamic,
"hebrew": .hebrew,
"chinese": .chinese,
"indian": .indian,
"coptic": .coptic,
"ethiopic": .ethiopicAmeteMihret,
"ethiopic-amete-alem": .ethiopicAmeteAlem,
"iso8601": .iso8601,
"islamic-umalqura": .islamicUmmAlQura,
"islamic-tbla": .islamicTabular,
]
func boot(routes: RoutesBuilder) throws {
let api = routes.grouped("api")
api.get("info", use: infoJSON)
api.post("format", use: formatJSON)
}
func infoJSON(_ req: Request) async throws -> InfoResponse {
InfoResponse(calendars: calendars.keys.sorted(by: <),
locales: Locale.availableIdentifiers.sorted(by: <),
timeZones: TimeZone.knownTimeZoneIdentifiers.sorted(by: <),
timeZoneDataVersion: TimeZone.timeZoneDataVersion)
}
func formatJSON(_ req: Request) async throws -> [FormatResponse] {
let requests = try req.content.decode([FormatRequest].self)
return try requests.map(processRequest)
}
private func processRequest(_ request: FormatRequest) throws -> FormatResponse {
let locale = try resolveLocale(matching: request.locale)
let calendar = try resolveCalendar(matching: request.calendar, fallback: locale)
let timeZone = try resolveTimeZone(matching: request.timeZone, fallback: locale)
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.timeZone = timeZone
formatter.locale = locale
switch request.format {
case .date(let date):
formatter.dateStyle = date.dateFormatterStyle
formatter.timeStyle = .none
case .time(let time):
formatter.dateStyle = .none
formatter.timeStyle = time.dateFormatterStyle
case .dateAndTime(let date, let time):
formatter.dateStyle = date.dateFormatterStyle
formatter.timeStyle = time.dateFormatterStyle
case .template(let string):
formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: string, options: 0, locale: locale)
case .raw(let string):
formatter.dateFormat = string
}
let date: Date
if let timestamp = request.timestamp {
date = Date(timeIntervalSince1970: timestamp)
} else {
date = Date()
}
let formatted = formatter.string(from: date)
let calendarIDName = calendars.first(where: { $0.value == calendar.identifier })?.key ?? "gregorian"
return FormatResponse(
id: request.id,
calendar: calendarIDName,
timeZone: timeZone.identifier,
locale: locale.identifier,
timestamp: date.timeIntervalSince1970,
format: formatter.dateFormat,
value: formatted
)
}
private func resolveLocale(matching identifier: String?) throws -> Locale {
if let identifier {
let loweredLocaledID = identifier.lowercased()
guard let actualLocaleID = Locale.availableIdentifiers.first(where: { $0.lowercased() == loweredLocaledID }) else {
throw Abort(.badRequest, reason: "Unknown locale: '\(identifier)'")
}
return Locale(identifier: actualLocaleID)
} else {
return Locale(identifier: "en_US_POSIX")
}
}
private func resolveCalendar(matching identifier: String?, fallback: Locale) throws -> Calendar {
if let identifier {
let loweredCalID = identifier.lowercased()
guard let calendarIdentifier = self.calendars.first(where: { $0.key.lowercased() == loweredCalID })?.value else {
throw Abort(.badRequest, reason: "Unknown calendar: '\(identifier)'")
}
return Calendar(identifier: calendarIdentifier)
} else {
return fallback.calendar
}
}
private func resolveTimeZone(matching identifier: String?, fallback: Locale) throws -> TimeZone {
if let identifier {
let loweredTZID = identifier.lowercased()
guard let actualTZID = TimeZone.knownTimeZoneIdentifiers.first(where: { $0.lowercased() == loweredTZID }) else {
throw Abort(.badRequest, reason: "Unknown time zone: '\(identifier)'")
}
return TimeZone(identifier: actualTZID)!
} else {
#if os(Linux)
return TimeZone(secondsFromGMT: 0)!
#else
return fallback.timeZone ?? TimeZone(secondsFromGMT: 0)!
#endif
}
}
}
/// A request to format a timestamp
///
/// Valid JSON requests can look like:
///
/// ```json
/// // format the current date in the default locale, calendar, and time zone
/// {
/// "format": { "date": "full" }
/// }
///
/// // format a specific point in time according to a specific locale,
/// // calendar, and time zone using a template format
/// {
/// "locale": "en_US",
/// "calendar": "japanese",
/// "timeZone": "africa/addis_ababa",
/// "timestamp": 1234567890.987,
/// "format": { "template": "yMMMdHHmmss" }
/// }
///
/// // format a specific point in time using the default locale and calendar,
/// // but in the New York time zone and using an ISO 8601-like format.
/// {
/// "id": "an-id-from-my-app",
/// "timeZone": "America/New_York",
/// "timestamp": 1708705211,
/// "format": { "raw": "y-MM-dd'T'HH:mm:ss.SSSX" }
/// }
/// ```
struct FormatRequest: Content {
/// A client-provided identifier for the request.
///
/// This value is returned as-is in the ``FormatResponse``. The purpose of this identifier is to help clients
/// associate the responses with their requests. For example, this id might correspond to the `id="…"` value
/// of an HTML element, or some other view identifier.
let id: String?
/// The identifier of the locale to use for the request (case insensitive)
///
/// Valid values can be retrieved from the `/info.json` endpoint and are strings like `"en_US"`, `"se_NO"`, etc.
/// If this value is omitted, then the `en_US_POSIX` locale is used.
let locale: String?
/// The identifier of the calendar to use for the request (case insensitive)
///
/// Valid values can be retrieved from the `/info.json` endpoint and are strings like `"gregorian"`, `"japanese"`, etc.
/// If this value is omitted, then the calendar of the `locale` is used. If the locale does not specify a calendar, then `"gregorian"` is used.
let calendar: String?
/// The identifier of the time zone to use for the request (case insensitive)
///
/// These identifiers are ones such as `"America/Los_Angeles"` or `"Europe/Stockholm"`. There is no support for passing
/// a time zone *offset*, since such time zones result in "fixed" time zones with no notion of Daylight Saving Time. By using the time zone
/// identifier, formatting requests account for proper DST shifts or time-zone-specific variations, such as `Pacific/Apia`'s missing 30 Dec 2011.
///
/// If this value is omitted, then the locale's time zone is used, if it has one. If the locale does not have one, then GMT is used.
let timeZone: String?
/// The Unix timestamp to format into a string
///
/// This value (of seconds since the 1970 Unix epoch) is used as the point in time for formatting. It can be omitted, in which case "now" is used.
let timestamp: Double?
/// The format of the "rendered" timestamp
///
/// This is the only required parameter of a `FormatRequest`, since all others may be omitted to assume default values.
/// Valid values for this parameter are things like:
/// - `.date(.full)`: render the localized date portion of the timestamp using complete era, year, month, and day information
/// - `.time(.short)`: render only the time of day portion of the timestamp using a localized abbreviated format.
/// - `.dateAndTime(.medium, .medium)`: render the date and time of day information in the timestamp in a localized format
/// - `.template("jmm")`: render the timestamp using the provided date format *template*. This value is localized by `DateFormatter`
/// using the resolved locale.
/// - `.raw("HH:mm")`: render the timestamp using the provided date format.
let format: Format
enum Style: String, Codable {
case full
case long
case medium
case short
var dateFormatterStyle: DateFormatter.Style {
switch self {
case .full: return .full
case .long: return .long
case .medium: return .medium
case .short: return .short
}
}
}
enum Format: Codable {
enum CodingKeys: CodingKey {
case date
case time
case template
case raw
}
case date(Style)
case time(Style)
case dateAndTime(Style, Style)
case template(String)
case raw(String)
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: FormatRequest.Format.CodingKeys.self)
let allKeys = container.allKeys
if allKeys == [.date] {
self = .date(try container.decode(Style.self, forKey: .date))
} else if allKeys == [.date, .time] || allKeys == [.time, .date] {
self = .dateAndTime(try container.decode(Style.self, forKey: .date),
try container.decode(Style.self, forKey: .time))
} else if allKeys == [.time] {
self = .time(try container.decode(Style.self, forKey: .time))
} else if allKeys == [.template] {
self = .template(try container.decode(String.self, forKey: .template))
} else if allKeys == [.raw] {
self = .raw(try container.decode(String.self, forKey: .raw))
} else {
let keyNames = allKeys.map(\.stringValue)
throw Abort(.badRequest, reason: "Invalid format options: '\(keyNames)'")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .date(let style):
try container.encode(style, forKey: .date)
case .time(let style):
try container.encode(style, forKey: .time)
case .dateAndTime(let d, let t):
try container.encode(d, forKey: .date)
try container.encode(t, forKey: .time)
case .template(let t):
try container.encode(t, forKey: .template)
case .raw(let r):
try container.encode(r, forKey: .raw)
}
}
}
}
struct FormatResponse: Content {
let id: String?
let calendar: String
let timeZone: String
let locale: String
let timestamp: Double
let format: String
let value: String
}
struct InfoResponse: Content {
let calendars: Array<String>
let locales: Array<String>
let timeZones: Array<String>
let timeZoneDataVersion: String
}