Skip to content

Commit

Permalink
[Runtime] Include partial errors in oneOf/anyOf decoding errors (#66)
Browse files Browse the repository at this point in the history
[Runtime] Include partial errors in oneOf/anyOf decoding errors

### Motivation

The runtime changes to address apple/swift-openapi-generator#275.

This makes debugging of decoding of oneOf/anyOf much easier, as the individual errors aren't dropped on the floor anymore.

### Modifications

Added SPI that allows the generated code to collect and report partial errors when a oneOf/anyOf fails to decode (that includes trying multiple subschemas, which themselves emit errors when they're not the right match).

### Result

Easier debugging of oneOf/anyOf decoding issues.

### Test Plan

Tested manually as part of the generator changes, we don't generally test exact error strings.


Reviewed by: simonjbeaumont

Builds:
     ✔︎ pull request validation (5.10) - Build finished. 
     ✔︎ pull request validation (5.8) - Build finished. 
     ✔︎ pull request validation (5.9) - Build finished. 
     ✔︎ pull request validation (docc test) - Build finished. 
     ✔︎ pull request validation (integration test) - Build finished. 
     ✔︎ pull request validation (nightly) - Build finished. 
     ✔︎ pull request validation (soundness) - Build finished. 
     ✖︎ pull request validation (api breakage) - Build finished. 

#66
  • Loading branch information
czechboy0 committed Oct 30, 2023
1 parent 333d73a commit a51b3bd
Show file tree
Hide file tree
Showing 3 changed files with 150 additions and 13 deletions.
76 changes: 67 additions & 9 deletions Sources/OpenAPIRuntime/Conversion/ErrorExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,19 @@ extension DecodingError {
/// occurred.
/// - codingPath: The coding path to the decoder that attempted to decode
/// the type.
/// - errors: The errors encountered when decoding individual cases.
/// - Returns: A decoding error.
static func failedToDecodeAnySchema(
type: Any.Type,
codingPath: [any CodingKey]
codingPath: [any CodingKey],
errors: [any Error]
) -> Self {
DecodingError.valueNotFound(
type,
DecodingError.Context.init(
codingPath: codingPath,
debugDescription: "The anyOf structure did not decode into any child schema."
debugDescription: "The anyOf structure did not decode into any child schema.",
underlyingError: MultiError(errors: errors)
)
)
}
Expand All @@ -43,24 +46,47 @@ extension DecodingError {
/// occurred.
/// - codingPath: The coding path to the decoder that attempted to decode
/// the type.
/// - errors: The errors encountered when decoding individual cases.
/// - Returns: A decoding error.
@_spi(Generated)
public static func failedToDecodeOneOfSchema(
type: Any.Type,
codingPath: [any CodingKey]
codingPath: [any CodingKey],
errors: [any Error]
) -> Self {
DecodingError.valueNotFound(
type,
DecodingError.Context.init(
codingPath: codingPath,
debugDescription: "The oneOf structure did not decode into any child schema."
debugDescription: "The oneOf structure did not decode into any child schema.",
underlyingError: MultiError(errors: errors)
)
)
}
}

@_spi(Generated)
extension DecodingError {
/// Returns a decoding error used by the oneOf decoder when
/// the discriminator property contains an unknown schema name.
/// - Parameters:
/// - discriminatorKey: The discriminator coding key.
/// - discriminatorValue: The unknown value of the discriminator.
/// - codingPath: The coding path to the decoder that attempted to decode
/// the type, with the discriminator value as the last component.
/// - Returns: A decoding error.
@_spi(Generated)
public static func unknownOneOfDiscriminator(
discriminatorKey: any CodingKey,
discriminatorValue: String,
codingPath: [any CodingKey]
) -> Self {
return DecodingError.keyNotFound(
discriminatorKey,
DecodingError.Context.init(
codingPath: codingPath,
debugDescription:
"The oneOf structure does not contain the provided discriminator value '\(discriminatorValue)'."
)
)
}

/// Verifies that the anyOf decoder successfully decoded at least one
/// child schema, and throws an error otherwise.
Expand All @@ -70,17 +96,49 @@ extension DecodingError {
/// occurred.
/// - codingPath: The coding path to the decoder that attempted to decode
/// the type.
/// - errors: The errors encountered when decoding individual cases.
/// - Throws: An error of type `DecodingError.failedToDecodeAnySchema` if none of the child schemas were successfully decoded.
@_spi(Generated)
public static func verifyAtLeastOneSchemaIsNotNil(
_ values: [Any?],
type: Any.Type,
codingPath: [any CodingKey]
codingPath: [any CodingKey],
errors: [any Error]
) throws {
guard values.contains(where: { $0 != nil }) else {
throw DecodingError.failedToDecodeAnySchema(
type: type,
codingPath: codingPath
codingPath: codingPath,
errors: errors
)
}
}
}

/// A wrapper of multiple errors, for example collected during a parallelized
/// operation from the individual subtasks.
struct MultiError: Swift.Error, LocalizedError, CustomStringConvertible {

/// The multiple underlying errors.
var errors: [any Error]

var description: String {
let combinedDescription =
errors
.map { error in
guard let error = error as? (any PrettyStringConvertible) else {
return error.localizedDescription
}
return error.prettyDescription
}
.enumerated()
.map { ($0.offset + 1, $0.element) }
.map { "Error \($0.0): [\($0.1)]" }
.joined(separator: ", ")
return "MultiError (contains \(errors.count) error\(errors.count == 1 ? "" : "s")): \(combinedDescription)"
}

var errorDescription: String? {
description
}
}
71 changes: 71 additions & 0 deletions Sources/OpenAPIRuntime/Deprecated/Deprecated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,74 @@ extension Converter {
}
}
}

extension DecodingError {
/// Returns a decoding error used by the oneOf decoder when not a single
/// child schema decodes the received payload.
/// - Parameters:
/// - type: The type representing the oneOf schema in which the decoding
/// occurred.
/// - codingPath: The coding path to the decoder that attempted to decode
/// the type.
/// - Returns: A decoding error.
@_spi(Generated)
@available(*, deprecated)
public static func failedToDecodeOneOfSchema(
type: Any.Type,
codingPath: [any CodingKey]
) -> Self {
DecodingError.valueNotFound(
type,
DecodingError.Context.init(
codingPath: codingPath,
debugDescription: "The oneOf structure did not decode into any child schema."
)
)
}

/// Returns a decoding error used by the anyOf decoder when not a single
/// child schema decodes the received payload.
/// - Parameters:
/// - type: The type representing the anyOf schema in which the decoding
/// occurred.
/// - codingPath: The coding path to the decoder that attempted to decode
/// the type.
/// - Returns: A decoding error.
@available(*, deprecated)
static func failedToDecodeAnySchema(
type: Any.Type,
codingPath: [any CodingKey]
) -> Self {
DecodingError.valueNotFound(
type,
DecodingError.Context.init(
codingPath: codingPath,
debugDescription: "The anyOf structure did not decode into any child schema."
)
)
}

/// Verifies that the anyOf decoder successfully decoded at least one
/// child schema, and throws an error otherwise.
/// - Parameters:
/// - values: An array of optional values to check.
/// - type: The type representing the anyOf schema in which the decoding
/// occurred.
/// - codingPath: The coding path to the decoder that attempted to decode
/// the type.
/// - Throws: An error of type `DecodingError.failedToDecodeAnySchema` if none of the child schemas were successfully decoded.
@_spi(Generated)
@available(*, deprecated)
public static func verifyAtLeastOneSchemaIsNotNil(
_ values: [Any?],
type: Any.Type,
codingPath: [any CodingKey]
) throws {
guard values.contains(where: { $0 != nil }) else {
throw DecodingError.failedToDecodeAnySchema(
type: type,
codingPath: codingPath
)
}
}
}
16 changes: 12 additions & 4 deletions Tests/OpenAPIRuntimeTests/URICoder/Test_URICodingRoundtrip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,22 +50,30 @@ final class Test_URICodingRoundtrip: Test_Runtime {
self.value3 = value3
}
init(from decoder: any Decoder) throws {
var errors: [any Error] = []
do {
let container = try decoder.singleValueContainer()
value1 = try? container.decode(Foundation.Date.self)
value1 = try container.decode(Foundation.Date.self)
} catch {
errors.append(error)
}
do {
let container = try decoder.singleValueContainer()
value2 = try? container.decode(SimpleEnum.self)
value2 = try container.decode(SimpleEnum.self)
} catch {
errors.append(error)
}
do {
let container = try decoder.singleValueContainer()
value3 = try? container.decode(TrivialStruct.self)
value3 = try container.decode(TrivialStruct.self)
} catch {
errors.append(error)
}
try DecodingError.verifyAtLeastOneSchemaIsNotNil(
[value1, value2, value3],
type: Self.self,
codingPath: decoder.codingPath
codingPath: decoder.codingPath,
errors: errors
)
}
func encode(to encoder: any Encoder) throws {
Expand Down

0 comments on commit a51b3bd

Please sign in to comment.