[REQ] [Swift5] Fully control encoding of optional parameter
Created by: jarrodparkes
Is your feature request related to a problem? Please describe.
Problem 1
Consider an endpoint called PATCH /task/{task-id}
— this comes from an internal API that I use. PATCH /task/{task-id}
states that passing null
for any task property in the request body will "clear the value".
Here's the spec for the payload...
{
"title": "TaskPatchPayload",
"type": "object",
"properties": {
"name": {
"type": "string",
"nullable": true
},
"due_at": {
"type": "string",
"format": "date-time"
"nullable": true
},
"completed_at": {
"type": "string",
"format": "date-time"
"nullable": true
}
}
}
Written as-is, none of the properties are required, but each is nullable. This means the generated model would look like this...
struct TaskPatchPayload: Codable {
let name: String?
let dueAt: Date?
let completedAt: Date?
// ... CodingKeys redacted
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(name, forKey: .name)
try container.encodeIfPresent(dueAt, forKey: .dueAt)
try container.encodeIfPresent(completedAt, forKey: .completedAt)
}
}
The problem with this example is that null
can never be explicitly encoded, so you can never "clear the value" of a property. encodeIfPresent
only encodes a value if it is non-nil. Stumbling into this problem is what caused me to write #10926 (closed). But, after closer examination, you can explicitly encode null
. Stay with me.
Problem 2
If you modify the model spec so that properties are nullable AND required, then the generated model will allow null
to be explicitly encoded. Here's what the spec would look like...
{
"title": "TaskPatchPayload",
"type": "object",
"properties": {
"name": {
"type": "string",
"nullable": true
},
"due_at": {
"type": "string",
"format": "date-time"
"nullable": true
},
"completed_at": {
"type": "string",
"format": "date-time"
"nullable": true
}
},
"required": ["name", "due_at", "completed_at"]
}
And the generated model...
struct TaskPatchPayload: Codable {
let name: String?
let dueAt: Date?
let completedAt: Date?
// ... CodingKeys redacted
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
try container.encode(dueAt, forKey: .dueAt)
try container.encode(completedAt, forKey: .completedAt)
}
}
In this model encode
is used, so the property is encoded regardless of whether it is nil or non-nil. Problem solved, right? Well... not quite. Remember, this payload is for PATCH /task/{task-id}
, and this API and many reputable sources state...
The HTTP PATCH request method applies partial modifications to a resource.
But, since every property in this model is always encoded, you can never do a partial modification. For example, your payloads might look like this...
{
"name": null,
"due_at": null,
"completed_at": null
}
{
"name": "My new task",
"due_at": null,
"completed_at": "2021-12-03T21:30:00Z"
}
In each example, every property is present. This isn't a PATCH
and you also have the adverse effect of null
properties "clearing a value" even when that may not be your intention. So now what?
Describe the solution you'd like
I'd like to propose a simple property extension (ex: x-explicit-null-encodable
) and a corresponding custom type that will generate models that give developers full control of how/when they want to encode null
.
I've got some sample code running in a Playground that already looks promising (full example):
/// A value that can be included in a payload (`.explicitNone` or `.some`)
/// or completely absent (`.none`). Intended for request payloads.
public enum ExplicitNullEncodable<Wrapped> {
case none
case explicitNone
case some(Wrapped)
}
extension ExplicitNullEncodable: Codable where Wrapped: Codable {
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let value = try? container.decode(Wrapped.self) {
self = .some(value)
} else if container.decodeNil() {
self = .explicitNone
} else {
self = .none
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .none: return
case .explicitNone: try container.encodeNil()
case .some(let wrapped): try container.encode(wrapped)
}
}
}
struct TaskPatchPayload: Codable {
let name: ExplicitNullEncodable<String>
let dueAt: ExplicitNullEncodable<Date>
let completedAt: ExplicitNullEncodable<Date>
public enum CodingKeys: String, CodingKey, CaseIterable {
case name = "name"
case dueAt = "due_at"
case completedAt = "completed_at"
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch name {
case .none:
break
case .explicitNone, .some:
try container.encode(name, forKey: .name)
}
switch dueAt {
case .none:
break
case .explicitNone, .some:
try container.encode(dueAt, forKey: .dueAt)
}
switch completedAt {
case .none:
break
case .explicitNone, .some:
try container.encode(completedAt, forKey: .completedAt)
}
}
}
Describe alternatives you've considered
- Always send every property ("use a
PUT
instead of aPATCH
") => possible, but unnecessarily complex for small changes - Tell my team to re-write the internal API => not likely
Additional context
I'm proposing a property extension, and not a generator option that would apply this customization to all optionals. I'm doing this because the default handling of optionals covers most cases. An API-wide customization seems too heavy-handed when most don't need this level of control.