Last active
January 19, 2025 11:48
-
-
Save thomsmed/bc86a5729812f4e71f72f7d5ba962f48 to your computer and use it in GitHub Desktop.
A simple Swift implementation of [RFC 7807 - Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc7807).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// HTTPAPIProblem.swift | |
// | |
import Foundation | |
// MARK: HTTP API Problem | |
/// Error thrown when decoding a`HTTPAPIProblem` if the decoded HTTP API Problem's type does not match the type of the associated `Extras`. | |
public struct HTTPAPIProblemTypeMismatch: Error {} | |
/// Error thrown when decoding a`HTTPAPIProblem` if the decoded HTTP API Problem's type is empty/not present. A `HTTPAPIProblem` must always have a type. | |
public struct HTTPAPIProblemTypeMissing: Error {} | |
public protocol HTTPAPIProblemExtras: Decodable, Sendable { | |
static var associatedProblemType: String? { get } | |
} | |
/// [RFC 7807 - Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc7807). | |
public struct HTTPAPIProblem<Extras: HTTPAPIProblemExtras>: Error { | |
public let type: String | |
public let title: String | |
public let status: Int | |
public let detail: String | |
public let instance: String | |
public let extras: Extras | |
enum CodingKeys: String, CodingKey { | |
case type | |
case title | |
case status | |
case detail | |
case instance | |
} | |
public init(type: String, title: String, status: Int, detail: String, instance: String, extras: Extras) throws { | |
if let associatedProblemType = Extras.associatedProblemType { | |
if associatedProblemType.isEmpty { | |
throw HTTPAPIProblemTypeMissing() | |
} | |
if type != associatedProblemType { | |
throw HTTPAPIProblemTypeMismatch() | |
} | |
} | |
self.type = type | |
self.title = title | |
self.status = status | |
self.detail = detail | |
self.instance = instance | |
self.extras = extras | |
} | |
public init(title: String, status: Int, detail: String, instance: String, extras: Extras) throws { | |
guard let type = Extras.associatedProblemType, !type.isEmpty else { | |
throw HTTPAPIProblemTypeMissing() | |
} | |
self.type = type | |
self.title = title | |
self.status = status | |
self.detail = detail | |
self.instance = instance | |
self.extras = extras | |
} | |
} | |
extension HTTPAPIProblem: Decodable where Extras: Decodable { | |
public init(from decoder: any Decoder) throws { | |
let keyedContainer = try decoder.container(keyedBy: CodingKeys.self) | |
let type = try keyedContainer.decode(String.self, forKey: .type) | |
if type.isEmpty { | |
throw HTTPAPIProblemTypeMissing() | |
} | |
if let associatedProblemType = Extras.associatedProblemType { | |
guard type == associatedProblemType else { | |
throw HTTPAPIProblemTypeMismatch() | |
} | |
} | |
self.type = type | |
self.title = try keyedContainer.decode(String.self, forKey: .title) | |
self.status = try keyedContainer.decode(Int.self, forKey: .status) | |
self.detail = try keyedContainer.decode(String.self, forKey: .detail) | |
self.instance = try keyedContainer.decode(String.self, forKey: .instance) | |
self.extras = try Extras(from: decoder) | |
} | |
} | |
// MARK: Opaque/"untyped" HTTP API Problem | |
public struct OpaqueProblemExtras: HTTPAPIProblemExtras { | |
public static let associatedProblemType: String? = nil | |
} | |
public typealias OpaqueHTTPAPIProblem = HTTPAPIProblem<OpaqueProblemExtras> | |
extension OpaqueHTTPAPIProblem { | |
public init(type: String, title: String, status: Int, detail: String, instance: String) throws { | |
if type.isEmpty { | |
throw HTTPAPIProblemTypeMissing() | |
} | |
self.type = type | |
self.title = title | |
self.status = status | |
self.detail = detail | |
self.instance = instance | |
self.extras = OpaqueProblemExtras() | |
} | |
public init(_ problem: HTTPAPIProblem<some HTTPAPIProblemExtras>) { | |
self.type = problem.type | |
self.title = problem.title | |
self.status = problem.status | |
self.detail = problem.detail | |
self.instance = problem.instance | |
self.extras = OpaqueProblemExtras() | |
} | |
} | |
// MARK: Concrete HTTP API Problems (defined by Type and Extras) | |
public struct SomeExtras: HTTPAPIProblemExtras { | |
public static let associatedProblemType: String? = "urn:some:problem:type" | |
public let description: String | |
} | |
public typealias SomeHTTPAPIProblem = HTTPAPIProblem<SomeExtras> | |
public struct SomeOtherExtras: HTTPAPIProblemExtras { | |
public static let associatedProblemType: String? = "https://my.domain.com/some-other-problem" | |
public let message: String | |
} | |
public typealias SomeOtherHTTPAPIProblem = HTTPAPIProblem<SomeOtherExtras> | |
public struct UserActionRequiredExtras: HTTPAPIProblemExtras { | |
public static let associatedProblemType: String? = "https://my.domain.com/action/required" | |
public let description: String | |
public let action: URL | |
} | |
public typealias UserActionRequiredHTTPAPIProblem = HTTPAPIProblem<UserActionRequiredExtras> | |
// MARK: Group/Collection of related HTTP API Problems (represented as an enum) | |
public enum CoreHTTPAPIProblem: Decodable, Error { | |
case someProblem(SomeHTTPAPIProblem) | |
case someOtherProblem(SomeOtherHTTPAPIProblem) | |
case userActionRequired(UserActionRequiredHTTPAPIProblem) | |
case opaqueProblem(OpaqueHTTPAPIProblem) | |
public init(from decoder: any Decoder) throws { | |
if let someProblem = try? SomeHTTPAPIProblem(from: decoder) { | |
self = .someProblem(someProblem) | |
} else if let someOtherProblem = try? SomeOtherHTTPAPIProblem(from: decoder) { | |
self = .someOtherProblem(someOtherProblem) | |
} else if let userActionRequired = try? UserActionRequiredHTTPAPIProblem(from: decoder) { | |
self = .userActionRequired(userActionRequired) | |
} else { | |
// Fall back to an opaque/"untyped" HTTP API Problem | |
self = .opaqueProblem(try OpaqueHTTPAPIProblem(from: decoder)) | |
} | |
} | |
} | |
// MARK: Usage | |
import SwiftUI | |
struct HTTPClient { | |
struct Response: Sendable { | |
let statusCode: Int | |
let body: Data | |
init(statusCode: Int, body: Data) { | |
self.statusCode = statusCode | |
self.body = body | |
} | |
func decode<Value: Decodable>(as _: Value.Type = Value.self) throws -> Value { | |
let jsonDecoder = JSONDecoder() | |
return try jsonDecoder.decode(Value.self, from: body) | |
} | |
} | |
struct UnexpectedResponse: Error { | |
let statusCode: Int | |
let body: Data | |
init(statusCode: Int, body: Data) { | |
self.statusCode = statusCode | |
self.body = body | |
} | |
func decode<Value: Decodable>(as _: Value.Type = Value.self) throws -> Value { | |
let jsonDecoder = JSONDecoder() | |
return try jsonDecoder.decode(Value.self, from: body) | |
} | |
} | |
private func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { | |
let (data, httpResponse) = try await URLSession.shared.data(for: request) | |
guard let httpURLResponse = httpResponse as? HTTPURLResponse else { | |
fatalError("Why did this happen?") | |
} | |
return (data, httpURLResponse) | |
} | |
func response(for request: URLRequest) async throws -> Response { | |
let (data, httpResponse) = try await data(for: request) | |
if (200..<300).contains(httpResponse.statusCode) { | |
return Response(statusCode: httpResponse.statusCode, body: data) | |
} else { | |
throw UnexpectedResponse(statusCode: httpResponse.statusCode, body: data) | |
} | |
} | |
} | |
struct AlertDetails { | |
struct Action { | |
let title: String | |
let handler: () -> Void | |
} | |
let message: String | |
let actions: [Action] | |
} | |
// MARK: ContentView | |
struct ContentView: View { | |
@Environment(\.openURL) private var openURL | |
@State private var alertDetails: AlertDetails? | |
var body: some View { | |
VStack { | |
Text("Hello HTTP API Problem!") | |
} | |
.alert( | |
"Ups!", | |
isPresented: Binding( | |
get: { alertDetails != nil }, | |
set: { _ in alertDetails = nil } | |
), | |
presenting: alertDetails, | |
actions: { alertDetails in | |
ForEach(alertDetails.actions.indices, id: \.self) { index in | |
Button( | |
alertDetails.actions[index].title, | |
action: alertDetails.actions[index].handler | |
) | |
} | |
Button("Close", role: .cancel) {} | |
}, | |
message: { alertDetails in | |
Text(alertDetails.message) | |
} | |
) | |
.task { | |
do { | |
let httpClient = HTTPClient() | |
let request = URLRequest(url: URL(string: "www.example.ios")!) | |
let _ = try await httpClient.response(for: request) | |
} catch let unexpectedResponse as HTTPClient.UnexpectedResponse { | |
if let coreProblem = try? unexpectedResponse.decode(as: CoreHTTPAPIProblem.self) { | |
handle(coreProblem) | |
} else { | |
handleUnexpectedSituation() | |
} | |
} catch { | |
alertDetails = AlertDetails( | |
message: "Network issue", | |
actions: [] | |
) | |
} | |
} | |
} | |
private func handle(_ coreProblem: CoreHTTPAPIProblem) { | |
switch coreProblem { | |
case .someProblem(let someProblem): | |
alertDetails = AlertDetails( | |
message: someProblem.extras.description, | |
actions: [] | |
) | |
case .someOtherProblem(let someOtherProblem): | |
alertDetails = AlertDetails( | |
message: someOtherProblem.extras.message, | |
actions: [] | |
) | |
case .userActionRequired(let action): | |
alertDetails = AlertDetails( | |
message: action.extras.description, | |
actions: [ | |
AlertDetails.Action( | |
title: "Act Now", | |
handler: { | |
openURL(action.extras.action) | |
} | |
) | |
] | |
) | |
case .opaqueProblem(let opaqueProblem): | |
alertDetails = AlertDetails( | |
message: opaqueProblem.detail, | |
actions: [] | |
) | |
} | |
} | |
private func handleUnexpectedSituation() { | |
alertDetails = AlertDetails( | |
message: "Something unexpected happened", | |
actions: [] | |
) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment