Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 43 additions & 7 deletions Source/Features/URLEncodedFormEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -323,18 +323,52 @@ public final class URLEncodedFormEncoder {
public static let dropValue = NilEncoding { "" }
/// Encodes `nil` as `null`.
public static let null = NilEncoding { "null" }

/// Encodes `nil` only when intentionally specified:
/// - `encode(nil)` produces "null"
/// - `encodeIfPresent(nil)` skips encoding entirely
public static let intentionalOnly = NilEncoding(
encodeNil: { "null" },
encodeIfPresent: { nil }
)

// Two independent encoding closures for dual strategy support
private let encodeEncoding: @Sendable () -> String?
private let encodeIfPresentEncoding: @Sendable () -> String?

/// Creates a `NilEncoding` with separate strategies for `encode` and `encodeIfPresent` methods.
///
/// - Parameters:
/// - `encodeNil`: Strategy used when `encode(nil)` or `encodeNil()` is called directly
/// - `encodeIfPresent`: Strategy used when `encodeIfPresent(nil)` is called
public init(
encodeNil: @escaping @Sendable () -> String?,
encodeIfPresent: @escaping @Sendable () -> String?
) {
self.encodeEncoding = encodeNil
self.encodeIfPresentEncoding = encodeIfPresent
}

private let encoding: @Sendable () -> String?

/// Creates an instance with the encoding closure called for `nil` values.
/// Creates a `NilEncoding` with the same strategy for both `encode` and `encodeIfPresent`.
///
/// - Parameter encoding: Closure used to perform the encoding.
/// - Parameter `encoding`: Strategy to use for all `nil` encoding scenarios
public init(encoding: @escaping @Sendable () -> String?) {
self.encoding = encoding
self.encodeEncoding = encoding
self.encodeIfPresentEncoding = encoding
}

/// Encodes `nil` when `encode(nil)` or `encodeNil()` is called directly.
///
/// - Returns: The encoded string representation of `nil`, or `nil` to skip encoding.
func encodeNil() -> String? {
encoding()
encodeEncoding()
}

/// Encodes `nil` when `encodeIfPresent(nil)` is called.
///
/// - Returns: The encoded string representation of `nil`, or `nil` to skip encoding.
func encodeNilIfPresent() -> String? {
encodeIfPresentEncoding()
}
}

Expand Down Expand Up @@ -759,7 +793,9 @@ extension _URLEncodedFormEncoder.KeyedContainer: KeyedEncodingContainerProtocol
if let value {
try encode(value, forKey: key)
} else {
try encodeNil(forKey: key)
// Use the `encodeIfPresent` strategy for `nil` values
guard let nilValue = nilEncoding.encodeNilIfPresent() else { return }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This behavior can't change either. Instead it needs to default to the current behavior and only use the new behavior if the user provides the encodeIfPresent closure.

Copy link
Author

@darkbrewx darkbrewx Sep 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for pointing this out.
From my perspective, the current behavior remains fully backward-compatible.

  1. When using the single-parameter initializer, the encodeIfPresentEncoding and encodeEncoding closures are equivalent.
  2. Here calling nilEncoding.encodeNilIfPresent() can fall back to nilEncoding.encodeNil() automatically.

Implementing it as you suggested would require to:

  1. changingencodeIfPresentEncoding to optional, and when using single-parameter initializer, set it as nil.
  2. exposing encodeIfPresentEncoding as fileprivate or introducing an additional fileprivate(wider than private) flag to check for nil; otherwise, cannot directly unwrap it using if let.

So, may I ask you whether the current implementation is acceptable? Or if you have any further concerns, please let me know.

try encode(nilValue, forKey: key)
}
}

Expand Down
96 changes: 96 additions & 0 deletions Tests/ParameterEncoderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,83 @@ final class URLEncodedFormEncoderTests: BaseTestCase {
XCTAssertEqual(result.success, "one=one&two=")
}

func testThatNilCanBeEncodedWithIntentionalOnlyStrategy() {
// Given
let encoder = URLEncodedFormEncoder(nilEncoding: .intentionalOnly)
let parameters: [String: String?] = ["a": nil, "b": "value"]

// When
let result = Result<String, any Error> { try encoder.encode(parameters) }

// Then
XCTAssertEqual(result.success, "a=null&b=value")
}

func testThatIntentionalOnlyDistinguishesEncodeAndEncodeIfPresent() {
// Given
let encoder = URLEncodedFormEncoder(nilEncoding: .intentionalOnly)

// When
let result = Result<String, any Error> { try encoder.encode(IntentionalNilTestStruct()) }

// Then
XCTAssertEqual(result.success, "encodeIfPresentValue=present&encodeNil=null&encodeValue=encoded")
}

func testThatCustomDualStrategyWorksWithAllCombinations() {
// Given
let customStrategy = URLEncodedFormEncoder.NilEncoding(
encodeNil: { "ENCODE_NULL" },
encodeIfPresent: { "IF_PRESENT_NULL" }
)
let encoder = URLEncodedFormEncoder(nilEncoding: customStrategy)

// When
let result = Result<String, any Error> { try encoder.encode(IntentionalNilTestStruct()) }

// Then
let expected = "encodeIfPresentNil=IF_PRESENT_NULL&encodeIfPresentValue=present&encodeNil=ENCODE_NULL&encodeValue=encoded"
XCTAssertEqual(result.success, expected)
}

func testThatSingleStrategyMaintainsBackwardCompatibility() {
// Given
let legacyStrategy = URLEncodedFormEncoder.NilEncoding { "legacy_null" }
let encoder = URLEncodedFormEncoder(nilEncoding: legacyStrategy)

// When
let result = Result<String, any Error> { try encoder.encode(IntentionalNilTestStruct()) }

// Then
let expected = "encodeIfPresentNil=legacy_null&encodeIfPresentValue=present&encodeNil=legacy_null&encodeValue=encoded"
XCTAssertEqual(result.success, expected)
}

func testThatIntentionalOnlyHandlesArraysWithNils() {
// Given
let encoder = URLEncodedFormEncoder(nilEncoding: .intentionalOnly)
let parameters = ["values": [nil, "a", nil, "b"] as [String?]]

// When
let result = Result<String, any Error> { try encoder.encode(parameters) }

// Then
XCTAssertEqual(result.success, "values%5B%5D=a&values%5B%5D=b&values%5B%5D=null&values%5B%5D=null")
}

func testThatIntentionalOnlyWorksWithNestedDictionary() {
// Given
let encoder = URLEncodedFormEncoder(nilEncoding: .intentionalOnly)
let nested: [String: String?] = ["key1": nil, "key2": "value"]
let parameters = ["nested": nested]

// When
let result = Result<String, any Error> { try encoder.encode(parameters) }

// Then
XCTAssertEqual(result.success, "nested%5Bkey1%5D=null&nested%5Bkey2%5D=value")
}

func testThatSpacesCanBeEncodedAsPluses() {
// Given
let encoder = URLEncodedFormEncoder(spaceEncoding: .plusReplaced)
Expand Down Expand Up @@ -1246,3 +1323,22 @@ private struct FailingOptionalStruct: Encodable {
}
}
}

private struct IntentionalNilTestStruct: Encodable {
let encodeNil: String? = nil
let encodeValue: String = "encoded"
let encodeIfPresentNil: String? = nil
let encodeIfPresentValue: String? = "present"

func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(encodeNil, forKey: .encodeNil)
try container.encode(encodeValue, forKey: .encodeValue)
try container.encodeIfPresent(encodeIfPresentNil, forKey: .encodeIfPresentNil)
try container.encodeIfPresent(encodeIfPresentValue, forKey: .encodeIfPresentValue)
}

enum CodingKeys: String, CodingKey {
case encodeNil, encodeValue, encodeIfPresentNil, encodeIfPresentValue
}
}