diff --git a/Sources/GraphQL/SwiftUtilities/DidYouMean.swift b/Sources/GraphQL/SwiftUtilities/DidYouMean.swift new file mode 100644 index 00000000..063b88c8 --- /dev/null +++ b/Sources/GraphQL/SwiftUtilities/DidYouMean.swift @@ -0,0 +1,16 @@ +private let MAX_SUGGESTIONS = 5 + +func didYouMean(_ submessage: String? = nil, suggestions: [String]) -> String { + guard !suggestions.isEmpty else { + return "" + } + + var message = " Did you mean " + if let submessage = submessage { + message.append("\(submessage) ") + } + + let suggestionList = suggestions[0 ... min(suggestions.count - 1, MAX_SUGGESTIONS - 1)] + .map { "\"\($0)\"" }.orList() + return message + "\(suggestionList)?" +} diff --git a/Sources/GraphQL/SwiftUtilities/FormatList.swift b/Sources/GraphQL/SwiftUtilities/FormatList.swift new file mode 100644 index 00000000..a8215ac1 --- /dev/null +++ b/Sources/GraphQL/SwiftUtilities/FormatList.swift @@ -0,0 +1,27 @@ +extension Collection where Element == String, Index == Int { + /// Given ["A", "B", "C"] return "A, B, or C". + func orList() -> String { + return formatList("or") + } + + /// Given ["A", "B", "C"] return "A, B, and C". + func andList() -> String { + return formatList("and") + } + + private func formatList(_ conjunction: String) -> String { + switch count { + case 0: + return "" + case 1: + return self[0] + case 2: + return joined(separator: " \(conjunction) ") + default: + let allButLast = self[0 ... count - 2] + let lastItem = self[count - 1] + + return allButLast.joined(separator: ", ") + ", \(conjunction) \(lastItem)" + } + } +} diff --git a/Sources/GraphQL/SwiftUtilities/QuotedOrList.swift b/Sources/GraphQL/SwiftUtilities/QuotedOrList.swift deleted file mode 100644 index c68ec016..00000000 --- a/Sources/GraphQL/SwiftUtilities/QuotedOrList.swift +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Given ["A", "B", "C"] return "\"A\", \"B\", or \"C\"". - */ -func quotedOrList(items: [String]) -> String { - let maxLength = min(5, items.count) - let selected = items[0 ..< maxLength] - - return selected.map { "\"" + $0 + "\"" }.enumerated().reduce("") { list, quoted in - if selected.count == 1 { - return quoted.element - } - - let or = quoted.offset == 0 ? "" : (quoted.offset == selected.count - 1 ? " or " : ", ") - return list + or + quoted.element - } -} diff --git a/Sources/GraphQL/Type/Definition.swift b/Sources/GraphQL/Type/Definition.swift index f7ff8ada..b20f6fe4 100644 --- a/Sources/GraphQL/Type/Definition.swift +++ b/Sources/GraphQL/Type/Definition.swift @@ -1012,23 +1012,53 @@ public final class GraphQLEnumType { } public func serialize(value: Any) throws -> Map { - return try valueLookup[map(from: value)].map { .string($0.name) } ?? .null + let mapValue = try map(from: value) + guard let enumValue = valueLookup[mapValue] else { + throw GraphQLError( + message: "Enum '\(name)' cannot represent value '\(mapValue)'." + ) + } + return .string(enumValue.name) } public func parseValue(value: Map) throws -> Map { - if case let .string(value) = value { - return nameLookup[value]?.value ?? .null + guard let valueStr = value.string else { + throw GraphQLError( + message: "Enum '\(name)' cannot represent non-string value '\(value)'." + + didYouMeanEnumValue(unknownValueStr: value.description) + ) } - - return .null + guard let enumValue = nameLookup[valueStr] else { + throw GraphQLError( + message: "Value '\(valueStr)' does not exist in '\(name)' enum." + + didYouMeanEnumValue(unknownValueStr: valueStr) + ) + } + return enumValue.value } - public func parseLiteral(valueAST: Value) -> Map { - if let enumValue = valueAST as? EnumValue { - return nameLookup[enumValue.value]?.value ?? .null + public func parseLiteral(valueAST: Value) throws -> Map { + guard let enumNode = valueAST as? EnumValue else { + throw GraphQLError( + message: "Enum '\(name)' cannot represent non-enum value '\(valueAST)'." + + didYouMeanEnumValue(unknownValueStr: "\(valueAST)"), + nodes: [valueAST] + ) } + guard let enumValue = nameLookup[enumNode.value] else { + throw GraphQLError( + message: "Value '\(enumNode)' does not exist in '\(name)' enum." + + didYouMeanEnumValue(unknownValueStr: enumNode.value), + nodes: [valueAST] + ) + } + return enumValue.value + } - return .null + private func didYouMeanEnumValue(unknownValueStr: String) -> String { + let allNames = values.map { $0.name } + let suggestedValues = suggestionList(input: unknownValueStr, options: allNames) + return didYouMean("the enum value", suggestions: suggestedValues) } } diff --git a/Sources/GraphQL/Validation/Rules/FieldsOnCorrectTypeRule.swift b/Sources/GraphQL/Validation/Rules/FieldsOnCorrectTypeRule.swift index cbe276cb..5328c31e 100644 --- a/Sources/GraphQL/Validation/Rules/FieldsOnCorrectTypeRule.swift +++ b/Sources/GraphQL/Validation/Rules/FieldsOnCorrectTypeRule.swift @@ -7,11 +7,9 @@ func undefinedFieldMessage( var message = "Cannot query field \"\(fieldName)\" on type \"\(type)\"." if !suggestedTypeNames.isEmpty { - let suggestions = quotedOrList(items: suggestedTypeNames) - message += " Did you mean to use an inline fragment on \(suggestions)?" + message += didYouMean("to use an inline fragment on", suggestions: suggestedTypeNames) } else if !suggestedFieldNames.isEmpty { - let suggestions = quotedOrList(items: suggestedFieldNames) - message += " Did you mean \(suggestions)?" + message += didYouMean(suggestions: suggestedFieldNames) } return message diff --git a/Sources/GraphQL/Validation/Rules/KnownArgumentNamesRule.swift b/Sources/GraphQL/Validation/Rules/KnownArgumentNamesRule.swift index e7c065af..f390287e 100644 --- a/Sources/GraphQL/Validation/Rules/KnownArgumentNamesRule.swift +++ b/Sources/GraphQL/Validation/Rules/KnownArgumentNamesRule.swift @@ -10,8 +10,7 @@ func undefinedArgumentMessage( "Field \"\(fieldName)\" on type \"\(type)\" does not have argument \"\(argumentName)\"." if !suggestedArgumentNames.isEmpty { - let suggestions = quotedOrList(items: suggestedArgumentNames) - message += " Did you mean \(suggestions)?" + message += didYouMean(suggestions: suggestedArgumentNames) } return message diff --git a/Sources/GraphQL/Validation/Rules/ProvidedNonNullArgumentsRule.swift b/Sources/GraphQL/Validation/Rules/ProvidedNonNullArgumentsRule.swift index 0d20526d..7490adda 100644 --- a/Sources/GraphQL/Validation/Rules/ProvidedNonNullArgumentsRule.swift +++ b/Sources/GraphQL/Validation/Rules/ProvidedNonNullArgumentsRule.swift @@ -5,7 +5,7 @@ func missingArgumentsMessage( type: String, missingArguments: [String] ) -> String { - let arguments = quotedOrList(items: missingArguments) + let arguments = missingArguments.andList() return "Field \"\(fieldName)\" on type \"\(type)\" is missing required arguments \(arguments)." } diff --git a/Sources/GraphQL/Validation/Rules/ScalarLeafsRule.swift b/Sources/GraphQL/Validation/Rules/ScalarLeafsRule.swift index e41dea60..201b40fb 100644 --- a/Sources/GraphQL/Validation/Rules/ScalarLeafsRule.swift +++ b/Sources/GraphQL/Validation/Rules/ScalarLeafsRule.swift @@ -5,7 +5,7 @@ func noSubselectionAllowedMessage(fieldName: String, type: GraphQLType) -> Strin func requiredSubselectionMessage(fieldName: String, type: GraphQLType) -> String { return "Field \"\(fieldName)\" of type \"\(type)\" must have a " + - "selection of subfields. Did you mean \"\(fieldName) { ... }\"?" + "selection of subfields." + didYouMean(suggestions: ["\(fieldName) { ... }"]) } /** diff --git a/Tests/GraphQLTests/SwiftUtilitiesTests/DidYouMeanTests.swift b/Tests/GraphQLTests/SwiftUtilitiesTests/DidYouMeanTests.swift new file mode 100644 index 00000000..379e1dcd --- /dev/null +++ b/Tests/GraphQLTests/SwiftUtilitiesTests/DidYouMeanTests.swift @@ -0,0 +1,46 @@ +@testable import GraphQL +import XCTest + +class DidYouMeanTests: XCTestCase { + func testEmptyList() { + XCTAssertEqual( + didYouMean(suggestions: []), + "" + ) + } + + func testSingleSuggestion() { + XCTAssertEqual( + didYouMean(suggestions: ["A"]), + #" Did you mean "A"?"# + ) + } + + func testTwoSuggestions() { + XCTAssertEqual( + didYouMean(suggestions: ["A", "B"]), + #" Did you mean "A" or "B"?"# + ) + } + + func testMultipleSuggestions() { + XCTAssertEqual( + didYouMean(suggestions: ["A", "B", "C"]), + #" Did you mean "A", "B", or "C"?"# + ) + } + + func testLimitsToFiveSuggestions() { + XCTAssertEqual( + didYouMean(suggestions: ["A", "B", "C", "D", "E", "F"]), + #" Did you mean "A", "B", "C", "D", or "E"?"# + ) + } + + func testAddsSubmessage() { + XCTAssertEqual( + didYouMean("the letter", suggestions: ["A"]), + #" Did you mean the letter "A"?"# + ) + } +}