From a8c60fd3ee9a938ee1ee1718cf6c0d03bc4f7328 Mon Sep 17 00:00:00 2001 From: Moritz Lang <16192401+slashmo@users.noreply.github.com> Date: Mon, 27 Jan 2025 01:00:48 +0100 Subject: [PATCH] Implement bool flag resolution --- Sources/OFREP/OFREPProvider.swift | 32 ++- ...wift => OpenFeatureEvaluation+OFREP.swift} | 31 ++ .../OFREP/OpenFeatureResolution+OFREP.swift | 160 +++++++++++ Tests/OFREPTests/OFREPProviderTests.swift | 207 ++++++++++++- .../OpenFeatureResolutionDecodingTests.swift | 272 ++++++++++++++++++ 5 files changed, 698 insertions(+), 4 deletions(-) rename Sources/OFREP/{OpenFeatureEvaluationContext+OFREP.swift => OpenFeatureEvaluation+OFREP.swift} (63%) create mode 100644 Sources/OFREP/OpenFeatureResolution+OFREP.swift create mode 100644 Tests/OFREPTests/OpenFeatureResolutionDecodingTests.swift diff --git a/Sources/OFREP/OFREPProvider.swift b/Sources/OFREP/OFREPProvider.swift index 610d535..fd8eb0f 100644 --- a/Sources/OFREP/OFREPProvider.swift +++ b/Sources/OFREP/OFREPProvider.swift @@ -11,7 +11,9 @@ // //===----------------------------------------------------------------------===// +import Foundation import Logging +import OpenAPIRuntime import OpenFeature import ServiceLifecycle @@ -19,10 +21,12 @@ public struct OFREPProvider: OpenFeatureProvide public let metadata = OpenFeatureProviderMetadata(name: "OpenFeature Remote Evaluation Protocol Provider") public let description = "OFREPProvider" private let transport: Transport + private let client: Client private let logger = Logger(label: "OFREPProvider") - package init(transport: Transport) { + package init(serverURL: URL, transport: Transport) { self.transport = transport + self.client = Client(serverURL: serverURL, transport: transport) } public func resolution( @@ -30,7 +34,31 @@ public struct OFREPProvider: OpenFeatureProvide defaultValue: Bool, context: OpenFeatureEvaluationContext? ) async -> OpenFeatureResolution { - OpenFeatureResolution(value: defaultValue) + let request: Components.Schemas.EvaluationRequest + do { + request = try Components.Schemas.EvaluationRequest(flag: flag, defaultValue: defaultValue, context: context) + } catch { + return error.resolution + } + + do { + do { + let response = try await client.postOfrepV1EvaluateFlagsKey( + path: .init(key: flag), + headers: .init(accept: [.init(contentType: .json)]), + body: .json(request) + ) + return OpenFeatureResolution(response, defaultValue: defaultValue) + } catch let error as ClientError { + throw error.underlyingError + } + } catch { + return OpenFeatureResolution( + value: defaultValue, + error: OpenFeatureResolutionError(code: .general, message: "\(error)"), + reason: .error + ) + } } public func run() async throws { diff --git a/Sources/OFREP/OpenFeatureEvaluationContext+OFREP.swift b/Sources/OFREP/OpenFeatureEvaluation+OFREP.swift similarity index 63% rename from Sources/OFREP/OpenFeatureEvaluationContext+OFREP.swift rename to Sources/OFREP/OpenFeatureEvaluation+OFREP.swift index 566cd83..4394d6e 100644 --- a/Sources/OFREP/OpenFeatureEvaluationContext+OFREP.swift +++ b/Sources/OFREP/OpenFeatureEvaluation+OFREP.swift @@ -15,6 +15,37 @@ import Foundation import OpenAPIRuntime import OpenFeature +extension Components.Schemas.EvaluationRequest { + package init( + flag: String, + defaultValue: Value, + context: OpenFeatureEvaluationContext? + ) throws(EvaluationRequestSerializationError) { + let serializedContext: Components.Schemas.Context? + do { + serializedContext = try context.map(Components.Schemas.Context.init) + } catch { + throw EvaluationRequestSerializationError( + value: defaultValue, + error: OpenFeatureResolutionError(code: .invalidContext, message: "\(error)"), + reason: .error + ) + } + + self.init(context: serializedContext) + } +} + +package struct EvaluationRequestSerializationError: Error { + let value: Value + let error: OpenFeatureResolutionError + let reason: OpenFeatureResolutionReason + + var resolution: OpenFeatureResolution { + OpenFeatureResolution(value: value, error: error, reason: reason) + } +} + extension Components.Schemas.Context { package init(_ context: OpenFeatureEvaluationContext) throws { let additionalProperties = try OpenAPIObjectContainer(context.fields) diff --git a/Sources/OFREP/OpenFeatureResolution+OFREP.swift b/Sources/OFREP/OpenFeatureResolution+OFREP.swift new file mode 100644 index 0000000..517337e --- /dev/null +++ b/Sources/OFREP/OpenFeatureResolution+OFREP.swift @@ -0,0 +1,160 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OpenFeature open source project +// +// Copyright (c) 2025 the Swift OpenFeature project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import OpenFeature + +extension OpenFeatureResolution { + package init(_ response: Operations.PostOfrepV1EvaluateFlagsKey.Output, defaultValue: Bool) { + switch response { + case .ok(let ok): + switch ok.body { + case .json(let responsePayload): + self = OpenFeatureResolution(responsePayload, defaultValue: defaultValue) + } + case .badRequest(let badRequest): + switch badRequest.body { + case .json(let responsePayload): + self = OpenFeatureResolution( + value: defaultValue, + error: .init( + code: .init(rawValue: responsePayload.errorCode.rawValue), + message: responsePayload.errorDetails + ), + reason: .error + ) + } + case .notFound(let notFound): + switch notFound.body { + case .json(let responsePayload): + self = OpenFeatureResolution( + value: defaultValue, + error: .init( + code: .init(rawValue: responsePayload.errorCode.rawValue), + message: responsePayload.errorDetails + ), + reason: .error + ) + } + case .unauthorized: + self = OpenFeatureResolution( + value: defaultValue, + error: OpenFeatureResolutionError(code: .general, message: "Unauthorized."), + reason: .error + ) + case .forbidden: + self = OpenFeatureResolution( + value: defaultValue, + error: OpenFeatureResolutionError(code: .general, message: "Forbidden."), + reason: .error + ) + case .tooManyRequests(let responsePayload): + let message: String + if let retryAfter = responsePayload.headers.retryAfter { + let dateString = retryAfter.ISO8601Format(.iso8601WithTimeZone()) + message = #"Too many requests. Retry after "\#(dateString)"."# + } else { + message = "Too many requests." + } + self = OpenFeatureResolution( + value: defaultValue, + error: OpenFeatureResolutionError(code: .general, message: message), + reason: .error + ) + case .internalServerError(let internalServerError): + switch internalServerError.body { + case .json(let responsePayload): + self = OpenFeatureResolution( + value: defaultValue, + error: OpenFeatureResolutionError(code: .general, message: responsePayload.errorDetails), + reason: .error + ) + } + case .undocumented(let statusCode, _): + self = OpenFeatureResolution( + value: defaultValue, + error: OpenFeatureResolutionError( + code: .general, + message: #"Received unexpected response status code "\#(statusCode)"."# + ), + reason: .error + ) + } + } +} + +extension OpenFeatureResolution { + package init( + _ response: Components.Schemas.ServerEvaluationSuccess, + defaultValue: Bool + ) { + let variant = response.value1.value1.variant + let flagMetadata = response.value1.value1.metadata.toFlagMetadata() + + switch response.value1.value2 { + case .BooleanFlag(let boolContainer): + self.init( + value: boolContainer.value, + error: nil, + reason: response.value1.value1.reason.map(OpenFeatureResolutionReason.init), + variant: variant, + flagMetadata: flagMetadata + ) + default: + self.init( + value: defaultValue, + error: OpenFeatureResolutionError( + code: .typeMismatch, + message: response.value1.value2.typeMismatchErrorMessage(expectedType: "\(Value.self)") + ), + reason: .error, + variant: variant, + flagMetadata: flagMetadata + ) + } + } +} + +extension Components.Schemas.EvaluationSuccess.Value1Payload.MetadataPayload? { + package func toFlagMetadata() -> [String: OpenFeatureFlagMetadataValue] { + self?.additionalProperties.mapValues(OpenFeatureFlagMetadataValue.init) ?? [:] + } +} + +extension OpenFeatureFlagMetadataValue { + package init( + _ payload: Components.Schemas.EvaluationSuccess.Value1Payload.MetadataPayload.AdditionalPropertiesPayload + ) { + self = + switch payload { + case .case1(let value): .bool(value) + case .case2(let value): .string(value) + case .case3(let value): .double(value) + } + } +} + +extension Components.Schemas.EvaluationSuccess.Value2Payload { + package var typeDescription: String { + switch self { + case .BooleanFlag: "Bool" + case .StringFlag: "String" + case .IntegerFlag: "Int" + case .FloatFlag: "Double" + case .ObjectFlag: "Object" + } + } + + func typeMismatchErrorMessage(expectedType: String) -> String { + #"Expected flag value of type "\#(expectedType)" but received "\#(typeDescription)"."# + } +} diff --git a/Tests/OFREPTests/OFREPProviderTests.swift b/Tests/OFREPTests/OFREPProviderTests.swift index 7c341dc..3232637 100644 --- a/Tests/OFREPTests/OFREPProviderTests.swift +++ b/Tests/OFREPTests/OFREPProviderTests.swift @@ -15,8 +15,10 @@ import Foundation import HTTPTypes import OFREP import OpenAPIRuntime -import Testing +import OpenFeature import ServiceLifecycle +import Testing + @testable import Logging @Suite("OFREP Provider") @@ -33,6 +35,122 @@ final class OFREPProviderTests { LoggingSystem.bootstrapInternal(SwiftLogNoOpLogHandler.init) } + @Test("Returns default value when evaluation context serialization fails", arguments: [true, false]) + func returnsDefaultValueWhenEvaluationContextSerializationFails(defaultValue: Bool) async throws { + struct TestError: Error, CustomStringConvertible { + let description = "An error description." + } + + struct Object: Codable, Sendable { + func encode(to encoder: any Encoder) throws { + throw TestError() + } + } + + let transport = FailingOFREPClientTransport() + let provider = OFREPProvider(serverURL: URL(string: "http://stub.stub")!, transport: transport) + + let resolution = await provider.resolution( + of: "flag", + defaultValue: defaultValue, + context: OpenFeatureEvaluationContext(fields: ["object": .object(Object())]) + ) + + #expect(resolution.value == defaultValue) + #expect(resolution.error == OpenFeatureResolutionError(code: .invalidContext, message: "An error description.")) + #expect(resolution.reason == .error) + } + + @Test("Includes evaluation context in requests") + func includesEvaluationContextInRequests() async throws { + let serverURL = URL(string: "http://localhost:42")! + let transport = RecordingOFREPClientTransport() + let provider = OFREPProvider(serverURL: serverURL, transport: transport) + let flag = "test-flag" + let targetingKey = "test-targeting-key" + + struct Object: Codable, Sendable { + let foo: String + } + let context = OpenFeatureEvaluationContext( + targetingKey: targetingKey, + fields: [ + "bool": true, + "string": "foo", + "int": 42, + "double": 42.84, + "date": .date(Date(timeIntervalSince1970: 42)), + "object": .object(Object(foo: "bar")), + ] + ) + + _ = await provider.resolution(of: flag, defaultValue: true, context: context) + + let request = try await #require(transport.requests.first) + #expect(request.baseURL == serverURL) + + let body = try #require(request.body) + let bodyBytes = try await Data(HTTPBody.ByteChunk(collecting: body, upTo: .max)) + let payload = Components.Schemas.EvaluationRequest( + context: Components.Schemas.Context( + targetingKey: targetingKey, + additionalProperties: try OpenAPIObjectContainer(unvalidatedValue: [ + "bool": true, + "string": "foo", + "int": 42, + "double": 42.84, + "date": 42, + "object": [ + "foo": "bar" + ], + ]) + ) + ) + try #expect(JSONDecoder().decode(Components.Schemas.EvaluationRequest.self, from: bodyBytes) == payload) + } + + @Test("Returns successful server evaluation", arguments: [true, false]) + func returnsSuccessfulServerEvaluation(value: Bool) async throws { + let transport = ClosureOFREPClientTransport { + ( + HTTPResponse(status: .ok), + HTTPBody( + """ + { + "value": \(value), + "reason": "STATIC", + "variant": "a" + } + """ + ) + ) + } + let provider = OFREPProvider(transport: transport) + + let resolution = await provider.resolution(of: "test-flag", defaultValue: !value, context: nil) + + #expect(resolution.value == value) + #expect(resolution.error == nil) + #expect(resolution.reason == .static) + #expect(resolution.variant == "a") + } + + @Test("Returns default value when transport fails", arguments: [true, false]) + func returnsDefaultValueWhenTransportFails(value: Bool) async throws { + struct TransportError: Error, CustomStringConvertible { + let description = "Example error." + } + let transport = ClosureOFREPClientTransport { throw TransportError() } + let provider = OFREPProvider(transport: transport) + + let resolution = await provider.resolution(of: "test-flag", defaultValue: value, context: nil) + + #expect(resolution.value == value) + #expect(resolution.error == OpenFeatureResolutionError(code: .general, message: "Example error.")) + #expect(resolution.reason == .error) + #expect(resolution.variant == nil) + } + @Test("Graceful shutdown") func shutsDownTransport() async throws { /// A no-op service which is used to shut down the service group upon successful termination. @@ -63,7 +181,10 @@ final class OFREPProviderTests { } } +// MARK: - Helpers + private actor RecordingOFREPClientTransport: OFREPClientTransport { + var requests = [Request]() var numberOfShutdownCalls = 0 func send( @@ -75,10 +196,92 @@ private actor RecordingOFREPClientTransport: OFREPClientTransport { HTTPResponse, HTTPBody? ) { - (HTTPResponse(status: 418), nil) + requests.append(Request(body: body, baseURL: baseURL)) + return (HTTPResponse(status: 501), nil) } func shutdownGracefully() async throws { numberOfShutdownCalls += 1 } + + struct Request { + let body: HTTPBody? + let baseURL: URL + } +} + +extension OFREPProvider { + fileprivate init(transport: Transport) { + self.init(serverURL: .stub, transport: transport) + } +} + +private struct FailingOFREPClientTransport: OFREPClientTransport { + private let sourceLocation: SourceLocation + + init(sourceLocation: SourceLocation = #_sourceLocation) { + self.sourceLocation = sourceLocation + } + + func send( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String + ) async throws -> ( + HTTPResponse, + HTTPBody? + ) { + Issue.record("Unexpectedly sent request to OFREP client transport.", sourceLocation: sourceLocation) + throw TransportError() + } + + func shutdownGracefully() async throws { + Issue.record("Unexpectedly shut down OFREP client transport.", sourceLocation: sourceLocation) + throw TransportError() + } + + struct TransportError: Error {} +} + +private struct ClosureOFREPClientTransport: OFREPClientTransport { + private let sourceLocation: SourceLocation + private let onRequest: @Sendable () async throws -> (HTTPResponse, HTTPBody?) + + init( + sourceLocation: SourceLocation = #_sourceLocation, + onRequest: @escaping @Sendable () async throws -> (HTTPResponse, HTTPBody?) + ) { + self.sourceLocation = sourceLocation + self.onRequest = onRequest + } + + func send( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String + ) async throws -> ( + HTTPResponse, + HTTPBody? + ) { + try await onRequest() + } + + func shutdownGracefully() async throws { + Issue.record("Unexpectedly shut down OFREP client transport.", sourceLocation: sourceLocation) + throw TransportError() + } + + struct TransportError: Error {} +} + +extension OFREPProvider { + fileprivate init(transport: Transport) { + self.init(serverURL: .stub, transport: transport) + } +} + +extension URL { + fileprivate static let stub = URL(string: "http://stub.stub")! } diff --git a/Tests/OFREPTests/OpenFeatureResolutionDecodingTests.swift b/Tests/OFREPTests/OpenFeatureResolutionDecodingTests.swift new file mode 100644 index 0000000..b3724ea --- /dev/null +++ b/Tests/OFREPTests/OpenFeatureResolutionDecodingTests.swift @@ -0,0 +1,272 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OpenFeature open source project +// +// Copyright (c) 2025 the Swift OpenFeature project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import OFREP +import OpenAPIRuntime +import OpenFeature +import Testing + +@Suite("Resolution Decoding") +struct OpenFeatureResolutionDecodingTests { + @Suite("Bool") + struct BoolResolutionDecodingTests { + @Test("Success", arguments: [true, false]) + func success(value: Bool) { + let response = Operations.PostOfrepV1EvaluateFlagsKey.Output.ok( + .init( + body: .json( + Components.Schemas.ServerEvaluationSuccess( + value1: .init( + value1: .init( + key: "flag", + reason: "TARGETING_MATCH", + variant: "b", + metadata: .init(additionalProperties: ["foo": .case2("bar")]) + ), + value2: .BooleanFlag(.init(value: value)) + ), + value2: .init(cacheable: nil) + ) + ) + ) + ) + + let resolution = OpenFeatureResolution( + value: value, + error: nil, + reason: .targetingMatch, + variant: "b", + flagMetadata: ["foo": .string("bar")] + ) + + #expect(OpenFeatureResolution(response, defaultValue: !value) == resolution) + } + + @Test("Bad request", arguments: ["Targeting key is required.", nil]) + func badRequest(message: String?) { + let response = Operations.PostOfrepV1EvaluateFlagsKey.Output.badRequest( + .init( + body: .json( + .init( + key: "flag", + errorCode: .targetingKeyMissing, + errorDetails: message + ) + ) + ) + ) + + let resolution = OpenFeatureResolution( + value: true, + error: OpenFeatureResolutionError(code: .targetingKeyMissing, message: message), + reason: .error + ) + + #expect(OpenFeatureResolution(response, defaultValue: true) == resolution) + } + + @Test("Not found", arguments: ["Flag not found.", nil]) + func notFound(message: String?) { + let response = Operations.PostOfrepV1EvaluateFlagsKey.Output.notFound( + .init( + body: .json( + .init( + key: "flag", + errorCode: .flagNotFound, + errorDetails: message + ) + ) + ) + ) + + let resolution = OpenFeatureResolution( + value: false, + error: OpenFeatureResolutionError(code: .flagNotFound, message: message), + reason: .error + ) + + #expect(OpenFeatureResolution(response, defaultValue: false) == resolution) + } + + @Test("Unauthorized") + func unauthorized() throws { + let response = Operations.PostOfrepV1EvaluateFlagsKey.Output.unauthorized(.init()) + + let resolution = OpenFeatureResolution( + value: false, + error: OpenFeatureResolutionError(code: .general, message: "Unauthorized."), + reason: .error + ) + + #expect(OpenFeatureResolution(response, defaultValue: false) == resolution) + } + + @Test("Forbidden") + func forbidden() throws { + let response = Operations.PostOfrepV1EvaluateFlagsKey.Output.forbidden(.init()) + + let resolution = OpenFeatureResolution( + value: false, + error: OpenFeatureResolutionError(code: .general, message: "Forbidden."), + reason: .error + ) + + #expect(OpenFeatureResolution(response, defaultValue: false) == resolution) + } + + @Test("Too many requests without retry date") + func tooManyRequests() throws { + let response = Operations.PostOfrepV1EvaluateFlagsKey.Output.tooManyRequests(.init()) + + let resolution = OpenFeatureResolution( + value: false, + error: OpenFeatureResolutionError(code: .general, message: "Too many requests."), + reason: .error + ) + + #expect(OpenFeatureResolution(response, defaultValue: false) == resolution) + } + + @Test("Too many requests with retry date") + func tooManyRequestsWithRetryDate() throws { + let response = Operations.PostOfrepV1EvaluateFlagsKey.Output.tooManyRequests( + .init(headers: .init(retryAfter: Date(timeIntervalSince1970: 1_737_935_656))) + ) + + let resolution = OpenFeatureResolution( + value: false, + error: OpenFeatureResolutionError( + code: .general, + message: #"Too many requests. Retry after "2025-01-26T23:54:16Z"."# + ), + reason: .error + ) + + #expect(OpenFeatureResolution(response, defaultValue: false) == resolution) + } + + @Test("Internal server error", arguments: ["Database connection failed.", nil]) + func internalServerError(message: String?) throws { + let response = Operations.PostOfrepV1EvaluateFlagsKey.Output.internalServerError( + .init(body: .json(.init(errorDetails: message))) + ) + + let resolution = OpenFeatureResolution( + value: false, + error: OpenFeatureResolutionError(code: .general, message: message), + reason: .error + ) + + #expect(OpenFeatureResolution(response, defaultValue: false) == resolution) + } + + @Test("Unknown status code") + func internalServerError() throws { + let response = Operations.PostOfrepV1EvaluateFlagsKey.Output.undocumented(statusCode: 418, .init()) + + let resolution = OpenFeatureResolution( + value: false, + error: OpenFeatureResolutionError( + code: .general, + message: #"Received unexpected response status code "418"."# + ), + reason: .error + ) + + #expect(OpenFeatureResolution(response, defaultValue: false) == resolution) + } + + @Test("Type mismatch", arguments: [true, false]) + func typeMismatch(defaultValue: Bool) { + let response = Components.Schemas.ServerEvaluationSuccess( + value1: .init( + value1: .init( + key: "flag", + reason: "TARGETING_MATCH", + variant: "b", + metadata: .init(additionalProperties: ["foo": .case2("bar")]) + ), + value2: .StringFlag(.init(value: "💩")) + ), + value2: .init(cacheable: nil) + ) + + let resolution = OpenFeatureResolution( + value: defaultValue, + error: OpenFeatureResolutionError( + code: .typeMismatch, + message: #"Expected flag value of type "Bool" but received "String"."# + ), + reason: .error, + variant: "b", + flagMetadata: ["foo": .string("bar")] + ) + + #expect(OpenFeatureResolution(response, defaultValue: defaultValue) == resolution) + } + } + + @Test( + "OFREP value type description", + arguments: [ + "Bool": .BooleanFlag(.init(value: true)), + "String": .StringFlag(.init(value: "string")), + "Int": .IntegerFlag(.init(value: 42)), + "Double": .FloatFlag(.init(value: 42.84)), + "Object": .ObjectFlag( + try! .init(value: .init(unvalidatedValue: ["foo": "bar"])) + ), + ] as [String: Components.Schemas.EvaluationSuccess.Value2Payload] + ) + func valueTypeDescription(key: String, value: Components.Schemas.EvaluationSuccess.Value2Payload) { + #expect(value.typeDescription == key) + } + + @Suite("Flag metadata") + struct FlagMetadataDecodingTests { + @Test("Bool value", arguments: [true, false]) + func boolValue(value: Bool) { + let payload = Components.Schemas.EvaluationSuccess.Value1Payload.MetadataPayload?( + .init(additionalProperties: ["key": .case1(value)]) + ) + + #expect(payload.toFlagMetadata() == ["key": .bool(value)]) + } + + @Test("String value") + func stringValue() { + let payload = Components.Schemas.EvaluationSuccess.Value1Payload.MetadataPayload?( + .init(additionalProperties: ["key": .case2("value")]) + ) + + #expect(payload.toFlagMetadata() == ["key": .string("value")]) + } + + @Test("Double value", arguments: [-Double.greatestFiniteMagnitude, 42, Double.greatestFiniteMagnitude]) + func doubleValue(value: Double) { + let payload = Components.Schemas.EvaluationSuccess.Value1Payload.MetadataPayload?( + .init(additionalProperties: ["key": .case3(value)]) + ) + + #expect(payload.toFlagMetadata() == ["key": .double(value)]) + } + + @Test("Converts nil to empty metadata") + func nilToEmpty() { + let payload: Components.Schemas.EvaluationSuccess.Value1Payload.MetadataPayload? = nil + + #expect(payload.toFlagMetadata() == [:]) + } + } +}