Skip to content

Instantly share code, notes, and snippets.

@thomsmed
Last active January 19, 2025 11:48
Show Gist options
  • Save thomsmed/bc86a5729812f4e71f72f7d5ba962f48 to your computer and use it in GitHub Desktop.
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).
//
// 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