Skip to content

Commit 4ae4688

Browse files
authored
Add ability to decode custom errors (#649)
* Start of decoding custom errors * Cleanup. Added AWSErrorShape protocol * Pass extended error to AWSErrorContext * Remove any namespace from error codes
1 parent a572bc6 commit 4ae4688

File tree

4 files changed

+87
-14
lines changed

4 files changed

+87
-14
lines changed

Sources/SotoCore/Doc/AWSShape.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ extension AWSEncodableShape {
193193
/// AWSShape that can be decoded from API output
194194
public protocol AWSDecodableShape: AWSShape & Decodable {}
195195

196+
/// AWSShape that can be decoded as an error
197+
public protocol AWSErrorShape: AWSShape & Decodable {}
198+
196199
/// AWSShape options.
197200
public struct AWSShapeOptions: OptionSet, Sendable {
198201
public var rawValue: Int

Sources/SotoCore/Errors/Error.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,31 +28,39 @@ public protocol AWSErrorType: Error, CustomStringConvertible {
2828

2929
extension AWSErrorType {
3030
public var localizedDescription: String {
31-
description
31+
"\(self)"
3232
}
3333

3434
public var message: String? {
3535
context?.message
3636
}
3737
}
3838

39+
/// Service that has extended error information
40+
public protocol AWSServiceErrorType: AWSErrorType {
41+
static var errorCodeMap: [String: AWSErrorShape.Type] { get }
42+
}
43+
3944
/// Additional information about error
4045
public struct AWSErrorContext {
4146
public let message: String
4247
public let responseCode: HTTPResponseStatus
4348
public let headers: HTTPHeaders
4449
public let additionalFields: [String: String]
50+
public let extendedError: AWSErrorShape?
4551

4652
internal init(
4753
message: String,
4854
responseCode: HTTPResponseStatus,
4955
headers: HTTPHeaders = [:],
50-
additionalFields: [String: String] = [:]
56+
additionalFields: [String: String] = [:],
57+
extendedError: AWSErrorShape? = nil
5158
) {
5259
self.message = message
5360
self.responseCode = responseCode
5461
self.headers = headers
5562
self.additionalFields = additionalFields
63+
self.extendedError = extendedError
5664
}
5765
}
5866

Sources/SotoCore/HTTP/AWSHTTPResponse.swift

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -104,13 +104,17 @@ public struct AWSHTTPResponse: Sendable {
104104
case .byteBuffer(let buffer):
105105
switch serviceConfig.serviceProtocol {
106106
case .restjson:
107-
apiError = try? JSONDecoder().decode(RESTJSONError.self, from: buffer)
107+
let jsonDecoder = JSONDecoder()
108+
jsonDecoder.userInfo[.awsErrorMap] = serviceConfig.errorType
109+
apiError = try? jsonDecoder.decode(RESTJSONError.self, from: buffer)
108110
if apiError?.code == nil {
109111
apiError?.code = self.headers["x-amzn-errortype"].first
110112
}
111113

112114
case .json:
113-
apiError = try? JSONDecoder().decode(JSONError.self, from: buffer)
115+
let jsonDecoder = JSONDecoder()
116+
jsonDecoder.userInfo[.awsErrorMap] = serviceConfig.errorType
117+
apiError = try? jsonDecoder.decode(JSONError.self, from: buffer)
114118

115119
case .query:
116120
let xmlDocument = try? XML.Document(buffer: buffer)
@@ -119,15 +123,19 @@ public struct AWSHTTPResponse: Sendable {
119123
element = errors
120124
}
121125
guard let errorElement = element.elements(forName: "Error").first else { break }
122-
apiError = try? XMLDecoder().decode(XMLQueryError.self, from: errorElement)
126+
var xmlDecoder = XMLDecoder()
127+
xmlDecoder.userInfo[.awsErrorMap] = serviceConfig.errorType
128+
apiError = try? xmlDecoder.decode(XMLQueryError.self, from: errorElement)
123129

124130
case .restxml:
125131
let xmlDocument = try? XML.Document(buffer: buffer)
126132
guard var element = xmlDocument?.rootElement() else { break }
127133
if let error = element.elements(forName: "Error").first {
128134
element = error
129135
}
130-
apiError = try? XMLDecoder().decode(XMLQueryError.self, from: element)
136+
var xmlDecoder = XMLDecoder()
137+
xmlDecoder.userInfo[.awsErrorMap] = serviceConfig.errorType
138+
apiError = try? xmlDecoder.decode(XMLQueryError.self, from: element)
131139

132140
case .ec2:
133141
let xmlDocument = try? XML.Document(buffer: buffer)
@@ -136,7 +144,9 @@ public struct AWSHTTPResponse: Sendable {
136144
element = errors
137145
}
138146
guard let errorElement = element.elements(forName: "Error").first else { break }
139-
apiError = try? XMLDecoder().decode(XMLQueryError.self, from: errorElement)
147+
var xmlDecoder = XMLDecoder()
148+
xmlDecoder.userInfo[.awsErrorMap] = serviceConfig.errorType
149+
apiError = try? xmlDecoder.decode(XMLQueryError.self, from: errorElement)
140150
}
141151
}
142152
if let errorMessage = apiError, var code = errorMessage.code {
@@ -158,7 +168,8 @@ public struct AWSHTTPResponse: Sendable {
158168
message: errorMessage.message,
159169
responseCode: self.status,
160170
headers: self.headers,
161-
additionalFields: errorMessage.additionalFields
171+
additionalFields: errorMessage.additionalFields,
172+
extendedError: errorMessage.extendedError
162173
)
163174

164175
if let errorType = serviceConfig.errorType {
@@ -181,17 +192,30 @@ public struct AWSHTTPResponse: Sendable {
181192
}
182193

183194
/// Error used by XML output
184-
private struct XMLQueryError: Codable, APIError {
195+
private struct XMLQueryError: Decodable, APIError {
185196
var code: String?
186197
let message: String
187198
let additionalFields: [String: String]
199+
let extendedError: AWSErrorShape?
188200

189201
init(from decoder: Decoder) throws {
190202
// use `ErrorCodingKey` so we get extract additional keys from `container.allKeys`
191203
let container = try decoder.container(keyedBy: ErrorCodingKey.self)
192-
self.code = try container.decodeIfPresent(String.self, forKey: .init("Code"))
204+
let code = try container.decodeIfPresent(String.self, forKey: .init("Code"))
205+
// Remove namespace from error code
206+
self.code = code?.split(separator: "#").last.map { String($0) }
193207
self.message = try container.decode(String.self, forKey: .init("Message"))
194208

209+
if let code = self.code,
210+
let errorMapping = decoder.userInfo[.awsErrorMap] as? AWSServiceErrorType.Type,
211+
let errorType = errorMapping.errorCodeMap[code]
212+
{
213+
let container = try decoder.singleValueContainer()
214+
self.extendedError = try? container.decode(errorType)
215+
} else {
216+
self.extendedError = nil
217+
}
218+
195219
var additionalFields: [String: String] = [:]
196220
for key in container.allKeys {
197221
guard key.stringValue != "Code", key.stringValue != "Message" else { continue }
@@ -203,19 +227,31 @@ public struct AWSHTTPResponse: Sendable {
203227
}
204228
}
205229

206-
/// Error used by JSON output
207230
private struct JSONError: Decodable, APIError {
208231
var code: String?
209232
let message: String
210233
let additionalFields: [String: String]
234+
let extendedError: AWSErrorShape?
211235

212236
init(from decoder: Decoder) throws {
213237
// use `ErrorCodingKey` so we get extract additional keys from `container.allKeys`
214238
let container = try decoder.container(keyedBy: ErrorCodingKey.self)
215-
self.code = try container.decodeIfPresent(String.self, forKey: .init("__type"))
239+
let code = try container.decodeIfPresent(String.self, forKey: .init("__type"))
240+
// Remove namespace from error code
241+
self.code = code?.split(separator: "#").last.map { String($0) }
216242
self.message =
217243
try container.decodeIfPresent(String.self, forKey: .init("message")) ?? container.decode(String.self, forKey: .init("Message"))
218244

245+
let errorMapping = decoder.userInfo[.awsErrorMap]
246+
if let code = self.code,
247+
let errorMapping = errorMapping as? AWSServiceErrorType.Type,
248+
let errorType = errorMapping.errorCodeMap[code]
249+
{
250+
let container = try decoder.singleValueContainer()
251+
self.extendedError = try? container.decode(errorType)
252+
} else {
253+
self.extendedError = nil
254+
}
219255
var additionalFields: [String: String] = [:]
220256
for key in container.allKeys {
221257
guard key.stringValue != "__type", key.stringValue != "message", key.stringValue != "Message" else { continue }
@@ -232,14 +268,27 @@ public struct AWSHTTPResponse: Sendable {
232268
var code: String?
233269
let message: String
234270
let additionalFields: [String: String]
271+
let extendedError: AWSErrorShape?
235272

236273
init(from decoder: Decoder) throws {
237274
// use `ErrorCodingKey` so we get extract additional keys from `container.allKeys`
238275
let container = try decoder.container(keyedBy: ErrorCodingKey.self)
239-
self.code = try container.decodeIfPresent(String.self, forKey: .init("code"))
276+
let code = try container.decodeIfPresent(String.self, forKey: .init("code"))
277+
// Remove namespace from error code
278+
self.code = code?.split(separator: "#").last.map { String($0) }
240279
self.message =
241280
try container.decodeIfPresent(String.self, forKey: .init("message")) ?? container.decode(String.self, forKey: .init("Message"))
242281

282+
if let code = self.code,
283+
let errorMapping = decoder.userInfo[.awsErrorMap] as? AWSServiceErrorType.Type,
284+
let errorType = errorMapping.errorCodeMap[code]
285+
{
286+
let container = try decoder.singleValueContainer()
287+
self.extendedError = try? container.decode(errorType)
288+
} else {
289+
self.extendedError = nil
290+
}
291+
243292
var additionalFields: [String: String] = [:]
244293
for key in container.allKeys {
245294
guard key.stringValue != "code", key.stringValue != "message", key.stringValue != "Message" else { continue }
@@ -276,6 +325,7 @@ private protocol APIError {
276325
var code: String? { get set }
277326
var message: String { get }
278327
var additionalFields: [String: String] { get }
328+
var extendedError: AWSErrorShape? { get }
279329
}
280330

281331
extension XML.Document {
@@ -284,3 +334,7 @@ extension XML.Document {
284334
try self.init(string: xmlString)
285335
}
286336
}
337+
338+
extension CodingUserInfoKey {
339+
public static var awsErrorMap: Self { .init(rawValue: "soto.awsErrorMap")! }
340+
}

Tests/SotoCoreTests/AWSResponseTests.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,8 @@ class AWSResponseTests: XCTestCase {
356356
XCTAssertEqual(error?.message, "Don't like it")
357357
XCTAssertEqual(error?.context?.responseCode, .notFound)
358358
XCTAssertEqual(error?.context?.additionalFields["fault"], "client")
359+
let contextError = try XCTUnwrap(error?.context?.extendedError as? ServiceErrorType.MessageRejected)
360+
XCTAssertEqual(contextError.fault, "client")
359361
}
360362

361363
func testEC2Error() async throws {
@@ -672,7 +674,11 @@ class AWSResponseTests: XCTestCase {
672674

673675
// MARK: Types used in tests
674676

675-
struct ServiceErrorType: AWSErrorType, Equatable {
677+
struct ServiceErrorType: AWSServiceErrorType, Equatable {
678+
struct MessageRejected: AWSErrorShape {
679+
let message: String?
680+
let fault: String?
681+
}
676682
enum Code: String {
677683
case resourceNotFoundException = "ResourceNotFoundException"
678684
case noSuchKey = "NoSuchKey"
@@ -682,6 +688,8 @@ class AWSResponseTests: XCTestCase {
682688
let error: Code
683689
let context: AWSErrorContext?
684690

691+
static let errorCodeMap: [String: AWSErrorShape.Type] = ["MessageRejected": MessageRejected.self]
692+
685693
init?(errorCode: String, context: AWSErrorContext) {
686694
guard let error = Code(rawValue: errorCode) else { return nil }
687695
self.error = error

0 commit comments

Comments
 (0)