diff --git a/Package.swift b/Package.swift index 962b80ca8..4ed07c0a3 100644 --- a/Package.swift +++ b/Package.swift @@ -32,6 +32,9 @@ let package = Package( .testTarget( name: "OpenAPIKitCompatibilitySuite", dependencies: ["OpenAPIKit", "Yams"]), + .testTarget( + name: "OpenAPIKitErrorReportingTests", + dependencies: ["OpenAPIKit", "Yams"]) ], swiftLanguageVersions: [ .v5 ] ) diff --git a/README.md b/README.md index 231647423..93cb7c765 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,17 @@ let decoder = ... // JSONDecoder() or YAMLDecoder() let openAPIDoc = try decoder.decode(OpenAPI.Document, from: ...) ``` +#### Decoding Errors +You can wrap any error you get back from a decoder in `OpenAPI.Error` to get a friendlier human-readable description from `localizedDescription`. + +```swift +do { + try decoder.docode(OpenAPI.Document, from: ...) +} catch let error { + print(OpenAPI.Error(from: error).localizedDescription) +} +``` + ### Encoding OpenAPI Documents You can encode a JSON OpenAPI document (i.e. using the `JSONEncoder` from the **Foundation** library) or a YAML OpenAPI document (i.e. using the `YAMLEncoder` from the [**Yams**](https://github.com/jpsim/Yams) library) with the following code: diff --git a/Sources/OpenAPIKit/CodableVendorExtendable.swift b/Sources/OpenAPIKit/CodableVendorExtendable.swift index d5bfdd475..61a2bcfbb 100644 --- a/Sources/OpenAPIKit/CodableVendorExtendable.swift +++ b/Sources/OpenAPIKit/CodableVendorExtendable.swift @@ -47,7 +47,6 @@ protocol CodableVendorExtendable: VendorExtendable { enum VendorExtensionDecodingError: Swift.Error { case selfIsArrayNotDict case foundNonStringKeys - case foundExtensionsWithoutXPrefix } extension CodableVendorExtendable { @@ -71,7 +70,11 @@ extension CodableVendorExtendable { } guard extensions.keys.allSatisfy({ $0.lowercased().starts(with: "x-") }) else { - throw VendorExtensionDecodingError.foundExtensionsWithoutXPrefix + throw InconsistencyError( + subjectName: "Vendor Extension", + details: "Found a vendor extension property that does not begin with the required 'x-' prefix", + codingPath: decoder.codingPath + ) } return extensions.mapValues(AnyCodable.init) diff --git a/Sources/OpenAPIKit/Content.swift b/Sources/OpenAPIKit/Content.swift index c9ce34230..5beb09eeb 100644 --- a/Sources/OpenAPIKit/Content.swift +++ b/Sources/OpenAPIKit/Content.swift @@ -132,10 +132,15 @@ extension OpenAPI.Content: Decodable { let container = try decoder.container(keyedBy: CodingKeys.self) guard !(container.contains(.examples) && container.contains(.example)) else { - throw Error.foundBothExampleAndExamples + throw InconsistencyError( + subjectName: "Example and Examples", + details: "Only one of `example` and `examples` is allowed in the Media Type Object (`OpenAPI.Content`).", + codingPath: container.codingPath + ) } schema = try container.decode(Either, JSONSchema>.self, forKey: .schema) + encoding = try container.decodeIfPresent(OrderedDictionary.self, forKey: .encoding) if container.contains(.example) { @@ -149,10 +154,6 @@ extension OpenAPI.Content: Decodable { vendorExtensions = try Self.extensions(from: decoder) } - - public enum Error: Swift.Error { - case foundBothExampleAndExamples - } } extension OpenAPI.Content { diff --git a/Sources/OpenAPIKit/Decoding Errors/DecodingErrorExtensions.swift b/Sources/OpenAPIKit/Decoding Errors/DecodingErrorExtensions.swift new file mode 100644 index 000000000..5c51906c0 --- /dev/null +++ b/Sources/OpenAPIKit/Decoding Errors/DecodingErrorExtensions.swift @@ -0,0 +1,116 @@ +// +// DecodingErrorExtensions.swift +// +// +// Created by Mathew Polzin on 2/26/20. +// + +import Foundation + +internal extension Swift.DecodingError { + var subjectName: String { + let name: String? = { + switch self { + case .keyNotFound(let key, _): + return "\(key.stringValue)" + case .typeMismatch(_, let ctx), .valueNotFound(_, let ctx), .dataCorrupted(let ctx): + return ctx.codingPath.last?.stringValue + @unknown default: + return nil + } + }() + + return name ?? "[unknown object]" + } + + var codingPathWithoutSubject: [CodingKey] { + switch self { + case .keyNotFound(_, let ctx): + return ctx.codingPath + case .typeMismatch(_, let ctx), .valueNotFound(_, let ctx), .dataCorrupted(let ctx): + return ctx.codingPath.count > 0 ? ctx.codingPath.dropLast() : [] + @unknown default: + return [] + } + } + + var codingPath: [CodingKey] { + switch self { + case .keyNotFound(_, let ctx), .typeMismatch(_, let ctx), .valueNotFound(_, let ctx), .dataCorrupted(let ctx): + return ctx.codingPath + @unknown default: + return [] + } + } + + var relativeCodingPathString: String { + return codingPathWithoutSubject.stringValue + } + + var errorCategory: ErrorCategory { + switch self { + case .typeMismatch(let type, _): + return .typeMismatch(expectedTypeName: String(describing: type)) + case .valueNotFound: + return .missing(.value) + case .keyNotFound: + return .missing(.key) + case .dataCorrupted: + return .dataCorrupted(underlying: underlyingError) + @unknown default: + return .dataCorrupted(underlying: underlyingError) + } + } + + var underlyingError: Swift.Error? { + switch self { + case .typeMismatch(_, let ctx), .valueNotFound(_, let ctx), .keyNotFound(_, let ctx), .dataCorrupted(let ctx): + return ctx.underlyingError + @unknown default: + return nil + } + } + + func replacingPath(with codingPath: [CodingKey]) -> Self { + switch self { + case .typeMismatch(let type, let ctx): + return .typeMismatch(type, ctx.replacingPath(with: codingPath)) + case .valueNotFound(let type, let ctx): + return .valueNotFound(type, ctx.replacingPath(with: codingPath)) + case .keyNotFound(let key, let ctx): + return .keyNotFound(key, ctx.replacingPath(with: codingPath)) + case .dataCorrupted(let ctx): + return .dataCorrupted(ctx.replacingPath(with: codingPath)) + @unknown default: + return .dataCorrupted(.init(codingPath: codingPath, debugDescription: "unknown error")) + } + } +} + +internal extension Swift.DecodingError.Context { + func replacingPath(with codingPath: [CodingKey]) -> Self { + return Swift.DecodingError.Context( + codingPath: codingPath, + debugDescription: debugDescription, + underlyingError: underlyingError + ) + } +} + +internal struct DecodingErrorWrapper: OpenAPIError { + let decodingError: Swift.DecodingError + + var subjectName: String { decodingError.subjectName } + + var contextString: String { + let relativeCodingPathString = decodingError.relativeCodingPathString + + return relativeCodingPathString.isEmpty + ? "" + : "in \(relativeCodingPathString)" + } + + var errorCategory: ErrorCategory { decodingError.errorCategory } + + var codingPath: [CodingKey] { decodingError.codingPath } +} diff --git a/Sources/OpenAPIKit/Decoding Errors/DocumentDecodingError.swift b/Sources/OpenAPIKit/Decoding Errors/DocumentDecodingError.swift new file mode 100644 index 000000000..5376ee8f7 --- /dev/null +++ b/Sources/OpenAPIKit/Decoding Errors/DocumentDecodingError.swift @@ -0,0 +1,84 @@ +// +// File.swift +// +// +// Created by Mathew Polzin on 2/23/20. +// + +import Foundation + +extension OpenAPI.Error.Decoding { + public struct Document: OpenAPIError { + public let context: Context + public let codingPath: [CodingKey] + + public enum Context { + case path(Path) + case inconsistency(InconsistencyError) + case generic(Swift.DecodingError) + } + } +} + +extension OpenAPI.Error.Decoding.Document { + public var subjectName: String { + switch context { + case .path(let pathError): + return pathError.subjectName + + case .generic(let decodingError): + return decodingError.subjectName + + case .inconsistency(let error): + return error.subjectName + } + } + + public var contextString: String { + switch context { + case .path(let pathError): + return pathError.contextString + case .generic, .inconsistency: + return relativeCodingPathString.isEmpty + ? "in the root Document object" + : "in Document\(relativeCodingPathString)" + } + } + + public var errorCategory: ErrorCategory { + switch context { + case .path(let pathError): + return pathError.errorCategory + case .generic(let error): + return error.errorCategory + case .inconsistency(let error): + return .inconsistency(details: error.details) + } + } + + internal var relativeCodingPathString: String { + switch context { + case .generic(let decodingError): + return decodingError.relativeCodingPathString + case .inconsistency: + return "" + case .path(let pathError): + return pathError.relativeCodingPathString + } + } + + internal init(_ error: DecodingError) { + context = .generic(error) + codingPath = error.codingPath + } + + internal init(_ error: InconsistencyError) { + context = .inconsistency(error) + codingPath = error.codingPath + } + + internal init(_ error: OpenAPI.Error.Decoding.Path) { + context = .path(error) + codingPath = error.codingPath + } +} diff --git a/Sources/OpenAPIKit/Decoding Errors/InconsistencyError.swift b/Sources/OpenAPIKit/Decoding Errors/InconsistencyError.swift new file mode 100644 index 000000000..5a04c600d --- /dev/null +++ b/Sources/OpenAPIKit/Decoding Errors/InconsistencyError.swift @@ -0,0 +1,19 @@ +// +// InconsistencyError.swift +// +// +// Created by Mathew Polzin on 2/26/20. +// + +import Foundation + +public struct InconsistencyError: Swift.Error, OpenAPIError { + public let subjectName: String + public let details: String + public let codingPath: [CodingKey] + + public var contextString: String { "" } + public var errorCategory: ErrorCategory { .inconsistency(details: details) } + + public var localizedDescription: String { details } +} diff --git a/Sources/OpenAPIKit/Decoding Errors/OpenAPIDecodingErrors.swift b/Sources/OpenAPIKit/Decoding Errors/OpenAPIDecodingErrors.swift new file mode 100644 index 000000000..42cd106dd --- /dev/null +++ b/Sources/OpenAPIKit/Decoding Errors/OpenAPIDecodingErrors.swift @@ -0,0 +1,127 @@ +// +// OpenAPIDecodingErrors.swift +// +// +// Created by Mathew Polzin on 2/23/20. +// + +import Foundation +import Poly + +extension OpenAPI.Error { + public enum Decoding {} +} + +public enum ErrorCategory { + case typeMismatch(expectedTypeName: String) + case typeMismatch2(possibleTypeName1: String, possibleTypeName2: String, details: String) + case missing(KeyValue) + case dataCorrupted(underlying: Swift.Error?) + case inconsistency(details: String) + + public enum KeyValue { + case key + case value + } +} + +public protocol OpenAPIError: Swift.Error { + var subjectName: String { get } + var contextString: String { get } + var errorCategory: ErrorCategory { get } + var codingPath: [CodingKey] { get } +} + +public extension OpenAPIError { + /// Description of error given in the structure: + /// `subject` `context` `error`: `details` + /// + /// A subject, context, and error are all guaranteed. + /// The details are only provided in certain contexts. + var localizedDescription: String { + let subjectString: String = { + switch errorCategory { + case .missing(let kv): + switch kv { + case .key: + return "Expected to find `\(subjectName)` key" + case .value: + return "Expected `\(subjectName)` value" + } + case .typeMismatch(expectedTypeName: _): + if subjectName == "[unknown object]" { + return "Expected value" + } else { + return "Expected `\(subjectName)` value" + } + case .typeMismatch2(possibleTypeName1: let t1, possibleTypeName2: let t2, details: _): + return "Found neither a \(t1) nor a \(t2)" + case .dataCorrupted: + return "Could not parse `\(subjectName)`" + case .inconsistency(details: _): + return "Inconsistency encountered when parsing `\(subjectName)`" + } + }() + + let contextString = self.contextString.isEmpty ? "" : " \(self.contextString)" + + let errorTypeString: String = { + switch errorCategory { + case .typeMismatch(expectedTypeName: let typeName): + return " to be parsable as \(typeName) but it was not" + case .typeMismatch2(possibleTypeName1: _, possibleTypeName2: _, details: let details): + return ". \(details)" + case .missing(let kv): + switch kv { + case .key: + return " but it is missing" + case .value: + return " to be non-null but it is null" + } + case .dataCorrupted(underlying: let error): + return error.map { ":\n\n" + $0.localizedDescription } ?? "" + case .inconsistency(details: let details): + return ": \(details)" + } + }() + + return "\(subjectString)\(contextString)\(errorTypeString)." + } +} + +internal extension Swift.Array where Element == CodingKey { + var stringValue: String { + return self.map { key in + if let intVal = key.intValue { + return "[\(intVal)]" + } + let strVal = key.stringValue + if strVal.contains("/") { + return "['\(strVal)']" + } + return ".\(strVal)" + }.joined() + } + + /// Get a relative coding path. Given a shorter other coding path, this will + /// return the part of this coding path not overlapping with the given path. + /// + /// Given: + /// + /// self = ["a", "b", "c"] + /// other = ["a", "b"] + /// + /// The result will be: + /// + /// ["c"] + /// + func relative(to other: [CodingKey]) -> [CodingKey] { + Array( + self + .enumerated() + .drop { (offset, codingKey) in + other.lazy.enumerated().contains { $0.0 == offset && $0.1.stringValue == codingKey.stringValue } + }.map { $0.1 } + ) + } +} diff --git a/Sources/OpenAPIKit/Decoding Errors/OperationDecodingError.swift b/Sources/OpenAPIKit/Decoding Errors/OperationDecodingError.swift new file mode 100644 index 000000000..7626a87e9 --- /dev/null +++ b/Sources/OpenAPIKit/Decoding Errors/OperationDecodingError.swift @@ -0,0 +1,149 @@ +// +// OperationDecodingError.swift +// +// +// Created by Mathew Polzin on 2/23/20. +// + +import Foundation +import Poly + +extension OpenAPI.Error.Decoding { + public struct Operation: OpenAPIError { + public let endpoint: OpenAPI.HttpVerb + public let context: Context + internal let relativeCodingPath: [CodingKey] + + public enum Context { + case request(Request) + case response(Response) + case inconsistency(InconsistencyError) + case generic(Swift.DecodingError) + case neither(PolyDecodeNoTypesMatchedError) + } + } +} + +extension OpenAPI.Error.Decoding.Operation { + public var subjectName: String { + switch context { + case .request(let error): + return error.subjectName + case .response(let error): + return error.subjectName + case .inconsistency(let error): + return error.subjectName + case .generic(let decodingError): + return decodingError.subjectName + case .neither(let polyError): + return polyError.subjectName + } + } + + public var contextString: String { "" } + + public var errorCategory: ErrorCategory { + switch context { + case .request(let error): + return error.errorCategory + case .response(let error): + return error.errorCategory + case .inconsistency(let error): + return .inconsistency(details: error.details) + case .generic(let decodingError): + return decodingError.errorCategory + case .neither(let polyError): + return polyError.errorCategory + } + } + + public var codingPath: [CodingKey] { + switch context { + case .request(let error): + return error.codingPath + case .response(let error): + return error.codingPath + case .inconsistency(let error): + return error.codingPath + case .generic(let decodingError): + return decodingError.codingPath + case .neither(let polyError): + return polyError.codingPath + } + } + + internal var relativeCodingPathString: String { + relativeCodingPath.stringValue + } + + internal init(_ error: OpenAPI.Error.Decoding.Request) { + var codingPath = error.codingPath.dropFirst(2) + let verb = OpenAPI.HttpVerb(rawValue: codingPath.removeFirst().stringValue.uppercased())! + + endpoint = verb + context = .request(error) + relativeCodingPath = Array(codingPath) + } + + internal init(_ error: OpenAPI.Error.Decoding.Response) { + var codingPath = error.codingPath.dropFirst(2) + let verb = OpenAPI.HttpVerb(rawValue: codingPath.removeFirst().stringValue.uppercased())! + + endpoint = verb + context = .response(error) + relativeCodingPath = Array(codingPath) + } + + internal init(_ error: InconsistencyError) { + var codingPath = error.codingPath.dropFirst(2) + let verb = OpenAPI.HttpVerb(rawValue: codingPath.removeFirst().stringValue.uppercased())! + + endpoint = verb + context = .inconsistency(error) + relativeCodingPath = Array(codingPath) + } + + internal init(_ error: Swift.DecodingError) { + var codingPath = error.codingPathWithoutSubject.dropFirst(2) + let verb = OpenAPI.HttpVerb(rawValue: codingPath.removeFirst().stringValue.uppercased())! + + endpoint = verb + context = .generic(error) + relativeCodingPath = Array(codingPath) + } + + internal init(unwrapping error: Swift.DecodingError) { + if let decodingError = error.underlyingError as? Swift.DecodingError { + self = Self(unwrapping: decodingError) + } else if let responseError = error.underlyingError as? OpenAPI.Error.Decoding.Request { + self = Self(responseError) + } else if let responseError = error.underlyingError as? OpenAPI.Error.Decoding.Response { + self = Self(responseError) + } else if let inconsistencyError = error.underlyingError as? InconsistencyError { + self = Self(inconsistencyError) + } else if let polyError = error.underlyingError as? PolyDecodeNoTypesMatchedError { + self = Self(polyError) + } else { + self = Self(error) + } + } + + internal init(_ polyError: PolyDecodeNoTypesMatchedError) { + if polyError.individualTypeFailures.count == 2 { + if polyError.individualTypeFailures[0].typeString == "$ref" && polyError.individualTypeFailures[1].codingPath(relativeTo: polyError.codingPath).count > 1 { + self = Self(unwrapping: polyError.individualTypeFailures[1].error) + return + } else if polyError.individualTypeFailures[1].typeString == "$ref" && polyError.individualTypeFailures[0].codingPath(relativeTo: polyError.codingPath).count > 0 { + self = Self(unwrapping: polyError.individualTypeFailures[0].error) + return + } + } + + var codingPath = polyError.codingPath.dropFirst(2) + let verb = OpenAPI.HttpVerb(rawValue: codingPath.removeFirst().stringValue.uppercased())! + + endpoint = verb + context = .neither(polyError) + relativeCodingPath = Array(codingPath) + } +} diff --git a/Sources/OpenAPIKit/Decoding Errors/PathDecodingError.swift b/Sources/OpenAPIKit/Decoding Errors/PathDecodingError.swift new file mode 100644 index 000000000..0c08717d3 --- /dev/null +++ b/Sources/OpenAPIKit/Decoding Errors/PathDecodingError.swift @@ -0,0 +1,123 @@ +// +// File.swift +// +// +// Created by Mathew Polzin on 2/23/20. +// + +import Foundation +import Poly + +extension OpenAPI.Error.Decoding { + public struct Path: OpenAPIError { + public let path: OpenAPI.PathComponents + public let context: Context + internal let relativeCodingPath: [CodingKey] + + public enum Context { + case endpoint(Operation) + case generic(Swift.DecodingError) + case neither(PolyDecodeNoTypesMatchedError) + } + } +} + +extension OpenAPI.Error.Decoding.Path { + public var subjectName: String { + switch context { + case .endpoint(let endpointError): + return endpointError.subjectName + case .generic(let decodingError): + return decodingError.subjectName + case .neither(let polyError): + return polyError.subjectName + } + } + + public var contextString: String { + let relativeCodingPath = relativeCodingPathString.isEmpty + ? "" + : "in \(relativeCodingPathString) " + switch context { + case .endpoint(let endpointError): + switch endpointError.context { + case .response(let responseError): + let responseContext = responseError.statusCode.rawValue == "default" + ? "default" + : "status code '\(responseError.statusCode.rawValue)'" + return "\(relativeCodingPath)for the \(responseContext) response of the **\(endpointError.endpoint.rawValue)** endpoint under `\(path.rawValue)`" + case .request: + return "\(relativeCodingPath)for the request body of the **\(endpointError.endpoint.rawValue)** endpoint under `\(path.rawValue)`" + case .generic, .inconsistency, .neither: + return "\(relativeCodingPath)for the **\(endpointError.endpoint.rawValue)** endpoint under `\(path.rawValue)`" + } + case .generic, .neither: + return "\(relativeCodingPath)under the `\(path.rawValue)` path" + } + } + + public var errorCategory: ErrorCategory { + switch context { + case .endpoint(let endpointError): + return endpointError.errorCategory + case .generic(let decodingError): + return decodingError.errorCategory + case .neither(let polyError): + return polyError.errorCategory + } + } + + public var codingPath: [CodingKey] { + switch context { + case .endpoint(let endpointError): + return endpointError.codingPath + case .generic(let decodingError): + return decodingError.codingPath + case .neither(let polyError): + return polyError.codingPath + } + } + + internal var relativeCodingPathString: String { + switch context { + case .endpoint(let endpointError): + switch endpointError.context { + case .response(let responseError): + return responseError.relativeCodingPathString + case .request(let requestError): + return requestError.relativeCodingPathString + case .generic, .inconsistency, .neither: + return endpointError.relativeCodingPathString + } + case .generic, .neither: + return relativeCodingPath.stringValue + } + } + + internal init(_ error: DecodingError) { + var codingPath = error.codingPathWithoutSubject.dropFirst() + let route = OpenAPI.PathComponents(rawValue: codingPath.removeFirst().stringValue) + + path = route + context = .generic(error) + relativeCodingPath = Array(codingPath) + } + + internal init(_ polyError: PolyDecodeNoTypesMatchedError) { + var codingPath = polyError.codingPath.dropFirst() + let route = OpenAPI.PathComponents(rawValue: codingPath.removeFirst().stringValue) + + path = route + context = .neither(polyError) + relativeCodingPath = Array(codingPath) + } + + internal init(_ error: OpenAPI.Error.Decoding.Operation) { + var codingPath = error.codingPath.dropFirst() + let route = OpenAPI.PathComponents(rawValue: codingPath.removeFirst().stringValue) + + path = route + context = .endpoint(error) + relativeCodingPath = Array(codingPath) + } +} diff --git a/Sources/OpenAPIKit/Decoding Errors/PolyDecodeNoTypesMatchedErrorExtensions.swift b/Sources/OpenAPIKit/Decoding Errors/PolyDecodeNoTypesMatchedErrorExtensions.swift new file mode 100644 index 000000000..3cee900a4 --- /dev/null +++ b/Sources/OpenAPIKit/Decoding Errors/PolyDecodeNoTypesMatchedErrorExtensions.swift @@ -0,0 +1,105 @@ +// +// PolyDecodeNoTypesMatchedErrorExtensions.swift +// +// +// Created by Mathew Polzin on 2/26/20. +// + +import Foundation +import Poly + +internal extension PolyDecodeNoTypesMatchedError { + var subjectName: String { + return codingPath.last?.stringValue ?? "[unknown object]" + } + + var codingPathWithoutSubject: [CodingKey] { + return codingPath.count > 0 ? codingPath.dropLast() : [] + } + + var relativeCodingPathString: String { + return codingPathWithoutSubject.stringValue + } + + var errorCategory: ErrorCategory { + // + // We get away with assuming the choice is between either of 2 + // types currently. Likely we will not need to ever worry about + // a Poly3 or beyond when parsing OpenAPI documents (with the notable + // exception of parsing OpenAPI schemas) + // + guard + individualTypeFailures.count == 2, + let f1 = individualTypeFailures.first, + let f2 = individualTypeFailures.dropFirst().first + else { + return .dataCorrupted(underlying: self) + } + + func isRefKeyNotFoundError(_ failure: IndividualFailure) -> Bool { + guard case .keyNotFound(let key, _) = failure.error else { + return false + } + return key.stringValue == "$ref" + } + + // We want to omit details if the problem is a missing '$ref' key. + // If the intention was to write a reference, this error will be obvious. + // If the intention was not to use a reference, this error will be superfluous. + let error1 = isRefKeyNotFoundError(f1) + ? nil + : OpenAPI.Error(from: f1.error.replacingPath(with: f1.codingPath(relativeTo: codingPath))).localizedDescription + let error2 = isRefKeyNotFoundError(f2) + ? nil + : OpenAPI.Error(from: f2.error.replacingPath(with: f2.codingPath(relativeTo: codingPath))).localizedDescription + + let details1 = error1 + .map { "\(String(describing: f1.type)) could not be decoded because:\n\($0)" } + .map { "\n\n" + $0 } + ?? "" + let details2 = error2 + .map { "\(String(describing: f2.type)) could not be decoded because:\n\($0)" } + .map { "\n\n" + $0 } + ?? "" + + let details = details1 + details2 + + return .typeMismatch2( + possibleTypeName1: f1.typeString, + possibleTypeName2: f2.typeString, + details: details + ) + } +} + +internal extension PolyDecodeNoTypesMatchedError.IndividualFailure { + func codingPath(relativeTo other: [CodingKey]) -> [CodingKey] { + fullCodingPath.relative(to: other) + } + + var typeString: String { + if (type as? Reference.Type) != nil { + return "$ref" + } + return String(describing: type) + } + + /// This retrieves the coding path of any underlying error + /// which will be at least as long if not longer than that of + /// the `IndividualFailure` + var fullCodingPath: [CodingKey] { + if let err = error.underlyingError as? DecodingError { + return err.codingPath + } + if let err = error.underlyingError as? InconsistencyError { + return err.codingPath + } + if let err = error.underlyingError as? PolyDecodeNoTypesMatchedError { + return err.codingPath + } + if let err = error.underlyingError as? OpenAPIError { + return err.codingPath + } + return error.codingPath + } +} diff --git a/Sources/OpenAPIKit/Decoding Errors/RequestDecodingError.swift b/Sources/OpenAPIKit/Decoding Errors/RequestDecodingError.swift new file mode 100644 index 000000000..1f51f3fca --- /dev/null +++ b/Sources/OpenAPIKit/Decoding Errors/RequestDecodingError.swift @@ -0,0 +1,107 @@ +// +// RequestDecodingError.swift +// +// +// Created by Mathew Polzin on 2/28/20. +// + +import Foundation +import Poly + +extension OpenAPI.Error.Decoding { + public struct Request: OpenAPIError { + public let context: Context + internal let relativeCodingPath: [CodingKey] + + public enum Context { + case inconsistency(InconsistencyError) + case generic(Swift.DecodingError) + case neither(PolyDecodeNoTypesMatchedError) + } + } +} + +extension OpenAPI.Error.Decoding.Request { + public var subjectName: String { + switch context { + case .inconsistency(let error): + return error.subjectName + case .generic(let decodingError): + return decodingError.subjectName + case .neither(let polyError): + return polyError.subjectName + } + } + + public var contextString: String { "" } + + public var errorCategory: ErrorCategory { + switch context { + case .inconsistency(let error): + return .inconsistency(details: error.details) + case .generic(let decodingError): + return decodingError.errorCategory + case .neither(let polyError): + return polyError.errorCategory + } + } + + public var codingPath: [CodingKey] { + switch context { + case .inconsistency(let error): + return error.codingPath + case .generic(let decodingError): + return decodingError.codingPath + case .neither(let polyError): + return polyError.codingPath + } + } + + internal var relativeCodingPathString: String { + relativeCodingPath.stringValue + } + + internal static func relativePath(from path: [CodingKey]) -> [CodingKey] { + guard let responsesIdx = path.firstIndex(where: { $0.stringValue == "requestBody" }) else { + return path + } + return Array(path.dropFirst(responsesIdx.advanced(by: 1))) + } + + internal init(_ error: InconsistencyError) { + context = .inconsistency(error) + relativeCodingPath = Self.relativePath(from: error.codingPath) + } + + internal init(_ error: Swift.DecodingError) { + context = .generic(error) + relativeCodingPath = Self.relativePath(from: error.codingPathWithoutSubject) + } + + internal init(unwrapping error: Swift.DecodingError) { + if let decodingError = error.underlyingError as? Swift.DecodingError { + self = Self(unwrapping: decodingError) + } else if let inconsistencyError = error.underlyingError as? InconsistencyError { + self = Self(inconsistencyError) + } else if let polyError = error.underlyingError as? PolyDecodeNoTypesMatchedError { + self = Self(polyError) + } else { + self = Self(error) + } + } + + internal init(_ polyError: PolyDecodeNoTypesMatchedError) { + if polyError.individualTypeFailures.count == 2 { + if polyError.individualTypeFailures[0].typeString == "$ref" && polyError.individualTypeFailures[1].codingPath(relativeTo: polyError.codingPath).count > 1 { + self = Self(unwrapping: polyError.individualTypeFailures[1].error) + return + } else if polyError.individualTypeFailures[1].typeString == "$ref" && polyError.individualTypeFailures[0].codingPath(relativeTo: polyError.codingPath).count > 1 { + self = Self(unwrapping: polyError.individualTypeFailures[0].error) + return + } + } + context = .neither(polyError) + relativeCodingPath = Self.relativePath(from: polyError.codingPath) + } +} + diff --git a/Sources/OpenAPIKit/Decoding Errors/ResponseDecodingError.swift b/Sources/OpenAPIKit/Decoding Errors/ResponseDecodingError.swift new file mode 100644 index 000000000..60ed7743d --- /dev/null +++ b/Sources/OpenAPIKit/Decoding Errors/ResponseDecodingError.swift @@ -0,0 +1,120 @@ +// +// ResponseDecodingError.swift +// +// +// Created by Mathew Polzin on 2/28/20. +// + +import Foundation +import Poly + +extension OpenAPI.Error.Decoding { + public struct Response: OpenAPIError { + public let statusCode: OpenAPI.Response.StatusCode + public let context: Context + internal let relativeCodingPath: [CodingKey] + + public enum Context { + case inconsistency(InconsistencyError) + case generic(Swift.DecodingError) + case neither(PolyDecodeNoTypesMatchedError) + } + } +} + +extension OpenAPI.Error.Decoding.Response { + public var subjectName: String { + switch context { + case .inconsistency(let error): + return error.subjectName + case .generic(let decodingError): + return decodingError.subjectName + case .neither(let polyError): + return polyError.subjectName + } + } + + public var contextString: String { statusCode.rawValue } + + public var errorCategory: ErrorCategory { + switch context { + case .inconsistency(let error): + return .inconsistency(details: error.details) + case .generic(let decodingError): + return decodingError.errorCategory + case .neither(let polyError): + return polyError.errorCategory + } + } + + public var codingPath: [CodingKey] { + switch context { + case .inconsistency(let error): + return error.codingPath + case .generic(let decodingError): + return decodingError.codingPath + case .neither(let polyError): + return polyError.codingPath + } + } + + internal var relativeCodingPathString: String { + relativeCodingPath.stringValue + } + + internal static func relativePath(from path: [CodingKey]) -> [CodingKey] { + guard let responsesIdx = path.firstIndex(where: { $0.stringValue == "responses" }) else { + return path + } + return Array(path.dropFirst(responsesIdx.advanced(by: 1))) + } + + internal init(_ error: InconsistencyError) { + var codingPath = Self.relativePath(from: error.codingPath) + let code = codingPath.removeFirst().stringValue.lowercased() + + statusCode = OpenAPI.Response.StatusCode(rawValue: code)! + context = .inconsistency(error) + relativeCodingPath = Array(codingPath) + } + + internal init(_ error: Swift.DecodingError) { + var codingPath = Self.relativePath(from: error.codingPathWithoutSubject) + let code = codingPath.removeFirst().stringValue.lowercased() + + statusCode = OpenAPI.Response.StatusCode(rawValue: code)! + context = .generic(error) + relativeCodingPath = Array(codingPath) + } + + internal init(unwrapping error: Swift.DecodingError) { + if let decodingError = error.underlyingError as? Swift.DecodingError { + self = Self(unwrapping: decodingError) + } else if let inconsistencyError = error.underlyingError as? InconsistencyError { + self = Self(inconsistencyError) + } else if let polyError = error.underlyingError as? PolyDecodeNoTypesMatchedError { + self = Self(polyError) + } else { + self = Self(error) + } + } + + internal init(_ polyError: PolyDecodeNoTypesMatchedError) { + if polyError.individualTypeFailures.count == 2 { + if polyError.individualTypeFailures[0].typeString == "$ref" && polyError.individualTypeFailures[1].codingPath(relativeTo: polyError.codingPath).count > 1 { + self = Self(unwrapping: polyError.individualTypeFailures[1].error) + return + } else if polyError.individualTypeFailures[1].typeString == "$ref" && polyError.individualTypeFailures[0].codingPath(relativeTo: polyError.codingPath).count > 1 { + self = Self(unwrapping: polyError.individualTypeFailures[0].error) + return + } + } + + var codingPath = Self.relativePath(from: polyError.codingPath) + let code = codingPath.removeFirst().stringValue.lowercased() + + statusCode = OpenAPI.Response.StatusCode(rawValue: code)! + context = .neither(polyError) + relativeCodingPath = Array(codingPath) + } +} diff --git a/Sources/OpenAPIKit/Document.swift b/Sources/OpenAPIKit/Document.swift index b050a2a12..44e9416e4 100644 --- a/Sources/OpenAPIKit/Document.swift +++ b/Sources/OpenAPIKit/Document.swift @@ -54,6 +54,7 @@ extension OpenAPI.Document { case v3_0_0 = "3.0.0" case v3_0_1 = "3.0.1" case v3_0_2 = "3.0.2" + case v3_0_3 = "3.0.3" } } @@ -106,24 +107,35 @@ extension OpenAPI.Document: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - openAPIVersion = try container.decode(OpenAPI.Document.Version.self, forKey: .openAPIVersion) + do { + openAPIVersion = try container.decode(OpenAPI.Document.Version.self, forKey: .openAPIVersion) - info = try container.decode(OpenAPI.Document.Info.self, forKey: .info) + info = try container.decode(OpenAPI.Document.Info.self, forKey: .info) - servers = try container.decodeIfPresent([OpenAPI.Server].self, forKey: .servers) ?? [] + servers = try container.decodeIfPresent([OpenAPI.Server].self, forKey: .servers) ?? [] - paths = try container.decode(OpenAPI.PathItem.Map.self, forKey: .paths) + paths = try container.decode(OpenAPI.PathItem.Map.self, forKey: .paths) - let components = try container.decodeIfPresent(OpenAPI.Components.self, forKey: .components) ?? .noComponents - self.components = components + let components = try container.decodeIfPresent(OpenAPI.Components.self, forKey: .components) ?? .noComponents + self.components = components - // A real mess here because we've got an Array of non-string-keyed - // Dictionaries. - security = try decodeSecurityRequirements(from: container, forKey: .security, given: components) ?? [] + // A real mess here because we've got an Array of non-string-keyed + // Dictionaries. + security = try decodeSecurityRequirements(from: container, forKey: .security, given: components) ?? [] + + tags = try container.decodeIfPresent([OpenAPI.Tag].self, forKey: .tags) - tags = try container.decodeIfPresent([OpenAPI.Tag].self, forKey: .tags) + externalDocs = try container.decodeIfPresent(OpenAPI.ExternalDoc.self, forKey: .externalDocs) + } catch let error as OpenAPI.Error.Decoding.Path { - externalDocs = try container.decodeIfPresent(OpenAPI.ExternalDoc.self, forKey: .externalDocs) + throw OpenAPI.Error.Decoding.Document(error) + } catch let error as InconsistencyError { + + throw OpenAPI.Error.Decoding.Document(error) + } catch let error as DecodingError { + + throw OpenAPI.Error.Decoding.Document(error) + } } } @@ -166,7 +178,11 @@ internal func decodeSecurityRequirements(from container: return (try? components.contains(ref)) ?? false } guard securityKeysAndValues.map({ $0.key }).allSatisfy(foundInComponents) else { - throw DecodingError.dataCorruptedError(forKey: key, in: container, debugDescription: "Each key found in a Security Requirement dictionary must refer to a Security Scheme present in the Components dictionary.") + throw InconsistencyError( + subjectName: key.stringValue, + details: "Each key found in a Security Requirement dictionary must refer to a Security Scheme present in the Components dictionary", + codingPath: container.codingPath + [key] + ) } } diff --git a/Sources/OpenAPIKit/Header.swift b/Sources/OpenAPIKit/Header.swift index 1ba85eaef..65b013e3b 100644 --- a/Sources/OpenAPIKit/Header.swift +++ b/Sources/OpenAPIKit/Header.swift @@ -178,7 +178,11 @@ extension OpenAPI.Header: Decodable { case (nil, let schema?): schemaOrContent = .init(schema) default: - throw OpenAPI.DecodingError.unsatisfied(requirement: "A single path parameter must specify one but not both 'content' and 'schema'.", codingPath: decoder.codingPath) + throw InconsistencyError( + subjectName: "Header", + details: "A single path parameter must specify one but not both `content` and `schema`", + codingPath: decoder.codingPath + ) } description = try container.decodeIfPresent(String.self, forKey: .description) diff --git a/Sources/OpenAPIKit/JSON Utility/AnyJSONCaseIterable.swift b/Sources/OpenAPIKit/JSON Utility/AnyJSONCaseIterable.swift index d2a3d88b1..8add82580 100644 --- a/Sources/OpenAPIKit/JSON Utility/AnyJSONCaseIterable.swift +++ b/Sources/OpenAPIKit/JSON Utility/AnyJSONCaseIterable.swift @@ -41,7 +41,7 @@ public extension AnyJSONCaseIterable { public extension AnyJSONCaseIterable where Self: CaseIterable { static func caseIterableOpenAPISchemaGuess(using encoder: JSONEncoder) throws -> JSONSchema { guard let first = allCases.first else { - throw OpenAPI.CodableError.exampleNotCodable + throw OpenAPI.EncodableError.exampleNotCodable } return try OpenAPIKit.genericOpenAPISchemaGuess(for: first, using: encoder) } @@ -67,7 +67,7 @@ fileprivate func allCases(from input: [T], using encoder: JSONEnco // upon initialization. guard let arrayOfCodables = try JSONSerialization.jsonObject(with: encoder.encode(input), options: []) as? [Any] else { - throw OpenAPI.CodableError.allCasesArrayNotCodable + throw OpenAPI.EncodableError.allCasesArrayNotCodable } return arrayOfCodables.map(AnyCodable.init) } diff --git a/Sources/OpenAPIKit/JSON Utility/JSONReference.swift b/Sources/OpenAPIKit/JSON Utility/JSONReference.swift index 6874be310..bbf528711 100644 --- a/Sources/OpenAPIKit/JSON Utility/JSONReference.swift +++ b/Sources/OpenAPIKit/JSON Utility/JSONReference.swift @@ -24,6 +24,8 @@ public protocol ReferenceDict: AbstractReferenceDict { associatedtype Value } +internal protocol Reference {} + /// A RefDict knows what to call itself (Name) and where to /// look for itself (Root) and it stores a dictionary of /// JSONReferenceObjects (some of which might be other references). @@ -51,7 +53,7 @@ public struct RefDict: Equatable, Hashable, CustomStringConvertible { +public enum JSONReference: Equatable, Hashable, CustomStringConvertible, Reference { case `internal`(Local) case external(FileReference, Local?) diff --git a/Sources/OpenAPIKit/OpenAPI.swift b/Sources/OpenAPIKit/OpenAPI.swift index d74f2e020..f1867020c 100644 --- a/Sources/OpenAPIKit/OpenAPI.swift +++ b/Sources/OpenAPIKit/OpenAPI.swift @@ -11,7 +11,7 @@ import Foundation public enum OpenAPI {} extension OpenAPI { - public enum CodableError: Swift.Error, Equatable { + public enum EncodableError: Swift.Error, Equatable { case allCasesArrayNotCodable case exampleNotCodable case primitiveGuessFailed @@ -23,23 +23,34 @@ extension OpenAPI { case unknownNodeType(Any.Type) } - public enum DecodingError: Swift.Error, CustomDebugStringConvertible { - case missingKeyword(underlyingError: String?, codingPath: [CodingKey]) - case foundNeither(option1: String, option2: String, codingPath: [CodingKey], notOption1Because: Swift.Error?, notOption2Because: Swift.Error?) - case unsatisfied(requirement: String, codingPath: [CodingKey]) - case unknown(codingPath: [CodingKey]) - - public var debugDescription: String { - switch self { - case .missingKeyword(underlyingError: let err, codingPath: let path): - return "When parsing Open API JSON, an expected keyword was missing. Carefully read your JSON keys to make sure all keys required by OpenAPI are spelled correctly. Underlying error: " - + (err ?? "") + ". PATH: \(path)" - case .foundNeither(option1: let option1, option2: let option2, codingPath: let path, notOption1Because: let reason1, notOption2Because: let reason2): - return "Found neither of two expected things. Expected either \(option1) or \(option2). \n\nPATH: \(path).\n\n Could not have been \(option1) because: \(String(describing:reason1)).\n\n Could not have been \(option2) because: \(String(describing:reason2))" - case .unsatisfied(requirement: let requirement, codingPath: let path): - return "Unsatisfied OpenAPI requirement: \(requirement). PATH: \(path)" - case .unknown(codingPath: let path): - return "An unknown error has occurred. That sucks. PATH: \(path)" + public struct Error: Swift.Error { + + public let localizedDescription: String + public let codingPath: [CodingKey] + public let underlyingError: Swift.Error + + public var codingPathString: String { codingPath.stringValue } + + public init(from underlyingError: Swift.Error) { + self.underlyingError = underlyingError + if let openAPIError = underlyingError as? OpenAPIError { + localizedDescription = openAPIError.localizedDescription + codingPath = openAPIError.codingPath + + } else if let decodingError = underlyingError as? Swift.DecodingError { + + if let openAPIError = decodingError.underlyingError as? OpenAPIError { + localizedDescription = openAPIError.localizedDescription + codingPath = openAPIError.codingPath + } else { + let wrappedError = DecodingErrorWrapper(decodingError: decodingError) + localizedDescription = wrappedError.localizedDescription + codingPath = wrappedError.codingPath + } + + } else { + localizedDescription = underlyingError.localizedDescription + codingPath = [] } } } diff --git a/Sources/OpenAPIKit/Path Item/Operation.swift b/Sources/OpenAPIKit/Path Item/Operation.swift index d1233c172..5e27a7c7d 100644 --- a/Sources/OpenAPIKit/Path Item/Operation.swift +++ b/Sources/OpenAPIKit/Path Item/Operation.swift @@ -133,29 +133,46 @@ extension OpenAPI.PathItem.Operation: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - tags = try container.decodeIfPresent([String].self, forKey: .tags) + do { + tags = try container.decodeIfPresent([String].self, forKey: .tags) - summary = try container.decodeIfPresent(String.self, forKey: .summary) + summary = try container.decodeIfPresent(String.self, forKey: .summary) - description = try container.decodeIfPresent(String.self, forKey: .description) + description = try container.decodeIfPresent(String.self, forKey: .description) - externalDocs = try container.decodeIfPresent(OpenAPI.ExternalDoc.self, forKey: .externalDocs) + externalDocs = try container.decodeIfPresent(OpenAPI.ExternalDoc.self, forKey: .externalDocs) - operationId = try container.decodeIfPresent(String.self, forKey: .operationId) + operationId = try container.decodeIfPresent(String.self, forKey: .operationId) - parameters = try container.decodeIfPresent(OpenAPI.PathItem.Parameter.Array.self, forKey: .parameters) ?? [] + parameters = try container.decodeIfPresent(OpenAPI.PathItem.Parameter.Array.self, forKey: .parameters) ?? [] - requestBody = try container.decodeIfPresent(Either, OpenAPI.Request>.self, forKey: .requestBody) + requestBody = try container.decodeIfPresent(Either, OpenAPI.Request>.self, forKey: .requestBody) - responses = try container.decode(OpenAPI.Response.Map.self, forKey: .responses) + responses = try container.decode(OpenAPI.Response.Map.self, forKey: .responses) - deprecated = try container.decodeIfPresent(Bool.self, forKey: .deprecated) ?? false + deprecated = try container.decodeIfPresent(Bool.self, forKey: .deprecated) ?? false - // TODO: would be ideal to validate against components from here, but not - // sure off the top of my head the best way to go about that other than - // perhaps storing a copy of components in the userInfo for the decoder. - security = try decodeSecurityRequirements(from: container, forKey: .security, given: nil) + // TODO: would be ideal to validate against components from here, but not + // sure off the top of my head the best way to go about that other than + // perhaps storing a copy of components in the userInfo for the decoder. + security = try decodeSecurityRequirements(from: container, forKey: .security, given: nil) - servers = try container.decodeIfPresent([OpenAPI.Server].self, forKey: .servers) + servers = try container.decodeIfPresent([OpenAPI.Server].self, forKey: .servers) + } catch let error as OpenAPI.Error.Decoding.Request { + + throw OpenAPI.Error.Decoding.Operation(error) + } catch let error as OpenAPI.Error.Decoding.Response { + + throw OpenAPI.Error.Decoding.Operation(error) + } catch let error as DecodingError { + + throw OpenAPI.Error.Decoding.Operation(unwrapping: error) + } catch let error as InconsistencyError { + + throw OpenAPI.Error.Decoding.Operation(error) + } catch let error as PolyDecodeNoTypesMatchedError { + + throw OpenAPI.Error.Decoding.Operation(error) + } } } diff --git a/Sources/OpenAPIKit/Path Item/Parameter.swift b/Sources/OpenAPIKit/Path Item/Parameter.swift index 07f6a76b9..8a0b03734 100644 --- a/Sources/OpenAPIKit/Path Item/Parameter.swift +++ b/Sources/OpenAPIKit/Path Item/Parameter.swift @@ -217,7 +217,8 @@ extension OpenAPI.PathItem.Parameter: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - name = try container.decode(String.self, forKey: .name) + let name = try container.decode(String.self, forKey: .name) + self.name = name let required = try container.decodeIfPresent(Bool.self, forKey: .required) ?? false let location = try container.decode(LocationString.self, forKey: .parameterLocation) @@ -230,7 +231,11 @@ extension OpenAPI.PathItem.Parameter: Decodable { parameterLocation = .header(required: required) case .path: if !required { - throw OpenAPI.DecodingError.unsatisfied(requirement: "positional path parameters must be explicitly set to required.", codingPath: decoder.codingPath) + throw InconsistencyError( + subjectName: name, + details: "positional path parameters must be explicitly set to required", + codingPath: decoder.codingPath + ) } parameterLocation = .path case .cookie: @@ -252,7 +257,11 @@ extension OpenAPI.PathItem.Parameter: Decodable { case (nil, let schema?): schemaOrContent = .init(schema) default: - throw OpenAPI.DecodingError.unsatisfied(requirement: "A single path parameter must specify one but not both 'content' and 'schema'.", codingPath: decoder.codingPath) + throw InconsistencyError( + subjectName: name, + details: "A single path parameter must specify one but not both `content` and `schema`", + codingPath: decoder.codingPath + ) } description = try container.decodeIfPresent(String.self, forKey: .description) diff --git a/Sources/OpenAPIKit/Path Item/PathItem.swift b/Sources/OpenAPIKit/Path Item/PathItem.swift index 684a5c48a..389b4fe85 100644 --- a/Sources/OpenAPIKit/Path Item/PathItem.swift +++ b/Sources/OpenAPIKit/Path Item/PathItem.swift @@ -235,21 +235,32 @@ extension OpenAPI.PathItem: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - summary = try container.decodeIfPresent(String.self, forKey: .summary) + do { + summary = try container.decodeIfPresent(String.self, forKey: .summary) - description = try container.decodeIfPresent(String.self, forKey: .description) + description = try container.decodeIfPresent(String.self, forKey: .description) - servers = try container.decodeIfPresent([OpenAPI.Server].self, forKey: .servers) + servers = try container.decodeIfPresent([OpenAPI.Server].self, forKey: .servers) - parameters = try container.decodeIfPresent(Parameter.Array.self, forKey: .parameters) ?? [] + parameters = try container.decodeIfPresent(Parameter.Array.self, forKey: .parameters) ?? [] - get = try container.decodeIfPresent(Operation.self, forKey: .get) - put = try container.decodeIfPresent(Operation.self, forKey: .put) - post = try container.decodeIfPresent(Operation.self, forKey: .post) - delete = try container.decodeIfPresent(Operation.self, forKey: .delete) - options = try container.decodeIfPresent(Operation.self, forKey: .options) - head = try container.decodeIfPresent(Operation.self, forKey: .head) - patch = try container.decodeIfPresent(Operation.self, forKey: .patch) - trace = try container.decodeIfPresent(Operation.self, forKey: .trace) + get = try container.decodeIfPresent(Operation.self, forKey: .get) + put = try container.decodeIfPresent(Operation.self, forKey: .put) + post = try container.decodeIfPresent(Operation.self, forKey: .post) + delete = try container.decodeIfPresent(Operation.self, forKey: .delete) + options = try container.decodeIfPresent(Operation.self, forKey: .options) + head = try container.decodeIfPresent(Operation.self, forKey: .head) + patch = try container.decodeIfPresent(Operation.self, forKey: .patch) + trace = try container.decodeIfPresent(Operation.self, forKey: .trace) + } catch let error as DecodingError { + + throw OpenAPI.Error.Decoding.Path(error) + } catch let error as OpenAPI.Error.Decoding.Operation { + + throw OpenAPI.Error.Decoding.Path(error) + } catch let error as PolyDecodeNoTypesMatchedError { + + throw OpenAPI.Error.Decoding.Path(error) + } } } diff --git a/Sources/OpenAPIKit/Request.swift b/Sources/OpenAPIKit/Request.swift index 2e163aef2..102b7f64f 100644 --- a/Sources/OpenAPIKit/Request.swift +++ b/Sources/OpenAPIKit/Request.swift @@ -6,6 +6,7 @@ // import Foundation +import Poly extension OpenAPI { public struct Request: Equatable { @@ -49,10 +50,21 @@ extension OpenAPI.Request: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - description = try container.decodeIfPresent(String.self, forKey: .description) + do { + description = try container.decodeIfPresent(String.self, forKey: .description) - content = try container.decode(OpenAPI.Content.Map.self, forKey: .content) + content = try container.decode(OpenAPI.Content.Map.self, forKey: .content) - required = try container.decodeIfPresent(Bool.self, forKey: .required) ?? false + required = try container.decodeIfPresent(Bool.self, forKey: .required) ?? false + } catch let error as InconsistencyError { + + throw OpenAPI.Error.Decoding.Request(error) + } catch let error as Swift.DecodingError { + + throw OpenAPI.Error.Decoding.Request(error) + } catch let error as PolyDecodeNoTypesMatchedError { + + throw OpenAPI.Error.Decoding.Request(error) + } } } diff --git a/Sources/OpenAPIKit/Response.swift b/Sources/OpenAPIKit/Response.swift index 9557a6a70..dba47de18 100644 --- a/Sources/OpenAPIKit/Response.swift +++ b/Sources/OpenAPIKit/Response.swift @@ -120,11 +120,23 @@ extension OpenAPI.Response: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - description = try container.decode(String.self, forKey: .description) + do { + description = try container.decode(String.self, forKey: .description) - headers = try container.decodeIfPresent(OpenAPI.Header.Map.self, forKey: .headers) + headers = try container.decodeIfPresent(OpenAPI.Header.Map.self, forKey: .headers) - content = try container.decodeIfPresent(OpenAPI.Content.Map.self, forKey: .content) ?? [:] + content = try container.decodeIfPresent(OpenAPI.Content.Map.self, forKey: .content) ?? [:] + + } catch let error as InconsistencyError { + + throw OpenAPI.Error.Decoding.Response(error) + } catch let error as PolyDecodeNoTypesMatchedError { + + throw OpenAPI.Error.Decoding.Response(error) + } catch let error as DecodingError { + + throw OpenAPI.Error.Decoding.Response(error) + } } } @@ -148,10 +160,15 @@ extension OpenAPI.Response.StatusCode: Encodable { extension OpenAPI.Response.StatusCode: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() - let val = OpenAPI.Response.StatusCode(rawValue: try container.decode(String.self)) + let strVal = try container.decode(String.self) + let val = OpenAPI.Response.StatusCode(rawValue: strVal) guard let value = val else { - throw OpenAPI.DecodingError.unknown(codingPath: decoder.codingPath) + throw InconsistencyError( + subjectName: "status code", + details: "Expected the status code to be either an Int or 'default' but found \(strVal) instead", + codingPath: decoder.codingPath + ) } self = value diff --git a/Sources/OpenAPIKit/Schema Object/SchemaObject.swift b/Sources/OpenAPIKit/Schema Object/SchemaObject.swift index 8cec449be..d0728be85 100644 --- a/Sources/OpenAPIKit/Schema Object/SchemaObject.swift +++ b/Sources/OpenAPIKit/Schema Object/SchemaObject.swift @@ -289,7 +289,7 @@ extension JSONSchema { case .string(let context, let contextB): return .string(context.with(example: codableExample, using: encoder), contextB) case .all, .one, .any, .not, .reference, .undefined: - throw OpenAPI.CodableError.exampleNotSupported("examples not supported for `.allOf`, `.oneOf`, `.anyOf`, `.not` or for JSON references ($ref).") + throw OpenAPI.EncodableError.exampleNotSupported("examples not supported for `.allOf`, `.oneOf`, `.anyOf`, `.not` or for JSON references ($ref).") } } } @@ -801,22 +801,17 @@ extension JSONSchema: Decodable { let hintContainer = try decoder.container(keyedBy: HintCodingKeys.self) + let containerCount = hintContainer.allKeys.count // This means there is no type specified which is hopefully only found for truly // undefined schemas (i.e. "{}"); there has been known to be a "description" even // without a "type" specified. - let containerCount = hintContainer.allKeys.count if containerCount == 0 || (containerCount == 1 && hintContainer.contains(.description)) { let description = try hintContainer.decodeIfPresent(String.self, forKey: .description) self = .undefined(description: description) return } - let type: JSONType - do { - type = try hintContainer.decode(JSONType.self, forKey: .type) - } catch { - throw OpenAPI.DecodingError.missingKeyword(underlyingError: "A JSON Schema object is expected to be `oneOf`, `anyOf`, `allOf`, `not`, or have a `type` key.", codingPath: decoder.codingPath) - } + let type = try hintContainer.decode(JSONType.self, forKey: .type) switch type { case .boolean: diff --git a/Sources/OpenAPIKit/Schema Object/SchemaObjectContext.swift b/Sources/OpenAPIKit/Schema Object/SchemaObjectContext.swift index d67f30a29..c367e967f 100644 --- a/Sources/OpenAPIKit/Schema Object/SchemaObjectContext.swift +++ b/Sources/OpenAPIKit/Schema Object/SchemaObjectContext.swift @@ -424,7 +424,11 @@ extension JSONSchema.Context: Decodable { case (true, false): permissions = .readOnly case (true, true): - throw OpenAPI.DecodingError.unsatisfied(requirement: "Either readOnly or writeOnly can be true but not both.", codingPath: decoder.codingPath) + throw InconsistencyError( + subjectName: "JSONSchema", + details: "Either `readOnly` or `writeOnly` can be true but not both", + codingPath: decoder.codingPath + ) } deprecated = try container.decodeIfPresent(Bool.self, forKey: .deprecated) ?? false diff --git a/Tests/OpenAPIKitErrorReportingTests/DocumentErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/DocumentErrorTests.swift new file mode 100644 index 000000000..d20394010 --- /dev/null +++ b/Tests/OpenAPIKitErrorReportingTests/DocumentErrorTests.swift @@ -0,0 +1,188 @@ +// +// DocumentErrorTests.swift +// +// +// Created by Mathew Polzin on 2/23/20. +// + +import Foundation +import XCTest +import OpenAPIKit +import Yams + +final class DocumentErrorTests: XCTestCase { + + func test_missingOpenAPIVersion() { + let documentYML = +""" +info: + title: test + version: 1.0 +paths: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Expected to find `openapi` key in the root Document object but it is missing.") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, []) + } + } + + func test_wrongTypesOpenAPIVersion() { + let documentYML = +""" +openapi: null +info: + title: test + version: 1.0 +paths: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Could not parse `openapi` in the root Document object.") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "openapi" + ]) + } + + let documentYML2 = +""" +openapi: [] +info: + title: test + version: 1.0 +paths: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML2)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Expected `openapi` value in the root Document object to be parsable as Scalar but it was not.") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "openapi" + ]) + } + + let documentYML3 = + """ +openapi: {} +info: + title: test + version: 1.0 +paths: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML3)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Expected `openapi` value in the root Document object to be parsable as Scalar but it was not.") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "openapi" + ]) + } + } + + func test_missingInfo() { + let documentYML = +""" +openapi: "3.0.0" +paths: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Expected to find `info` key in the root Document object but it is missing.") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, []) + } + } + + func test_wrongTypesInfo() { + let documentYML = +""" +openapi: "3.0.0" +info: null +paths: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Expected `info` value in the root Document object to be parsable as Mapping but it was not.") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "info" + ]) + } + + let documentYML2 = +""" +openapi: "3.0.0" +info: [] +paths: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML2)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Expected `info` value in the root Document object to be parsable as Mapping but it was not.") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "info" + ]) + } + } + + func test_missingTitleInsideInfo() { + let documentYML = +""" +openapi: "3.0.0" +info: {} +paths: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Expected to find `title` key in Document.info but it is missing.") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "info" + ]) + } + } + + func test_missingNameInsideSecondTag() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: {} +tags: + - name: hi + - description: missing + - name: hello +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Expected to find `name` key in Document.tags[1] but it is missing.") + XCTAssertEqual(openAPIError.codingPath.map {$0.stringValue }, [ + "tags", + "Index 1" + ]) + } + } +} diff --git a/Tests/OpenAPIKitErrorReportingTests/Helpers.swift b/Tests/OpenAPIKitErrorReportingTests/Helpers.swift new file mode 100644 index 000000000..f067827ec --- /dev/null +++ b/Tests/OpenAPIKitErrorReportingTests/Helpers.swift @@ -0,0 +1,11 @@ +// +// Helpers.swift +// +// +// Created by Mathew Polzin on 2/23/20. +// + +import Foundation +import Yams + +let testDecoder = YAMLDecoder() diff --git a/Tests/OpenAPIKitErrorReportingTests/JSONReferenceErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/JSONReferenceErrorTests.swift new file mode 100644 index 000000000..c3baee2b2 --- /dev/null +++ b/Tests/OpenAPIKitErrorReportingTests/JSONReferenceErrorTests.swift @@ -0,0 +1,43 @@ +// +// JSONReferenceErrorTests.swift +// +// +// Created by Mathew Polzin on 2/27/20. +// + +import Foundation +import XCTest +import OpenAPIKit +import Yams + +final class JSONReferenceErrorTests: XCTestCase { + func test_referenceFailedToParse() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: + /hello/world: + get: + responses: {} + parameters: + - $ref: 'not a reference' +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a Parameter in .parameters[0] for the **GET** endpoint under `/hello/world`. \n\nJSONReference could not be decoded because:\nCould not parse `$ref`.\n\nParameter could not be decoded because:\nExpected to find `name` key but it is missing..") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "paths", + "/hello/world", + "get", + "parameters", + "Index 0" + ]) + } + } +} diff --git a/Tests/OpenAPIKitErrorReportingTests/OperationErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/OperationErrorTests.swift new file mode 100644 index 000000000..11d68afbc --- /dev/null +++ b/Tests/OpenAPIKitErrorReportingTests/OperationErrorTests.swift @@ -0,0 +1,132 @@ +// +// OperationErrorTests.swift +// +// +// Created by Mathew Polzin on 2/23/20. +// + +import Foundation +import XCTest +import OpenAPIKit +import Yams + +final class OperationErrorTests: XCTestCase { + func test_missingResponses() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: + /hello/world: + get: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Expected to find `responses` key for the **GET** endpoint under `/hello/world` but it is missing.") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "paths", + "/hello/world", + "get" + ]) + } + } + + func test_wrongTypeTags() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: + /hello/world: + get: + tags: 1234 + responses: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Expected `tags` value for the **GET** endpoint under `/hello/world` to be parsable as Sequence but it was not.") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "paths", + "/hello/world", + "get", + "tags" + ]) + } + } + + func test_missingUrlInServer() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: + /hello/world: + get: + servers: + - url: http://google.com + description: google + - description: missing a url + responses: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Expected to find `url` key in .servers[1] for the **GET** endpoint under `/hello/world` but it is missing.") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "paths", + "/hello/world", + "get", + "servers", + "Index 1" + ]) + } + } +} + +extension OperationErrorTests { + func test_missingResponseFromSSWGPitchConversation() { + let documentYML = +""" +openapi: 3.0.0 +info: + title: API + version: 1.0.0 +paths: + /all-items: + summary: Get all items + get: + responses: + "200": + description: All items + /one-item: + get: + summary: Get one item +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Expected to find `responses` key for the **GET** endpoint under `/one-item` but it is missing.") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "paths", + "/one-item", + "get" + ]) + } + } +} diff --git a/Tests/OpenAPIKitErrorReportingTests/PathsErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/PathsErrorTests.swift new file mode 100644 index 000000000..f9932483a --- /dev/null +++ b/Tests/OpenAPIKitErrorReportingTests/PathsErrorTests.swift @@ -0,0 +1,192 @@ +// +// PathsErrorTests.swift +// +// +// Created by Mathew Polzin on 2/23/20. +// + +import Foundation +import XCTest +import OpenAPIKit +import Yams + +final class PathsErrorTests: XCTestCase { + func test_missingPaths() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Expected to find `paths` key in the root Document object but it is missing.") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, []) + } + } + + func test_wrongTypeParameter() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: + /hello/world: + summary: hello + parameters: + - name: world + in: header + schema: + type: string + - invalid: hi +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a Parameter in .parameters[1] under the `/hello/world` path. \n\nParameter could not be decoded because:\nExpected to find `name` key but it is missing.." + ) + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, ["paths", "/hello/world", "parameters", "Index 1"]) + } + + let documentYML2 = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: + /hello/world: + summary: hello + parameters: + - name: world + in: header + schema: + type: string + - [] +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML2)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a Parameter in .parameters[1] under the `/hello/world` path. \n\nJSONReference could not be decoded because:\nExpected value to be parsable as Mapping but it was not.\n\nParameter could not be decoded because:\nExpected value to be parsable as Mapping but it was not.." + ) + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, ["paths", "/hello/world", "parameters", "Index 1"]) + } + } + + func test_optionalPositionalPathParam() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: + /hello/world: + summary: hello + parameters: + - name: world + in: path + schema: + type: string +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a Parameter in .parameters[0] under the `/hello/world` path. \n\nParameter could not be decoded because:\nInconsistency encountered when parsing `world`: positional path parameters must be explicitly set to required.." + ) + XCTAssertEqual( + openAPIError.codingPath.map { $0.stringValue }, + [ + "paths", + "/hello/world", + "parameters", + "Index 0" + ] + ) + } + } + + func test_noContentOrSchemaParam() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: + /hello/world: + summary: hello + parameters: + - name: world + in: query +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a Parameter in .parameters[0] under the `/hello/world` path. \n\nParameter could not be decoded because:\nInconsistency encountered when parsing `world`: A single path parameter must specify one but not both `content` and `schema`.." + ) + XCTAssertEqual( + openAPIError.codingPath.map { $0.stringValue }, + [ + "paths", + "/hello/world", + "parameters", + "Index 0" + ] + ) + } + } + + func test_bothContentAndSchemaParam() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: + /hello/world: + summary: hello + parameters: + - name: world + in: query + schema: + type: string + content: + application/json: + schema: + type: string +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a Parameter in .parameters[0] under the `/hello/world` path. \n\nParameter could not be decoded because:\nInconsistency encountered when parsing `world`: A single path parameter must specify one but not both `content` and `schema`.." + ) + XCTAssertEqual( + openAPIError.codingPath.map { $0.stringValue }, + [ + "paths", + "/hello/world", + "parameters", + "Index 0" + ] + ) + } + } +} diff --git a/Tests/OpenAPIKitErrorReportingTests/RequestContentMapErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/RequestContentMapErrorTests.swift new file mode 100644 index 000000000..dcb0bfc49 --- /dev/null +++ b/Tests/OpenAPIKitErrorReportingTests/RequestContentMapErrorTests.swift @@ -0,0 +1,154 @@ +// +// RequestContentMapErrorTests.swift +// +// +// Created by Mathew Polzin on 2/24/20. +// + +import Foundation +import XCTest +import OpenAPIKit +import Yams + +final class RequestContentMapErrorTests: XCTestCase { + /** + + The "wrong type content map key" does not fail right now because OrderedDictionary does not + throw for keys it cannot decode. This is a shortcoming of OrderedDictionary that should be solved + in a future release. + + */ + +// func test_wrongTypeContentMapKey() { +// let documentYML = +//""" +//openapi: "3.0.0" +//info: +// title: test +// version: 1.0 +//paths: +// /hello/world: +// get: +// requestBody: +// content: +// blablabla: +// schema: +// $ref: #/components/schemas/one +// responses: {} +//""" +// +// XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in +// +// let openAPIError = OpenAPI.Error(from: error) +// +// XCTAssertEqual(openAPIError.localizedDescription, "Expected to find either a $ref or a Request in .requestBody for the **GET** endpoint under `/hello/world` but found neither. \n\nJSONReference could not be decoded because:\nExpected `requestBody` value in .paths./hello/world.get to be parsable as Mapping but it was not.\n\nRequest could not be decoded because:\nExpected `requestBody` value in .paths./hello/world.get to be parsable as Mapping but it was not..") +// XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ +// "paths", +// "/hello/world", +// "get", +// "requestBody" +// ]) +// } +// } + + func test_missingSchemaContent() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: + /hello/world: + get: + requestBody: + content: + application/json: {} + responses: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Expected to find `schema` key in .content['application/json'] for the request body of the **GET** endpoint under `/hello/world` but it is missing.") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "paths", + "/hello/world", + "get", + "requestBody", + "content", + "application/json" + ]) + XCTAssertEqual(openAPIError.codingPathString, ".paths['/hello/world'].get.requestBody.content['application/json']") + } + } + + func test_wrongTypeContentValue() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: + /hello/world: + get: + requestBody: + content: + application/json: hello + responses: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Expected `application/json` value in .content for the request body of the **GET** endpoint under `/hello/world` to be parsable as Mapping but it was not.") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "paths", + "/hello/world", + "get", + "requestBody", + "content", + "application/json" + ]) + } + } + + func test_incorrectVendorExtension() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: + /hello/world: + get: + requestBody: + content: + application/json: + schema: + type: string + x-hello: world + invalid: extension + responses: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Inconsistency encountered when parsing `Vendor Extension` in .content['application/json'] for the request body of the **GET** endpoint under `/hello/world`: Found a vendor extension property that does not begin with the required 'x-' prefix.") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "paths", + "/hello/world", + "get", + "requestBody", + "content", + "application/json" + ]) + } + } +} diff --git a/Tests/OpenAPIKitErrorReportingTests/RequestContentSchemaErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/RequestContentSchemaErrorTests.swift new file mode 100644 index 000000000..352d9c033 --- /dev/null +++ b/Tests/OpenAPIKitErrorReportingTests/RequestContentSchemaErrorTests.swift @@ -0,0 +1,49 @@ +// +// RequestContentSchemaErrorTests.swift +// +// +// Created by Mathew Polzin on 2/25/20. +// + +import Foundation +import XCTest +import OpenAPIKit +import Yams + +final class RequestContentSchemaErrorTests: XCTestCase { + func test_wrongTypeContentSchemaTypeProperty() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: + /hello/world: + get: + requestBody: + content: + application/json: + schema: + type: + hi: there + responses: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a JSONSchema in .content['application/json'].schema for the request body of the **GET** endpoint under `/hello/world`. \n\nJSONSchema could not be decoded because:\nExpected `type` value to be parsable as Scalar but it was not..") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "paths", + "/hello/world", + "get", + "requestBody", + "content", + "application/json", + "schema" + ]) + } + } +} diff --git a/Tests/OpenAPIKitErrorReportingTests/RequestErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/RequestErrorTests.swift new file mode 100644 index 000000000..f017be90f --- /dev/null +++ b/Tests/OpenAPIKitErrorReportingTests/RequestErrorTests.swift @@ -0,0 +1,100 @@ +// +// RequestErrorTests.swift +// +// +// Created by Mathew Polzin on 2/24/20. +// + +import Foundation +import XCTest +import OpenAPIKit +import Yams + +final class RequestErrorTests: XCTestCase { + func test_wrongTypeRequest() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: + /hello/world: + get: + requestBody: hello + responses: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a Request in .requestBody for the **GET** endpoint under `/hello/world`. \n\nJSONReference could not be decoded because:\nExpected value to be parsable as Mapping but it was not.\n\nRequest could not be decoded because:\nExpected value to be parsable as Mapping but it was not..") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "paths", + "/hello/world", + "get", + "requestBody" + ]) + } + } + + func test_missingContentMap() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: + /hello/world: + get: + requestBody: + description: incomplete + responses: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a Request in .requestBody for the **GET** endpoint under `/hello/world`. \n\nRequest could not be decoded because:\nExpected to find `content` key but it is missing..") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "paths", + "/hello/world", + "get", + "requestBody" + ]) + } + } + + func test_wrongTypeContentMap() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: + /hello/world: + get: + requestBody: + description: incomplete + content: [] + responses: {} +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a Request in .requestBody for the **GET** endpoint under `/hello/world`. \n\nRequest could not be decoded because:\nExpected `content` value to be parsable as Mapping but it was not..") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "paths", + "/hello/world", + "get", + "requestBody" + ]) + } + } +} diff --git a/Tests/OpenAPIKitErrorReportingTests/ResponseErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/ResponseErrorTests.swift new file mode 100644 index 000000000..8a98ae4cf --- /dev/null +++ b/Tests/OpenAPIKitErrorReportingTests/ResponseErrorTests.swift @@ -0,0 +1,91 @@ +// +// ResponseErrorTests.swift +// +// +// Created by Mathew Polzin on 2/25/20. +// + +import Foundation +import XCTest +import OpenAPIKit +import Yams + +final class ResponseErrorTests: XCTestCase { + func test_headerWithContentAndSchema() { + let documentYML = +""" +openapi: "3.0.0" +info: + title: test + version: 1.0 +paths: + /hello/world: + get: + responses: + '200': + description: hello + content: {} + headers: + hi: + schema: + type: string + content: + application/json: + schema: + type: string +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Found neither a $ref nor a Header in .headers.hi for the status code '200' response of the **GET** endpoint under `/hello/world`. \n\nHeader could not be decoded because:\nInconsistency encountered when parsing `Header`: A single path parameter must specify one but not both `content` and `schema`..") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "paths", + "/hello/world", + "get", + "responses", + "200", + "headers", + "hi" + ]) + } + } + + /* + + The following will start failing once OrderedDictionary starts throwing an + error when it fails to decode a key like it should be. + + */ + +// func test_badStatusCode() { +// let documentYML = +//""" +//openapi: "3.0.0" +//info: +// title: test +// version: 1.0 +//paths: +// /hello/world: +// get: +// responses: +// 'twohundred': +// description: hello +// content: {} +//""" +// +// XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in +// +// let openAPIError = OpenAPI.Error(from: error) +// +// XCTAssertEqual(openAPIError.localizedDescription, "Expected to find either a $ref or a Header in .responses.200.headers.hi for the **GET** endpoint under `/hello/world` but found neither. \n\nHeader could not be decoded because:\nInconsistency encountered when parsing `Header`: A single path parameter must specify one but not both `content` and `schema`..") +// XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ +// "paths", +// "/hello/world", +// "get", +// "responses" +// ]) +// } +// } +} diff --git a/Tests/OpenAPIKitErrorReportingTests/SecuritySchemeErrorTests.swift b/Tests/OpenAPIKitErrorReportingTests/SecuritySchemeErrorTests.swift new file mode 100644 index 000000000..cb551bfa1 --- /dev/null +++ b/Tests/OpenAPIKitErrorReportingTests/SecuritySchemeErrorTests.swift @@ -0,0 +1,38 @@ +// +// SecuritySchemeErrorTests.swift +// +// +// Created by Mathew Polzin on 2/23/20. +// + +import Foundation +import XCTest +import OpenAPIKit +import Yams + +final class SecuritySchemeErrorTests: XCTestCase { + func test_missingSecuritySchemeError() { + // missing as-in not found in the Components Object + let documentYML = +""" +openapi: 3.0.0 +info: + title: test + version: 1.0 +paths: {} +components: {} +security: + - missing: [] +""" + + XCTAssertThrowsError(try testDecoder.decode(OpenAPI.Document.self, from: documentYML)) { error in + + let openAPIError = OpenAPI.Error(from: error) + + XCTAssertEqual(openAPIError.localizedDescription, "Inconsistency encountered when parsing `security` in the root Document object: Each key found in a Security Requirement dictionary must refer to a Security Scheme present in the Components dictionary.") + XCTAssertEqual(openAPIError.codingPath.map { $0.stringValue }, [ + "security" + ]) + } + } +} diff --git a/Tests/OpenAPIKitTests/DocumentTests.swift b/Tests/OpenAPIKitTests/DocumentTests.swift index 3914952f5..7b4c2b002 100644 --- a/Tests/OpenAPIKitTests/DocumentTests.swift +++ b/Tests/OpenAPIKitTests/DocumentTests.swift @@ -69,36 +69,6 @@ final class DocumentTests: XCTestCase { XCTAssertNoThrow(try JSONDecoder().decode(OpenAPI.Document.self, from: docData)) } - - func test_missingSecuritySchemeError() { - let docData = -""" -{ - "openapi": "3.0.0", - "info": { - "title": "test", - "version": "1.0" - }, - "paths": {}, - "components": {}, - "security": [ - { - "missing": [] - } - ] -} -""".data(using: .utf8)! - - XCTAssertThrowsError(try JSONDecoder().decode(OpenAPI.Document.self, from: docData)) { err in - XCTAssertTrue(err is DecodingError) - guard let decodingError = err as? DecodingError, - case .dataCorrupted(let context) = decodingError else { - XCTFail("Expected data corrupted decoding error") - return - } - assertJSONEquivalent(context.debugDescription, "Each key found in a Security Requirement dictionary must refer to a Security Scheme present in the Components dictionary.") - } - } } // MARK: - Codable diff --git a/Tests/OpenAPIKitTests/JSON Utility/GenericOpenAPISchemaTests.swift b/Tests/OpenAPIKitTests/JSON Utility/GenericOpenAPISchemaTests.swift index 3a6126f77..b6952e839 100644 --- a/Tests/OpenAPIKitTests/JSON Utility/GenericOpenAPISchemaTests.swift +++ b/Tests/OpenAPIKitTests/JSON Utility/GenericOpenAPISchemaTests.swift @@ -244,7 +244,7 @@ final class GenericOpenAPISchemaTests: XCTestCase { XCTAssertEqual(try AllowedValues.StringEnum.caseIterableOpenAPISchemaGuess(using: JSONEncoder()), .string) XCTAssertThrowsError(try CaselessEnum.caseIterableOpenAPISchemaGuess(using: JSONEncoder())) { err in - XCTAssertEqual(err as? OpenAPI.CodableError, .exampleNotCodable) + XCTAssertEqual(err as? OpenAPI.EncodableError, .exampleNotCodable) } } diff --git a/Tests/OpenAPIKitTests/VendorExtendableTests.swift b/Tests/OpenAPIKitTests/VendorExtendableTests.swift index 9f5c9706d..aaedf8398 100644 --- a/Tests/OpenAPIKitTests/VendorExtendableTests.swift +++ b/Tests/OpenAPIKitTests/VendorExtendableTests.swift @@ -75,7 +75,7 @@ final class VendorExtendableTests: XCTestCase { """.data(using: .utf8)! XCTAssertThrowsError(try JSONDecoder().decode(TestStruct.self, from: data)) { error in - XCTAssert(error as? VendorExtensionDecodingError == VendorExtensionDecodingError.foundExtensionsWithoutXPrefix) + XCTAssertNotNil(error as? InconsistencyError) } } }