diff --git a/Package.swift b/Package.swift index 644e7d0..5c2d938 100644 --- a/Package.swift +++ b/Package.swift @@ -31,7 +31,7 @@ let products: [Product] = [ let dependencies: [Package.Dependency] = [ .package( url: "https://github.com/grpc/grpc-swift.git", - exact: "2.0.0-beta.1" + branch: "main" ), .package( url: "https://github.com/apple/swift-protobuf.git", @@ -73,6 +73,7 @@ let targets: [Target] = [ dependencies: [ .target(name: "GRPCProtobuf"), .product(name: "GRPCCore", package: "grpc-swift"), + .product(name: "GRPCInProcessTransport", package: "grpc-swift"), .product(name: "SwiftProtobuf", package: "swift-protobuf"), ], swiftSettings: defaultSwiftSettings diff --git a/Sources/GRPCProtobuf/Errors/ErrorDetails+AnyPacking.swift b/Sources/GRPCProtobuf/Errors/ErrorDetails+AnyPacking.swift new file mode 100644 index 0000000..1fe4ee5 --- /dev/null +++ b/Sources/GRPCProtobuf/Errors/ErrorDetails+AnyPacking.swift @@ -0,0 +1,151 @@ +/* + * Copyright 2024, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +internal import SwiftProtobuf + +/// A type which can be packed and unpacked from a `Google_Protobuf_Any` message. +internal protocol GoogleProtobufAnyPackable { + static var typeURL: String { get } + + /// Pack the value into a `Google_Protobuf_Any`, if possible. + func pack() throws -> Google_Protobuf_Any + + /// Unpack the value from a `Google_Protobuf_Any`, if possible. + init?(unpacking any: Google_Protobuf_Any) throws +} + +/// A type which is backed by a Protobuf message. +/// +/// This is a convenience protocol to allow for automatic packing/unpacking of messages +/// where possible. +internal protocol ProtobufBacked { + associatedtype Message: SwiftProtobuf.Message + var storage: Message { get set } + init(storage: Message) +} + +extension GoogleProtobufAnyPackable where Self: ProtobufBacked { + func pack() throws -> Google_Protobuf_Any { + try .with { + $0.typeURL = Self.typeURL + $0.value = try self.storage.serializedBytes() + } + } + + init?(unpacking any: Google_Protobuf_Any) throws { + guard let storage = try any.unpack(Message.self) else { return nil } + self.init(storage: storage) + } +} + +extension Google_Protobuf_Any { + func unpack(_ as: Unpacked.Type) throws -> Unpacked? { + if self.isA(Unpacked.self) { + return try Unpacked(serializedBytes: self.value) + } else { + return nil + } + } +} + +extension ErrorDetails { + // Note: this type isn't packable into an 'Any' protobuf so doesn't conform + // to 'GoogleProtobufAnyPackable' despite holding types which are packable. + + func pack() throws -> Google_Protobuf_Any { + switch self.wrapped { + case .errorInfo(let info): + return try info.pack() + case .retryInfo(let info): + return try info.pack() + case .debugInfo(let info): + return try info.pack() + case .quotaFailure(let info): + return try info.pack() + case .preconditionFailure(let info): + return try info.pack() + case .badRequest(let info): + return try info.pack() + case .requestInfo(let info): + return try info.pack() + case .resourceInfo(let info): + return try info.pack() + case .help(let info): + return try info.pack() + case .localizedMessage(let info): + return try info.pack() + case .any(let any): + return any + } + } + + init(unpacking any: Google_Protobuf_Any) throws { + if let unpacked = try Self.unpack(any: any) { + self = unpacked + } else { + self = .any(any) + } + } + + private static func unpack(any: Google_Protobuf_Any) throws -> Self? { + switch any.typeURL { + case ErrorInfo.typeURL: + if let unpacked = try ErrorInfo(unpacking: any) { + return .errorInfo(unpacked) + } + case RetryInfo.typeURL: + if let unpacked = try RetryInfo(unpacking: any) { + return .retryInfo(unpacked) + } + case DebugInfo.typeURL: + if let unpacked = try DebugInfo(unpacking: any) { + return .debugInfo(unpacked) + } + case QuotaFailure.typeURL: + if let unpacked = try QuotaFailure(unpacking: any) { + return .quotaFailure(unpacked) + } + case PreconditionFailure.typeURL: + if let unpacked = try PreconditionFailure(unpacking: any) { + return .preconditionFailure(unpacked) + } + case BadRequest.typeURL: + if let unpacked = try BadRequest(unpacking: any) { + return .badRequest(unpacked) + } + case RequestInfo.typeURL: + if let unpacked = try RequestInfo(unpacking: any) { + return .requestInfo(unpacked) + } + case ResourceInfo.typeURL: + if let unpacked = try ResourceInfo(unpacking: any) { + return .resourceInfo(unpacked) + } + case Help.typeURL: + if let unpacked = try Help(unpacking: any) { + return .help(unpacked) + } + case LocalizedMessage.typeURL: + if let unpacked = try LocalizedMessage(unpacking: any) { + return .localizedMessage(unpacked) + } + default: + return .any(any) + } + + return nil + } +} diff --git a/Sources/GRPCProtobuf/Errors/ErrorDetails+CustomStringConvertible.swift b/Sources/GRPCProtobuf/Errors/ErrorDetails+CustomStringConvertible.swift new file mode 100644 index 0000000..c781aaa --- /dev/null +++ b/Sources/GRPCProtobuf/Errors/ErrorDetails+CustomStringConvertible.swift @@ -0,0 +1,101 @@ +/* + * Copyright 2024, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +extension ErrorDetails: CustomStringConvertible { + public var description: String { + switch self.wrapped { + case .errorInfo(let info): + return String(describing: info) + case .retryInfo(let info): + return String(describing: info) + case .debugInfo(let info): + return String(describing: info) + case .quotaFailure(let info): + return String(describing: info) + case .preconditionFailure(let info): + return String(describing: info) + case .badRequest(let info): + return String(describing: info) + case .requestInfo(let info): + return String(describing: info) + case .resourceInfo(let info): + return String(describing: info) + case .help(let info): + return String(describing: info) + case .localizedMessage(let info): + return String(describing: info) + case .any(let any): + return String(describing: any) + } + } +} + +// Some errors use protobuf messages as their storage so the default description isn't +// representative + +extension ErrorDetails.ErrorInfo: CustomStringConvertible { + public var description: String { + "\(Self.self)(reason: \"\(self.reason)\", domain: \"\(self.domain)\", metadata: \(self.metadata))" + } +} + +extension ErrorDetails.DebugInfo: CustomStringConvertible { + public var description: String { + "\(Self.self)(stack: \(self.stack), detail: \"\(self.detail)\")" + } +} + +extension ErrorDetails.QuotaFailure.Violation: CustomStringConvertible { + public var description: String { + "\(Self.self)(subject: \"\(self.subject)\", violationDescription: \"\(self.violationDescription)\")" + } +} + +extension ErrorDetails.PreconditionFailure.Violation: CustomStringConvertible { + public var description: String { + "\(Self.self)(subject: \"\(self.subject)\", type: \"\(self.type)\", violationDescription: \"\(self.violationDescription)\")" + } +} + +extension ErrorDetails.BadRequest.FieldViolation: CustomStringConvertible { + public var description: String { + "\(Self.self)(field: \"\(self.field)\", violationDescription: \"\(self.violationDescription)\")" + } +} + +extension ErrorDetails.RequestInfo: CustomStringConvertible { + public var description: String { + "\(Self.self)(requestID: \"\(self.requestID)\", servingData: \"\(self.servingData)\")" + } +} + +extension ErrorDetails.ResourceInfo: CustomStringConvertible { + public var description: String { + "\(Self.self)(name: \"\(self.name)\", owner: \"\(self.owner)\", type: \"\(self.type)\", errorDescription: \"\(self.errorDescription)\")" + } +} + +extension ErrorDetails.Help.Link: CustomStringConvertible { + public var description: String { + "\(Self.self)(url: \"\(self.url)\", linkDescription: \"\(self.linkDescription)\")" + } +} + +extension ErrorDetails.LocalizedMessage: CustomStringConvertible { + public var description: String { + "\(Self.self)(locale: \"\(self.locale)\", message: \"\(self.message)\")" + } +} diff --git a/Sources/GRPCProtobuf/Errors/ErrorDetails+Types.swift b/Sources/GRPCProtobuf/Errors/ErrorDetails+Types.swift new file mode 100644 index 0000000..1a7f6b4 --- /dev/null +++ b/Sources/GRPCProtobuf/Errors/ErrorDetails+Types.swift @@ -0,0 +1,591 @@ +/* + * Copyright 2024, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +internal import SwiftProtobuf + +extension ErrorDetails { + /// Describes the cause of the error with structured details. + /// + /// Example of an error when contacting the "pubsub.googleapis.com" API when it + /// is not enabled: + /// + /// ``` + /// { "reason": "API_DISABLED" + /// "domain": "googleapis.com" + /// "metadata": { + /// "resource": "projects/123", + /// "service": "pubsub.googleapis.com" + /// } + /// } + /// ``` + /// + /// This response indicates that the pubsub.googleapis.com API is not enabled. + /// + /// Example of an error that is returned when attempting to create a Spanner + /// instance in a region that is out of stock: + /// + /// ``` + /// { "reason": "STOCKOUT" + /// "domain": "spanner.googleapis.com", + /// "metadata": { + /// "availableRegions": "us-central1,us-east2" + /// } + /// } + /// ``` + public struct ErrorInfo: Sendable, Hashable, GoogleProtobufAnyPackable, ProtobufBacked { + var storage: Google_Rpc_ErrorInfo + // See https://protobuf.dev/programming-guides/proto3/#any + static var typeURL: String { "type.googleapis.com/google.rpc.ErrorInfo" } + + init(storage: Google_Rpc_ErrorInfo) { + self.storage = storage + } + + /// The reason of the error. + /// + /// This is a constant value that identifies the proximate cause of the error. Error reasons + /// are unique within a particular domain of errors. This should be at most 63 characters and + /// match a regular expression of `[A-Z][A-Z0-9_]+[A-Z0-9]`, which represents + /// 'UPPER\_SNAKE\_CASE'. + public var reason: String { + get { self.storage.reason } + set { self.storage.reason = newValue } + } + + /// The logical grouping to which the "reason" belongs. + /// + /// The error domain is typically the registered service name of the tool or product that + /// generates the error. Example: "pubsub.googleapis.com". If the error is generated by some + /// common infrastructure, the error domain must be a globally unique value that identifies + /// the infrastructure. For Google API infrastructure, the error domain is "googleapis.com". + public var domain: String { + get { self.storage.domain } + set { self.storage.domain = newValue } + } + + /// Additional structured details about this error. + /// + /// Keys should match `[a-zA-Z0-9-_]` and be limited to 64 characters in length. When + /// identifying the current value of an exceeded limit, the units should be contained in the + /// key, not the value. For example, rather than `{"instanceLimit": "100/request"}`, should be + /// returned as, `{"instanceLimitPerRequest": "100"}``, if the client exceeds the number of + /// instances that can be created in a single (batch) request. + public var metadata: [String: String] { + get { self.storage.metadata } + set { self.storage.metadata = newValue } + } + + public init(reason: String, domain: String, metadata: [String: String] = [:]) { + self.storage = .with { + $0.reason = reason + $0.domain = domain + $0.metadata = metadata + } + } + } + + /// Describes when the clients can retry a failed request. Clients could ignore + /// the recommendation here or retry when this information is missing from error + /// responses. + /// + /// It's always recommended that clients should use exponential backoff when + /// retrying. + /// + /// Clients should wait until `retry_delay` amount of time has passed since + /// receiving the error response before retrying. If retrying requests also + /// fail, clients should use an exponential backoff scheme to gradually increase + /// the delay between retries based on `retry_delay`, until either a maximum + /// number of retries have been reached or a maximum retry delay cap has been + /// reached. + public struct RetryInfo: Sendable, Hashable, GoogleProtobufAnyPackable { + // See https://protobuf.dev/programming-guides/proto3/#any + internal static var typeURL: String { "type.googleapis.com/google.rpc.RetryInfo" } + + internal func pack() throws -> Google_Protobuf_Any { + let duration = Google_Rpc_RetryInfo.with { + $0.retryDelay = .with { + $0.seconds = self.delay.components.seconds + $0.nanos = Int32(self.delay.components.attoseconds / 1_000_000_000) + } + } + + return try .with { + $0.typeURL = Self.typeURL + $0.value = try duration.serializedBytes() + } + } + + /// Clients should wait at least this long between retrying the same request. + public var delay: Duration + + public init(delay: Duration) { + self.delay = delay + } + + init?(unpacking any: Google_Protobuf_Any) throws { + guard let info = try any.unpack(Google_Rpc_RetryInfo.self) else { return nil } + self.delay = Duration( + secondsComponent: info.retryDelay.seconds, + attosecondsComponent: Int64(info.retryDelay.nanos) * 1_000_000_000 + ) + } + } + + /// Describes additional debugging info. + public struct DebugInfo: Sendable, Hashable, GoogleProtobufAnyPackable, ProtobufBacked { + var storage: Google_Rpc_DebugInfo + // See https://protobuf.dev/programming-guides/proto3/#any + static var typeURL: String { "type.googleapis.com/google.rpc.DebugInfo" } + + init(storage: Google_Rpc_DebugInfo) { + self.storage = storage + } + + /// The stack trace entries indicating where the error occurred. + public var stack: [String] { + get { self.storage.stackEntries } + set { self.storage.stackEntries = newValue } + } + + /// Additional debugging information provided by the server. + public var detail: String { + get { self.storage.detail } + set { self.storage.detail = newValue } + } + + public init(stack: [String], detail: String) { + self.storage = .with { + $0.stackEntries = stack + $0.detail = detail + } + } + } + + /// Describes how a quota check failed. + /// + /// For example if a daily limit was exceeded for the calling project, + /// a service could respond with a QuotaFailure detail containing the project + /// id and the description of the quota limit that was exceeded. If the + /// calling project hasn't enabled the service in the developer console, then + /// a service could respond with the project id and set `service_disabled` + /// to true. + /// + /// Also see RetryInfo and Help types for other details about handling a + /// quota failure. + public struct QuotaFailure: Sendable, Hashable, GoogleProtobufAnyPackable { + /// A message type used to describe a single quota violation. For example, a + /// daily quota or a custom quota that was exceeded. + public struct Violation: Sendable, Hashable { + /// The subject on which the quota check failed. + /// + /// For example, "clientip:" or "project:". + public var subject: String + + /// A description of how the quota check failed. + /// + /// Clients can use this description to find more about the quota configuration in the + /// service's public documentation, or find the relevant quota limit to adjust through + /// developer console. + /// + /// For example: "Service disabled" or "Daily Limit for read operations + /// exceeded". + public var violationDescription: String + + public init(subject: String, description: String) { + self.subject = subject + self.violationDescription = description + } + + init(_ storage: Google_Rpc_QuotaFailure.Violation) { + self.subject = storage.subject + self.violationDescription = storage.description_p + } + } + + // See https://protobuf.dev/programming-guides/proto3/#any + internal static var typeURL: String { "type.googleapis.com/google.rpc.QuotaFailure" } + + internal func pack() throws -> Google_Protobuf_Any { + let message = Google_Rpc_QuotaFailure.with { + $0.violations = self.violations.map { violation in + .with { + $0.subject = violation.subject + $0.description_p = violation.violationDescription + } + } + } + + return try .with { + // See https://protobuf.dev/programming-guides/proto3/#any + $0.typeURL = Self.typeURL + $0.value = try message.serializedBytes() + } + } + + /// Describes all quota violations. + public var violations: [Violation] + + public init(violations: [Violation]) { + self.violations = violations + } + + init?(unpacking any: Google_Protobuf_Any) throws { + guard let message = try any.unpack(Google_Rpc_QuotaFailure.self) else { return nil } + self.violations = message.violations.map { Violation($0) } + } + } + + /// Describes what preconditions have failed. + /// + /// For example, if an RPC failed because it required the Terms of Service to be + /// acknowledged, it could list the terms of service violation in the + /// PreconditionFailure message. + public struct PreconditionFailure: Sendable, Hashable, GoogleProtobufAnyPackable { + /// A message type used to describe a single precondition failure. + public struct Violation: Sendable, Hashable { + /// The type of PreconditionFailure. + /// + /// We recommend using a service-specific enum type to define the supported precondition + /// violation subjects. For example, "TOS" for "Terms of Service violation". + public var type: String + + /// The subject, relative to the type, that failed. + /// + /// For example, "google.com/cloud" relative to the "TOS" type would indicate + /// which terms of service is being referenced. + public var subject: String + + /// A description of how the precondition failed. Developers can use this + /// description to understand how to fix the failure. + /// + /// For example: "Terms of service not accepted". + public var violationDescription: String + + public init(type: String, subject: String, description: String) { + self.type = type + self.subject = subject + self.violationDescription = description + } + + init(_ storage: Google_Rpc_PreconditionFailure.Violation) { + self.type = storage.type + self.subject = storage.subject + self.violationDescription = storage.description_p + } + } + + // See https://protobuf.dev/programming-guides/proto3/#any + internal static var typeURL: String { "type.googleapis.com/google.rpc.PreconditionFailure" } + + internal func pack() throws -> Google_Protobuf_Any { + let message = Google_Rpc_PreconditionFailure.with { + $0.violations = self.violations.map { violation in + .with { + $0.type = violation.type + $0.subject = violation.subject + $0.description_p = violation.violationDescription + } + } + } + + return try .with { + $0.typeURL = Self.typeURL + $0.value = try message.serializedBytes() + } + } + + /// Describes all precondition violations. + public var violations: [Violation] + + public init(violations: [Violation]) { + self.violations = violations + } + + init?(unpacking any: Google_Protobuf_Any) throws { + guard let message = try any.unpack(Google_Rpc_PreconditionFailure.self) else { return nil } + self.violations = message.violations.map { Violation($0) } + } + } + + /// Describes violations in a client request. This error type focuses on the + /// syntactic aspects of the request. + public struct BadRequest: Sendable, Hashable, GoogleProtobufAnyPackable { + /// A message type used to describe a single bad request field. + public struct FieldViolation: Sendable, Hashable { + /// A path that leads to a field in the request body. The value will be a + /// sequence of dot-separated identifiers that identify a protocol buffer + /// field. + /// + /// Consider the following: + /// + /// message CreateContactRequest { + /// message EmailAddress { + /// enum Type { + /// TYPE_UNSPECIFIED = 0; + /// HOME = 1; + /// WORK = 2; + /// } + /// + /// optional string email = 1; + /// repeated EmailType type = 2; + /// } + /// + /// string full_name = 1; + /// repeated EmailAddress email_addresses = 2; + /// } + /// + /// In this example, in proto `field` could take one of the following values: + /// + /// * `full_name` for a violation in the `full_name` value + /// * `email_addresses[1].email` for a violation in the `email` field of the + /// first `email_addresses` message + /// * `email_addresses[3].type[2]` for a violation in the second `type` + /// value in the third `email_addresses` message. + /// + /// In JSON, the same values are represented as: + /// + /// * `fullName` for a violation in the `fullName` value + /// * `emailAddresses[1].email` for a violation in the `email` field of the + /// first `emailAddresses` message + /// * `emailAddresses[3].type[2]` for a violation in the second `type` + /// value in the third `emailAddresses` message. + public var field: String + + /// A description of why the request element is bad. + public var violationDescription: String + + public init(field: String, description: String) { + self.field = field + self.violationDescription = description + } + + init(_ storage: Google_Rpc_BadRequest.FieldViolation) { + self.field = storage.field + self.violationDescription = storage.description_p + } + } + + // See https://protobuf.dev/programming-guides/proto3/#any + internal static var typeURL: String { "type.googleapis.com/google.rpc.BadRequest" } + + internal func pack() throws -> Google_Protobuf_Any { + let message = Google_Rpc_BadRequest.with { + $0.fieldViolations = self.violations.map { violation in + .with { + $0.field = violation.field + $0.description_p = violation.violationDescription + } + } + } + + return try .with { + $0.typeURL = Self.typeURL + $0.value = try message.serializedBytes() + } + } + + init?(unpacking any: Google_Protobuf_Any) throws { + guard let message = try any.unpack(Google_Rpc_BadRequest.self) else { return nil } + self.violations = message.fieldViolations.map { FieldViolation($0) } + } + + /// Describes all violations in a client request. + public var violations: [FieldViolation] + + public init(violations: [FieldViolation]) { + self.violations = violations + } + + } + + /// Contains metadata about the request that clients can attach when filing a bug + /// or providing other forms of feedback. + public struct RequestInfo: Sendable, Hashable, GoogleProtobufAnyPackable, ProtobufBacked { + var storage: Google_Rpc_RequestInfo + // See https://protobuf.dev/programming-guides/proto3/#any + static var typeURL: String { "type.googleapis.com/google.rpc.RequestInfo" } + + init(storage: Google_Rpc_RequestInfo) { + self.storage = storage + } + + /// An opaque string that should only be interpreted by the service generating + /// it. For example, it can be used to identify requests in the service's logs. + public var requestID: String { + get { self.storage.requestID } + set { self.storage.requestID = newValue } + } + + /// Any data that was used to serve this request. For example, an encrypted + /// stack trace that can be sent back to the service provider for debugging. + public var servingData: String { + get { self.storage.servingData } + set { self.storage.servingData = newValue } + } + + public init(requestID: String, servingData: String) { + self.storage = .with { + $0.requestID = requestID + $0.servingData = servingData + } + } + } + + /// Describes the resource that is being accessed. + public struct ResourceInfo: Sendable, Hashable, GoogleProtobufAnyPackable, ProtobufBacked { + var storage: Google_Rpc_ResourceInfo + // See https://protobuf.dev/programming-guides/proto3/#any + static var typeURL: String { "type.googleapis.com/google.rpc.ResourceInfo" } + + init(storage: Google_Rpc_ResourceInfo) { + self.storage = storage + } + + /// A name for the type of resource being accessed, e.g. "sql table", + /// "cloud storage bucket", "file", "Google calendar"; or the type URL + /// of the resource: e.g. "type.googleapis.com/google.pubsub.v1.Topic". + public var type: String { + get { self.storage.resourceType } + set { self.storage.resourceType = newValue } + } + + /// The name of the resource being accessed. + /// + /// For example, a shared calendar name: "example.com_4fghdhgsrgh@group.calendar.google.com", + /// if the current error is `permissionDenied`. + public var name: String { + get { self.storage.resourceName } + set { self.storage.resourceName = newValue } + } + + /// The owner of the resource (optional). + /// + /// For example, "user:" or "project:". + public var owner: String { + get { self.storage.owner } + set { self.storage.owner = newValue } + } + + /// Describes what error is encountered when accessing this resource. + /// For example, updating a cloud project may require the `writer` permission + /// on the developer console project. + public var errorDescription: String { + get { self.storage.description_p } + set { self.storage.description_p = newValue } + } + + public init(type: String, name: String, errorDescription: String, owner: String = "") { + self.storage = .with { + $0.resourceType = type + $0.resourceName = name + $0.owner = owner + $0.description_p = errorDescription + } + } + } + + /// Provides links to documentation or for performing an out of band action. + /// + /// For example, if a quota check failed with an error indicating the calling + /// project hasn't enabled the accessed service, this can contain a URL pointing + /// directly to the right place in the developer console to flip the bit. + public struct Help: Sendable, Hashable, GoogleProtobufAnyPackable { + /// Describes a URL link. + public struct Link: Sendable, Hashable { + /// The URL of the link. + public var url: String + + /// Describes what the link offers. + public var linkDescription: String + + public init(url: String, description: String) { + self.url = url + self.linkDescription = description + } + + init(_ storage: Google_Rpc_Help.Link) { + self.url = storage.url + self.linkDescription = storage.description_p + } + } + + // See https://protobuf.dev/programming-guides/proto3/#any + internal static var typeURL: String { "type.googleapis.com/google.rpc.Help" } + + internal func pack() throws -> Google_Protobuf_Any { + let message = Google_Rpc_Help.with { + $0.links = self.links.map { link in + .with { + $0.url = link.url + $0.description_p = link.linkDescription + } + } + } + + return try .with { + $0.typeURL = Self.typeURL + $0.value = try message.serializedBytes() + } + } + + init?(unpacking any: Google_Protobuf_Any) throws { + guard let storage = try any.unpack(Google_Rpc_Help.self) else { return nil } + self.links = storage.links.map { Link($0) } + } + + /// URL(s) pointing to additional information on handling the current error. + public var links: [Link] + + public init(links: [Link]) { + self.links = links + } + } + + /// Provides a localized error message that is safe to return to the user + /// which can be attached to an RPC error. + public struct LocalizedMessage: Sendable, Hashable, GoogleProtobufAnyPackable, ProtobufBacked { + var storage: Google_Rpc_LocalizedMessage + // See https://protobuf.dev/programming-guides/proto3/#any + static var typeURL: String { "type.googleapis.com/google.rpc.LocalizedMessage" } + + init(storage: Google_Rpc_LocalizedMessage) { + self.storage = storage + } + + /// The locale used following the specification defined at + /// https://www.rfc-editor.org/rfc/bcp/bcp47.txt. + /// + /// Examples are: "en-US", "fr-CH", "es-MX" + public var locale: String { + get { self.storage.locale } + set { self.storage.locale = newValue } + } + + /// The localized error message in the above locale. + public var message: String { + get { self.storage.message } + set { self.storage.message = newValue } + } + + public init(locale: String, message: String) { + self.storage = .with { + $0.locale = locale + $0.message = message + } + } + } +} diff --git a/Sources/GRPCProtobuf/Errors/ErrorDetails.swift b/Sources/GRPCProtobuf/Errors/ErrorDetails.swift new file mode 100644 index 0000000..57ea235 --- /dev/null +++ b/Sources/GRPCProtobuf/Errors/ErrorDetails.swift @@ -0,0 +1,313 @@ +/* + * Copyright 2024, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +public import SwiftProtobuf + +/// Common details which can be used to supplement ``DetailedRPCError`` and ``GoogleRPCStatus``. +/// +/// This represents the set of common error types suggested by [Google AIP-193](https://google.aip.dev/193). +/// These types are derived from and will serialize to the those represented in +/// [google/rpc/error_details.proto](https://github.com/googleapis/googleapis/blob/3597f7db2191c00b100400991ef96e52d62f5841/google/rpc/error_details.proto). +/// +/// This type also allows you to provide wrap your own error details up as an "Any" +/// protobuf (`Google_Protobuf_Any`). +public struct ErrorDetails: Sendable, Hashable { + enum Wrapped: Sendable, Hashable { + case errorInfo(ErrorInfo) + case retryInfo(RetryInfo) + case debugInfo(DebugInfo) + case quotaFailure(QuotaFailure) + case preconditionFailure(PreconditionFailure) + case badRequest(BadRequest) + case requestInfo(RequestInfo) + case resourceInfo(ResourceInfo) + case help(Help) + case localizedMessage(LocalizedMessage) + case any(Google_Protobuf_Any) + } + + private(set) var wrapped: Wrapped + + private init(_ wrapped: Wrapped) { + self.wrapped = wrapped + } + + /// Create a new detail wrapping a `Google_Protobuf_Any`. + public static func any(_ any: Google_Protobuf_Any) -> Self { + Self(.any(any)) + } + + /// Create a new detail wrapping an ``ErrorInfo-swift.struct``. + public static func errorInfo(_ info: ErrorInfo) -> Self { + Self(.errorInfo(info)) + } + + /// Create a ``ErrorInfo-swift.struct`` detail. + /// + /// - Parameters: + /// - reason: The reason of the error. + /// - domain: The logical grouping to which the "reason" belongs. + /// - metadata: Additional structured details about this error. + public static func errorInfo( + reason: String, + domain: String, + metadata: [String: String] = [:] + ) -> Self { + Self.errorInfo(ErrorInfo(reason: reason, domain: domain, metadata: metadata)) + } + + /// Create a new detail wrapping a ``RetryInfo-swift.struct``. + public static func retryInfo(_ info: RetryInfo) -> Self { + Self(.retryInfo(info)) + } + + /// Create a ``RetryInfo-swift.struct`` detail. + /// + /// - Parameter delay: Amount of time clients should wait before retrying this request. + public static func retryInfo(delay: Duration) -> Self { + Self.retryInfo(RetryInfo(delay: delay)) + } + + /// Create a new detail wrapping a ``DebugInfo-swift.struct``. + public static func debugInfo(_ info: DebugInfo) -> Self { + Self(.debugInfo(info)) + } + + /// Create a ``DebugInfo-swift.struct`` detail. + /// + /// - Parameters: + /// - stack: The stack trace entries indicating where the error occurred. + /// - detail: Additional debugging information provided by the server. + public static func debugInfo(stack: [String], detail: String) -> Self { + Self.debugInfo(DebugInfo(stack: stack, detail: detail)) + } + + /// Create a new detail wrapping a ``QuotaFailure-swift.struct``. + public static func quotaFailure(_ info: QuotaFailure) -> Self { + Self(.quotaFailure(info)) + } + + /// Create a ``QuotaFailure-swift.struct`` detail. + /// + /// - Parameter violations: Describes all quota violations. + public static func quotaFailure(violations: [QuotaFailure.Violation]) -> Self { + Self.quotaFailure(QuotaFailure(violations: violations)) + } + + /// Create a new detail wrapping a ``PreconditionFailure-swift.struct``. + public static func preconditionFailure(_ info: PreconditionFailure) -> Self { + Self(.preconditionFailure(info)) + } + + /// Create a ``PreconditionFailure-swift.struct`` detail. + /// + /// - Parameter violations: Describes all precondition violations. + public static func preconditionFailure(violations: [PreconditionFailure.Violation]) -> Self { + Self.preconditionFailure(PreconditionFailure(violations: violations)) + } + + /// Create a new detail wrapping a ``BadRequest-swift.struct``. + public static func badRequest(_ info: BadRequest) -> Self { + Self(.badRequest(info)) + } + + /// Create a ``BadRequest-swift.struct`` detail. + /// + /// - Parameter violations: Describes all request violations. + public static func badRequest(violations: [BadRequest.FieldViolation]) -> Self { + Self.badRequest(BadRequest(violations: violations)) + } + + /// Create a new detail wrapping a ``RequestInfo-swift.struct``. + public static func requestInfo(_ info: RequestInfo) -> Self { + Self(.requestInfo(info)) + } + + /// Create a ``RequestInfo-swift.struct`` detail. + /// + /// - Parameters: + /// - requestID: /// An opaque string that should only be interpreted by the service generating + /// it. For example, it can be used to identify requests in the service's logs. + /// - servingData: Any data that was used to serve this request. For example, an encrypted + /// stack trace that can be sent back to the service provider for debugging. + public static func requestInfo(requestID: String, servingData: String) -> Self { + Self.requestInfo(RequestInfo(requestID: requestID, servingData: servingData)) + } + + /// Create a new detail wrapping a ``ResourceInfo-swift.struct``. + public static func resourceInfo(_ info: ResourceInfo) -> Self { + Self(.resourceInfo(info)) + } + + /// Create a ``ResourceInfo-swift.struct`` detail. + /// + /// - Parameters: + /// - type: The type of resource being accessed, e,.g. "sql table", "file" or type URL of the + /// resource. + /// - name: The name of the resource being accessed. + /// - errorDescription: Describes the error encountered when accessing this resource. + /// - owner: The owner of the resource. + public static func resourceInfo( + type: String, + name: String, + errorDescription: String, + owner: String = "" + ) -> Self { + Self.resourceInfo( + ResourceInfo(type: type, name: name, errorDescription: errorDescription, owner: owner) + ) + } + + /// Create a ``Help-swift.struct`` detail. + public static func help(_ info: Help) -> Self { + Self(.help(info)) + } + + /// Create a ``Help-swift.struct`` detail. + /// + /// - Parameter links: URL(s) pointing to additional information on handling the current error. + public static func help(links: [Help.Link]) -> Self { + Self.help(Help(links: links)) + } + + /// Create a ``LocalizedMessage-swift.struct`` detail. + public static func localizedMessage(_ info: LocalizedMessage) -> Self { + Self(.localizedMessage(info)) + } + + /// Create a ``Help-swift.struct`` detail. + /// + /// - Parameters: + /// - locale: The locale used. + /// - message: Localized error message. + public static func localizedMessage(locale: String, message: String) -> Self { + Self.localizedMessage(LocalizedMessage(locale: locale, message: message)) + } +} + +extension ErrorDetails { + /// Returns error info if set. + public var errorInfo: ErrorInfo? { + switch self.wrapped { + case .errorInfo(let info): + return info + default: + return nil + } + } + + /// Returns retry info if set. + public var retryInfo: RetryInfo? { + switch self.wrapped { + case .retryInfo(let info): + return info + default: + return nil + } + } + + /// Returns debug info if set. + public var debugInfo: DebugInfo? { + switch self.wrapped { + case .debugInfo(let info): + return info + default: + return nil + } + } + + /// Returns a quota failure if set. + public var quotaFailure: QuotaFailure? { + switch self.wrapped { + case .quotaFailure(let info): + return info + default: + return nil + } + } + + /// Returns a precondition failure if set. + public var preconditionFailure: PreconditionFailure? { + switch self.wrapped { + case .preconditionFailure(let info): + return info + default: + return nil + } + } + + /// Returns bad request details if set. + public var badRequest: BadRequest? { + switch self.wrapped { + case .badRequest(let info): + return info + default: + return nil + } + } + + /// Returns request info if set. + public var requestInfo: RequestInfo? { + switch self.wrapped { + case .requestInfo(let info): + return info + default: + return nil + } + } + + /// Returns resource info if set. + public var resourceInfo: ResourceInfo? { + switch self.wrapped { + case .resourceInfo(let info): + return info + default: + return nil + } + } + + /// Returns help if set. + public var help: Help? { + switch self.wrapped { + case .help(let info): + return info + default: + return nil + } + } + + /// Returns a localized message if set. + public var localizedMessage: LocalizedMessage? { + switch self.wrapped { + case .localizedMessage(let info): + return info + default: + return nil + } + } + + /// Returns `Google_Protobuf_Any` if applicable. + /// + /// Calling this **doesn't** encode a detail of another type into a `Google_Protobuf_Any`. + public var any: Google_Protobuf_Any? { + switch self.wrapped { + case .any(let any): + return any + default: + return nil + } + } +} diff --git a/Sources/GRPCProtobuf/Errors/GoogleRPCStatus.swift b/Sources/GRPCProtobuf/Errors/GoogleRPCStatus.swift new file mode 100644 index 0000000..831c7d6 --- /dev/null +++ b/Sources/GRPCProtobuf/Errors/GoogleRPCStatus.swift @@ -0,0 +1,120 @@ +/* + * Copyright 2024, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +public import GRPCCore +internal import SwiftProtobuf + +/// An error containing structured details which can be delivered to the client. +/// +/// This error follows the "richer error model" detailed in the +/// [gRPC error guide](https://grpc.io/docs/guides/error/) and +/// [Google AIP-193](https://google.aip.dev/193). +/// +/// Like an `RPCError`, this error has a `code` and `message`. However it also includes +/// a list of structured error details which can be propagated to clients. A set of standard +/// details are provided by ``ErrorDetails``. +/// +/// As a client you can extract this error from an `RPCError` using `unpackGoogleRPCStatus()`. +/// +/// > Implementation details: +/// > +/// > The error information is transmitted to clients in the trailing metadata of an RPC. It is +/// > inserted into the metadata keyed by "grpc-status-details-bin". The value of the metadata is +/// > the serialized bytes of a "google.protobuf.Any" protocol buffers message. The content of which +/// > is a "google.rpc.Status" protocol buffers message containing the status code, message, and +/// > details. +public struct GoogleRPCStatus: Error { + /// A code representing the high-level domain of the error. + public var code: RPCError.Code + + /// A developer-facing error message, which should be in English. + /// + /// Any user-facing error message should be localized and sent in the ``details`` field + /// or localized by the client. + public var message: String + + /// A list of messages that carry the error details. + /// + /// There is a common set of message types for APIs to use. + public var details: [ErrorDetails] + + /// Create a new Google RPC Status error. + /// + /// - Parameters: + /// - code: A code representing the high-level domain of the error. + /// - message: A developer-facing error message. + /// - details: A list of messages that carry the error details. + public init(code: RPCError.Code, message: String, details: [ErrorDetails]) { + self.code = code + self.message = message + self.details = details + } + + /// Create a new Google RPC Status error. + /// + /// - Parameters: + /// - code: A code representing the high-level domain of the error. + /// - message: A developer-facing error message. + /// - details: A list of messages that carry the error details. + public init(code: RPCError.Code, message: String, details: ErrorDetails...) { + self.code = code + self.message = message + self.details = details + } +} + +extension GoogleRPCStatus: GoogleProtobufAnyPackable { + // See https://protobuf.dev/programming-guides/proto3/#any + internal static var typeURL: String { "type.googleapis.com/google.rpc.Status" } + + init?(unpacking any: Google_Protobuf_Any) throws { + guard any.isA(Google_Rpc_Status.self) else { return nil } + let status = try Google_Rpc_Status(serializedBytes: any.value) + + let statusCode = Status.Code(rawValue: Int(status.code)) + self.code = statusCode.flatMap { RPCError.Code($0) } ?? .unknown + self.message = status.message + self.details = try status.details.map { try ErrorDetails(unpacking: $0) } + } + + func pack() throws -> Google_Protobuf_Any { + let status = try Google_Rpc_Status.with { + $0.code = Int32(self.code.rawValue) + $0.message = self.message + $0.details = try self.details.map { try $0.pack() } + } + + return try .with { + $0.typeURL = Self.typeURL + $0.value = try status.serializedBytes() + } + } +} + +extension GoogleRPCStatus: RPCErrorConvertible { + public var rpcErrorCode: RPCError.Code { self.code } + public var rpcErrorMessage: String { self.message } + public var rpcErrorMetadata: Metadata { + do { + let any = try self.pack() + let bytes: [UInt8] = try any.serializedBytes() + return [Metadata.statusDetailsBinKey: .binary(bytes)] + } catch { + // Failed to serialize error details. Not a lot can be done here. + return [:] + } + } +} diff --git a/Sources/GRPCProtobuf/Errors/RPCError+GoogleRPCStatus.swift b/Sources/GRPCProtobuf/Errors/RPCError+GoogleRPCStatus.swift new file mode 100644 index 0000000..9ab08db --- /dev/null +++ b/Sources/GRPCProtobuf/Errors/RPCError+GoogleRPCStatus.swift @@ -0,0 +1,38 @@ +/* + * Copyright 2024, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +public import GRPCCore +internal import SwiftProtobuf + +extension Metadata { + static let statusDetailsBinKey = "grpc-status-details-bin" +} + +extension RPCError { + /// Unpack a ``GoogleRPCStatus`` error from the error metadata. + /// + /// - Throws: If status details exist in the metadata but they couldn't be unpacked to + /// a ``GoogleRPCStatus``. + /// - Returns: The unpacked ``GoogleRPCStatus`` or `nil` if the metadata didn't contain any + /// status details. + public func unpackGoogleRPCStatus() throws -> GoogleRPCStatus? { + let values = self.metadata[binaryValues: Metadata.statusDetailsBinKey] + guard let bytes = values.first(where: { _ in true }) else { return nil } + + let any = try Google_Protobuf_Any(serializedBytes: bytes) + return try GoogleRPCStatus(unpacking: any) + } +} diff --git a/Tests/GRPCProtobufTests/Errors/DetailedErrorTests.swift b/Tests/GRPCProtobufTests/Errors/DetailedErrorTests.swift new file mode 100644 index 0000000..5c40002 --- /dev/null +++ b/Tests/GRPCProtobufTests/Errors/DetailedErrorTests.swift @@ -0,0 +1,230 @@ +/* + * Copyright 2024, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GRPCCore +import GRPCInProcessTransport +import GRPCProtobuf +import SwiftProtobuf +import Testing + +struct DetailedErrorTests { + @Test( + "Google RPC Status is transferred over the wire", + arguments: [ + ([], []), + (["ErrorInfo"], [.errorInfo(.testValue)]), + (["RetryInfo"], [.retryInfo(.testValue)]), + (["DebugInfo"], [.debugInfo(.testValue)]), + (["QuotaFailure"], [.quotaFailure(.testValue)]), + (["PreconditionFailure"], [.preconditionFailure(.testValue)]), + (["BadRequest"], [.badRequest(.testValue)]), + (["RequestInfo"], [.requestInfo(.testValue)]), + (["ResourceInfo"], [.resourceInfo(.testValue)]), + (["Help"], [.help(.testValue)]), + (["LocalizedMessage"], [.localizedMessage(.testValue)]), + (["DebugInfo", "RetryInfo"], [.debugInfo(.testValue), .retryInfo(.testValue)]), + (["Help", "PreconditionFailure"], [.help(.testValue), .preconditionFailure(.testValue)]), + (["Help", "Help", "Help"], [.help(.testValue), .help(.testValue), .help(.testValue)]), + ] as [([String], [ErrorDetails])] + ) + func rpcStatus(details: [String], expected: [ErrorDetails]) async throws { + let inProcess = InProcessTransport() + try await withGRPCServer(transport: inProcess.server, services: [ErrorThrowingService()]) { _ in + try await withGRPCClient(transport: inProcess.client) { client in + let errorClient = ErrorService.Client(wrapping: client) + let subkinds = details.joined(separator: ",") + let kind = "status/\(subkinds)" + + await #expect { + try await errorClient.throwError(.with { $0.kind = kind }) + } throws: { error in + guard let rpcError = error as? RPCError else { return false } + guard let status = try? rpcError.unpackGoogleRPCStatus() else { return false } + + // Code/message should be the same. + #expect(status.code == rpcError.code) + #expect(status.message == rpcError.message) + + // Set by the service. + #expect(status.code == .unknown) + #expect(status.message == subkinds) + #expect(status.details == expected) + + return true + } + } + } + } + + @Test( + arguments: [ + (.errorInfo(.testValue), #"ErrorInfo(reason: "r", domain: "d", metadata: ["k": "v"])"#), + (.retryInfo(.testValue), #"RetryInfo(delay: 1.0 seconds)"#), + (.debugInfo(.testValue), #"DebugInfo(stack: ["foo.foo()", "foo.bar()"], detail: "detail")"#), + ( + .quotaFailure(.testValue), + #"QuotaFailure(violations: [Violation(subject: "s", violationDescription: "d")])"# + ), + ( + .preconditionFailure(.testValue), + #"PreconditionFailure(violations: [Violation(subject: "s", type: "t", violationDescription: "d")])"# + ), + ( + .badRequest(.testValue), + #"BadRequest(violations: [FieldViolation(field: "f", violationDescription: "d")])"# + ), + (.requestInfo(.testValue), #"RequestInfo(requestID: "id", servingData: "d")"#), + ( + .resourceInfo(.testValue), + #"ResourceInfo(name: "n", owner: "", type: "t", errorDescription: "d")"# + ), + (.help(.testValue), #"Help(links: [Link(url: "url", linkDescription: "d")])"#), + (.localizedMessage(.testValue), #"LocalizedMessage(locale: "l", message: "m")"#), + ] as [(ErrorDetails, String)] + ) + func errorInfoDescription(_ details: ErrorDetails, expected: String) { + #expect(String(describing: details) == expected) + } +} + +private struct ErrorThrowingService: ErrorService.SimpleServiceProtocol { + func throwError( + request: ThrowInput, + context: ServerContext + ) async throws -> Google_Protobuf_Empty { + if request.kind.starts(with: "status/") { + try self.throwStatusError(kind: String(request.kind.dropFirst("status/".count))) + } else { + throw RPCError(code: .invalidArgument, message: "'\(request.kind)' is invalid.") + } + } + + private func throwStatusError(kind: String) throws(GoogleRPCStatus) -> Never { + var details: [ErrorDetails] = [] + for subkind in kind.split(separator: ",") { + if let detail = self.errorDetails(kind: String(subkind)) { + details.append(detail) + } else { + throw GoogleRPCStatus( + code: .invalidArgument, + message: "Unknown error subkind", + details: [ + .badRequest( + violations: [ + ErrorDetails.BadRequest.FieldViolation( + field: "kind", + description: "'\(kind)' is invalid" + ) + ] + ) + ] + ) + } + } + + throw GoogleRPCStatus(code: .unknown, message: kind, details: details) + } + + private func errorDetails(kind: String) -> ErrorDetails? { + let details: ErrorDetails? + + switch kind { + case "ErrorInfo": + details = .errorInfo(.testValue) + case "RetryInfo": + details = .retryInfo(.testValue) + case "DebugInfo": + details = .debugInfo(.testValue) + case "QuotaFailure": + details = .quotaFailure(.testValue) + case "PreconditionFailure": + details = .preconditionFailure(.testValue) + case "BadRequest": + details = .badRequest(.testValue) + case "RequestInfo": + details = .requestInfo(.testValue) + case "ResourceInfo": + details = .resourceInfo(.testValue) + case "Help": + details = .help(.testValue) + case "LocalizedMessage": + details = .localizedMessage(.testValue) + default: + details = nil + } + + return details + } +} + +extension ErrorDetails.ErrorInfo { + fileprivate static let testValue = Self(reason: "r", domain: "d", metadata: ["k": "v"]) +} + +extension ErrorDetails.RetryInfo { + fileprivate static let testValue = Self(delay: .seconds(1)) +} + +extension ErrorDetails.DebugInfo { + fileprivate static let testValue = Self( + stack: ["foo.foo()", "foo.bar()"], + detail: "detail" + ) +} + +extension ErrorDetails.QuotaFailure { + fileprivate static let testValue = Self( + violations: [ + Violation(subject: "s", description: "d") + ] + ) +} + +extension ErrorDetails.PreconditionFailure { + fileprivate static let testValue = Self( + violations: [ + Violation(type: "t", subject: "s", description: "d") + ] + ) +} + +extension ErrorDetails.BadRequest { + fileprivate static let testValue = Self( + violations: [ + FieldViolation(field: "f", description: "d") + ] + ) +} + +extension ErrorDetails.RequestInfo { + fileprivate static let testValue = Self(requestID: "id", servingData: "d") +} + +extension ErrorDetails.ResourceInfo { + fileprivate static let testValue = Self(type: "t", name: "n", errorDescription: "d") +} + +extension ErrorDetails.Help { + fileprivate static let testValue = Self( + links: [ + Link(url: "url", description: "d") + ] + ) +} + +extension ErrorDetails.LocalizedMessage { + fileprivate static let testValue = Self(locale: "l", message: "m") +}