From 24546b166130818fb207bb91a2c17fe786826de1 Mon Sep 17 00:00:00 2001 From: Honza Dvorsky Date: Thu, 24 Aug 2023 14:47:53 +0200 Subject: [PATCH] [Runtime] Accept header (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Runtime] Accept header ### Motivation SOAR-0003 was accepted, this is the runtime side of the implementation. ### Modifications - Introduced a new protocol `AcceptableProtocol`, which all the generated, operation-specific Accept enums conform to. - Introduced a new struct `QualityValue`, which wraps the quality parameter of the content type. Since the precision is capped at 3 decimal places, the internal storage is in 1000's, allowing for more reliable comparisons, as floating point numbers are only used when serialized into headers. - Introduced a new struct `AcceptHeaderContentType`, generic over `AcceptableProtocol`, which adds `QualityValue` to each generated Accept enum. - Introduced new extensions on `Converter` that allow setting and getting the Accept header. ### Result These are the requirements for https://github.com/apple/swift-openapi-generator/pull/185. ### Test Plan Added unit tests for both `QualityValue` and `AcceptHeaderContentType`, and for the new `Converter` methods. Reviewed by: gjcairo Builds: ✔︎ pull request validation (5.8) - Build finished. ✔︎ pull request validation (5.9) - Build finished. ✔︎ pull request validation (api breakage) - Build finished. ✔︎ pull request validation (docc test) - Build finished. ✔︎ pull request validation (integration test) - Build finished. ✔︎ pull request validation (nightly) - Build finished. ✔︎ pull request validation (soundness) - Build finished. https://github.com/apple/swift-openapi-runtime/pull/37 --- Sources/OpenAPIRuntime/Base/Acceptable.swift | 162 ++++++++++++++++++ .../OpenAPIRuntime/Base/OpenAPIMIMEType.swift | 6 - .../Conversion/Converter+Client.swift | 16 ++ .../Conversion/Converter+Server.swift | 25 +++ .../Conversion/FoundationExtensions.swift | 9 + .../OpenAPIRuntime/Errors/RuntimeError.swift | 3 + .../Base/Test_Acceptable.swift | 107 ++++++++++++ .../Conversion/Test_Converter+Client.swift | 14 ++ .../Conversion/Test_Converter+Server.swift | 16 ++ 9 files changed, 352 insertions(+), 6 deletions(-) create mode 100644 Sources/OpenAPIRuntime/Base/Acceptable.swift create mode 100644 Tests/OpenAPIRuntimeTests/Base/Test_Acceptable.swift diff --git a/Sources/OpenAPIRuntime/Base/Acceptable.swift b/Sources/OpenAPIRuntime/Base/Acceptable.swift new file mode 100644 index 00000000..f4afb72a --- /dev/null +++ b/Sources/OpenAPIRuntime/Base/Acceptable.swift @@ -0,0 +1,162 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// The protocol that all generated `AcceptableContentType` enums conform to. +public protocol AcceptableProtocol: RawRepresentable, Sendable, Hashable, CaseIterable where RawValue == String {} + +/// A quality value used to describe the order of priority in a comma-separated +/// list of values, such as in the Accept header. +public struct QualityValue: Sendable, Hashable { + + /// As the quality value only retains up to and including 3 decimal digits, + /// we store it in terms of the thousands. + /// + /// This allows predictable equality comparisons and sorting. + /// + /// For example, 1000 thousands is the quality value of 1.0. + private let thousands: UInt16 + + /// Returns a Boolean value indicating whether the quality value is + /// at its default value 1.0. + public var isDefault: Bool { + thousands == 1000 + } + + /// Creates a new quality value from the provided floating-point number. + /// + /// - Precondition: The value must be between 0.0 and 1.0, inclusive. + public init(doubleValue: Double) { + precondition( + doubleValue >= 0.0 && doubleValue <= 1.0, + "Provided quality number is out of range, must be between 0.0 and 1.0, inclusive." + ) + self.thousands = UInt16(doubleValue * 1000) + } + + /// The value represented as a floating-point number between 0.0 and 1.0, inclusive. + public var doubleValue: Double { + Double(thousands) / 1000 + } +} + +extension QualityValue: RawRepresentable { + public init?(rawValue: String) { + guard let doubleValue = Double(rawValue) else { + return nil + } + self.init(doubleValue: doubleValue) + } + + public var rawValue: String { + String(format: "%0.3f", doubleValue) + } +} + +extension QualityValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: UInt16) { + precondition( + value >= 0 && value <= 1, + "Provided quality number is out of range, must be between 0 and 1, inclusive." + ) + self.thousands = value * 1000 + } +} + +extension QualityValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self.init(doubleValue: value) + } +} + +extension Array { + + /// Returns the default values for the acceptable type. + public static func defaultValues() -> [AcceptHeaderContentType] + where Element == AcceptHeaderContentType { + T.allCases.map { .init(contentType: $0) } + } +} + +/// A wrapper of an individual content type in the accept header. +public struct AcceptHeaderContentType: Sendable, Hashable { + + /// The value representing the content type. + public var contentType: ContentType + + /// The quality value of this content type. + /// + /// Used to describe the order of priority in a comma-separated + /// list of values. + /// + /// Content types with a higher priority should be preferred by the server + /// when deciding which content type to use in the response. + /// + /// Also called the "q-factor" or "q-value". + public var quality: QualityValue + + /// Creates a new content type from the provided parameters. + /// - Parameters: + /// - value: The value representing the content type. + /// - quality: The quality of the content type, between 0.0 and 1.0. + /// - Precondition: Quality must be in the range 0.0 and 1.0 inclusive. + public init(contentType: ContentType, quality: QualityValue = 1.0) { + self.quality = quality + self.contentType = contentType + } + + /// Returns the default set of acceptable content types for this type, in + /// the order specified in the OpenAPI document. + public static var defaultValues: [Self] { + ContentType.allCases.map { .init(contentType: $0) } + } +} + +extension AcceptHeaderContentType: RawRepresentable { + public init?(rawValue: String) { + guard let validMimeType = OpenAPIMIMEType(rawValue) else { + // Invalid MIME type. + return nil + } + let quality: QualityValue + if let rawQuality = validMimeType.parameters["q"] { + guard let parsedQuality = QualityValue(rawValue: rawQuality) else { + // Invalid quality parameter. + return nil + } + quality = parsedQuality + } else { + quality = 1.0 + } + guard let typeAndSubtype = ContentType(rawValue: validMimeType.kind.description.lowercased()) else { + // Invalid type/subtype. + return nil + } + self.init(contentType: typeAndSubtype, quality: quality) + } + + public var rawValue: String { + contentType.rawValue + (quality.isDefault ? "" : "; q=\(quality.rawValue)") + } +} + +extension Array { + + /// Returns the array sorted by the quality value, highest quality first. + public func sortedByQuality() -> [AcceptHeaderContentType] + where Element == AcceptHeaderContentType { + sorted { a, b in + a.quality.doubleValue > b.quality.doubleValue + } + } +} diff --git a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift index d7386cb4..cc79ffd0 100644 --- a/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift +++ b/Sources/OpenAPIRuntime/Base/OpenAPIMIMEType.swift @@ -165,9 +165,3 @@ extension OpenAPIMIMEType: LosslessStringConvertible { .joined(separator: "; ") } } - -extension String { - fileprivate var trimmingLeadingAndTrailingSpaces: Self { - trimmingCharacters(in: .whitespacesAndNewlines) - } -} diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift index 0e6ed7a2..4195400e 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Client.swift @@ -15,6 +15,22 @@ import Foundation extension Converter { + /// Sets the "accept" header according to the provided content types. + /// - Parameters: + /// - headerFields: The header fields where to add the "accept" header. + /// - contentTypes: The array of acceptable content types by the client. + public func setAcceptHeader( + in headerFields: inout [HeaderField], + contentTypes: [AcceptHeaderContentType] + ) { + headerFields.append( + .init( + name: "accept", + value: contentTypes.map(\.rawValue).joined(separator: ", ") + ) + ) + } + // | client | set | request path | text | string-convertible | required | renderedRequestPath | public func renderedRequestPath( template: String, diff --git a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift index 0a0ac42b..98e83e06 100644 --- a/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift +++ b/Sources/OpenAPIRuntime/Conversion/Converter+Server.swift @@ -17,6 +17,31 @@ public extension Converter { // MARK: Miscs + /// Returns the "accept" header parsed into individual content types. + /// - Parameter headerFields: The header fields to inspect for an "accept" + /// header. + /// - Returns: The parsed content types, or the default content types if + /// the header was not provided. + func extractAcceptHeaderIfPresent( + in headerFields: [HeaderField] + ) throws -> [AcceptHeaderContentType] { + guard let rawValue = headerFields.firstValue(name: "accept") else { + return AcceptHeaderContentType.defaultValues + } + let rawComponents = + rawValue + .split(separator: ",") + .map(String.init) + .map(\.trimmingLeadingAndTrailingSpaces) + let parsedComponents = try rawComponents.map { rawComponent in + guard let value = AcceptHeaderContentType(rawValue: rawComponent) else { + throw RuntimeError.malformedAcceptHeader(rawComponent) + } + return value + } + return parsedComponents + } + /// Validates that the Accept header in the provided response /// is compatible with the provided content type substring. /// - Parameters: diff --git a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift index 3590c853..4c2b5cb8 100644 --- a/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift +++ b/Sources/OpenAPIRuntime/Conversion/FoundationExtensions.swift @@ -101,3 +101,12 @@ extension URLComponents { queryItems = groups.otherItems + [newItem] } } + +extension String { + + /// Returns the string with leading and trailing whitespace (such as spaces + /// and newlines) removed. + var trimmingLeadingAndTrailingSpaces: Self { + trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift index 46185e04..205b0e33 100644 --- a/Sources/OpenAPIRuntime/Errors/RuntimeError.swift +++ b/Sources/OpenAPIRuntime/Errors/RuntimeError.swift @@ -37,6 +37,7 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret case missingRequiredHeaderField(String) case unexpectedContentTypeHeader(String) case unexpectedAcceptHeader(String) + case malformedAcceptHeader(String) // Path case missingRequiredPathParameter(String) @@ -74,6 +75,8 @@ internal enum RuntimeError: Error, CustomStringConvertible, LocalizedError, Pret return "Unexpected Content-Type header: \(contentType)" case .unexpectedAcceptHeader(let accept): return "Unexpected Accept header: \(accept)" + case .malformedAcceptHeader(let accept): + return "Malformed Accept header: \(accept)" case .missingRequiredPathParameter(let name): return "Missing required path parameter named: \(name)" case .missingRequiredQueryParameter(let name): diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_Acceptable.swift b/Tests/OpenAPIRuntimeTests/Base/Test_Acceptable.swift new file mode 100644 index 00000000..405af225 --- /dev/null +++ b/Tests/OpenAPIRuntimeTests/Base/Test_Acceptable.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftOpenAPIGenerator open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftOpenAPIGenerator project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftOpenAPIGenerator project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import XCTest +@_spi(Generated) import OpenAPIRuntime + +enum TestAcceptable: AcceptableProtocol { + case json + case other(String) + + init?(rawValue: String) { + switch rawValue { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + + var rawValue: String { + switch self { + case .json: + return "application/json" + case .other(let string): + return string + } + } + + static var allCases: [TestAcceptable] { + [.json] + } +} + +final class Test_AcceptHeaderContentType: Test_Runtime { + func test() throws { + do { + let contentType = AcceptHeaderContentType(contentType: TestAcceptable.json) + XCTAssertEqual(contentType.contentType, .json) + XCTAssertEqual(contentType.quality, 1.0) + XCTAssertEqual(contentType.rawValue, "application/json") + XCTAssertEqual( + AcceptHeaderContentType(rawValue: "application/json"), + contentType + ) + } + do { + let contentType = AcceptHeaderContentType( + contentType: TestAcceptable.json, + quality: 0.5 + ) + XCTAssertEqual(contentType.contentType, .json) + XCTAssertEqual(contentType.quality, 0.5) + XCTAssertEqual(contentType.rawValue, "application/json; q=0.500") + XCTAssertEqual( + AcceptHeaderContentType(rawValue: "application/json; q=0.500"), + contentType + ) + } + do { + XCTAssertEqual( + AcceptHeaderContentType.defaultValues, + [ + .init(contentType: .json) + ] + ) + } + do { + let unsorted: [AcceptHeaderContentType] = [ + .init(contentType: .other("*/*"), quality: 0.3), + .init(contentType: .json, quality: 0.5), + ] + XCTAssertEqual( + unsorted.sortedByQuality(), + [ + .init(contentType: .json, quality: 0.5), + .init(contentType: .other("*/*"), quality: 0.3), + ] + ) + } + } +} + +final class Test_QualityValue: Test_Runtime { + func test() { + XCTAssertEqual((1 as QualityValue).doubleValue, 1.0) + XCTAssertTrue((1 as QualityValue).isDefault) + XCTAssertFalse(QualityValue(doubleValue: 0.5).isDefault) + XCTAssertEqual(QualityValue(doubleValue: 0.5).doubleValue, 0.5) + XCTAssertEqual(QualityValue(floatLiteral: 0.5).doubleValue, 0.5) + XCTAssertEqual(QualityValue(integerLiteral: 0).doubleValue, 0) + XCTAssertEqual(QualityValue(rawValue: "1.0")?.doubleValue, 1.0) + XCTAssertEqual(QualityValue(rawValue: "0.0")?.doubleValue, 0.0) + XCTAssertEqual(QualityValue(rawValue: "0.3")?.doubleValue, 0.3) + XCTAssertEqual(QualityValue(rawValue: "0.54321")?.rawValue, "0.543") + XCTAssertNil(QualityValue(rawValue: "hi")) + } +} diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift index 8dd24f23..370d44e9 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Client.swift @@ -16,6 +16,20 @@ import XCTest final class Test_ClientConverterExtensions: Test_Runtime { + func test_setAcceptHeader() throws { + var headerFields: [HeaderField] = [] + converter.setAcceptHeader( + in: &headerFields, + contentTypes: [.init(contentType: TestAcceptable.json, quality: 0.8)] + ) + XCTAssertEqual( + headerFields, + [ + .init(name: "accept", value: "application/json; q=0.800") + ] + ) + } + // MARK: Converter helper methods // | client | set | request path | text | string-convertible | required | renderedRequestPath | diff --git a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift index 5436d9dc..8cd8bc5e 100644 --- a/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift +++ b/Tests/OpenAPIRuntimeTests/Conversion/Test_Converter+Server.swift @@ -16,6 +16,22 @@ import XCTest final class Test_ServerConverterExtensions: Test_Runtime { + func testExtractAccept() throws { + let headerFields: [HeaderField] = [ + .init(name: "accept", value: "application/json, */*; q=0.8") + ] + let accept: [AcceptHeaderContentType] = try converter.extractAcceptHeaderIfPresent( + in: headerFields + ) + XCTAssertEqual( + accept, + [ + .init(contentType: .json, quality: 1.0), + .init(contentType: .other("*/*"), quality: 0.8), + ] + ) + } + // MARK: Miscs func testValidateAccept() throws {