question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

[REQ] [Swift5] Fully control encoding of optional parameter

See original GitHub issue

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. 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 a PATCH”) => 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.

Issue Analytics

  • State:closed
  • Created 2 years ago
  • Comments:10 (10 by maintainers)

github_iconTop GitHub Comments

1reaction
4brunucommented, Dec 13, 2021

I like it 👍 For me you can create the PR

0reactions
jarrodparkescommented, Dec 16, 2021

@4brunu ^ created the PR. it feels like a rough implementation, but seems to be generating what we’ve discussed

Read more comments on GitHub >

github_iconTop Results From Across the Web

ios - Swift - encode URL - Stack Overflow
To encode a parameter in URL I find using .alphanumerics character set the easiest option: let urlEncoded = value.
Read more >
Swift Style Guide - Google
File Encoding. Source files are encoded in UTF-8. Whitespace Characters. Aside from the line terminator, the Unicode horizontal space character ( U ...
Read more >
Working with Alamofire in Swift - AppyPie
Alamofire will automatically encode parameters with URLEncoding.default, i.e. the URL encoding that's common on the web. In the next section, ...
Read more >
Ultimate Guide to JSON Parsing with Swift - Ben Scheirman
For the comments field, the default implementation of Encodable uses encodeIfPresent on optional values. This means keys will be missing from ...
Read more >
Optional | Apple Developer Documentation
To conditionally bind the wrapped value of an Optional instance to a new variable, use one of the optional binding control structures, including...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found