0

Say we've got a cursor based paginated API where multiple endpoints can be paginated. The response of such an endpoint is always as follows:

{
    "nextCursor": "someString",
    "PAYLOAD_KEY": <generic response>

}

So the payload always returns a cursor and the payload key depends on the actual endpoint we use. For example if we have GET /users it might be users and the value of the key be an array of objects or we could cal a GET /some-large-object and the key being item and the payload be an object.
Bottom line the response is always an object with a cursor and one other key and it's associated value.

Trying to make this generic in Swift I was thinking of this:

public struct Paginable<Body>: Codable where Body: Codable {
    public let body: Body
    public let cursor: String?

    private enum CodingKeys: String, CodingKey {
        case body, cursor
    }
}

Now the only issue with this code is that it expects the Body to be accessible under the "body" key which isn't the case.

We could have a struct User: Codable and the paginable specialized as Paginable<[Users]> where the API response object would have the key users for the array.

My question is how can I make this generic Paginable struct work so that I can specify the JSON payload key from the Body type?

Majster
  • 3,611
  • 5
  • 38
  • 60
  • write a custom decoding init, list all keys and use the one that isn't `cursor`? – Sulthan Nov 25 '22 at 08:13
  • At first I thought it's the problem of decoding [any generic value](https://stackoverflow.com/questions/47472197/how-to-use-swift-jsondecode-with-dynamic-types/47496656#47496656) but it seems the kind of values you're going to expect here is limited, but still quite "dynamic". Can't you derive which kind of payload to expect according to the value in `nextCursor`? – DarkDust Nov 25 '22 at 08:23
  • @Sulthan that could work. How does one list all keys? – Majster Nov 25 '22 at 09:05
  • @DarkDust in this case it's not quite dynamic indeed. When you specialize the struct you know exactly what you're getting. The only thing is providing the custom key that has to do with the type that is used in specialization of the struct. – Majster Nov 25 '22 at 09:05
  • So basically you have a limited number of types and then also a limited number of key values. Sounds very similar to an enum and/or using a switch statement – Joakim Danielson Nov 25 '22 at 11:02
  • @JoakimDanielson yes but that kinda defeats the point of the generics and this approach. Using an enum means this logic can never be made general and extracted into a framework somewhere but has to be tweaked for every project it's used in. I'm wondering if it's possible to make it generic – Majster Nov 25 '22 at 11:54
  • Ok so it is not a limited number of types then, I just got that impression. – Joakim Danielson Nov 25 '22 at 11:58

2 Answers2

1

The simplest solution I can think of is to let the decoded Body to give you the decoding key:

protocol PaginableBody: Codable {
    static var decodingKey: String { get }
}

struct RawCodingKey: CodingKey, Equatable {
    let stringValue: String
    let intValue: Int?

    init(stringValue: String) {
        self.stringValue = stringValue
        intValue = nil
    }

    init(intValue: Int) {
        stringValue = "\(intValue)"
        self.intValue = intValue
    }
}

struct Paginable<Body: PaginableBody>: Codable {
    public let body: Body
    public let cursor: String?

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: RawCodingKey.self)
        body = try container.decode(Body.self, forKey: RawCodingKey(stringValue: Body.decodingKey))
        cursor = try container.decodeIfPresent(String.self, forKey: RawCodingKey(stringValue: "nextCursor"))
    }
}

For example:

let jsonString = """
{
    "nextCursor": "someString",
    "PAYLOAD_KEY": {}
}
"""
let jsonData = Data(jsonString.utf8)

struct SomeBody: PaginableBody {
    static let decodingKey = "PAYLOAD_KEY"
}

let decoder = JSONDecoder()
let decoded = try? decoder.decode(Paginable<SomeBody>.self, from: jsonData)
print(decoded)

Another option is to always take the "other" non-cursor key as the body:

struct Paginable<Body: Codable>: Codable {
    public let body: Body
    public let cursor: String?

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: RawCodingKey.self)

        let cursorKey = RawCodingKey(stringValue: "nextCursor")

        cursor = try container.decodeIfPresent(String.self, forKey: cursorKey)

        // ! should be replaced with proper decoding error thrown
        let bodyKey = container.allKeys.first { $0 != cursorKey }!
        body = try container.decode(Body.self, forKey: bodyKey)
    }
}

Another possible option is to pass the decoding key directly to JSONDecoder inside userInfo and then access it inside init(from:). That would give you the biggest flexibility but you would have to specify it always during decoding.

Sulthan
  • 128,090
  • 22
  • 218
  • 270
  • I ended up using the "other non-cursor" approach and while I agree it's not perfect I prefer it over having yet another constraint (what you proposed with PaginableBody). – Majster Nov 26 '22 at 08:06
-2

You can use generic model with type erasing, for example

struct GenericInfo: Encodable {

  init<T: Encodable>(name: String, params: T) {
      valueEncoder = {
        var container = $0.container(keyedBy: CodingKeys.self)
        try container.encode(name, forKey: . name)
        try container.encode(params, forKey: .params)
      }
   }

   // MARK: Public

   func encode(to encoder: Encoder) throws {
       try valueEncoder(encoder)
   }

   // MARK: Internal

   enum CodingKeys: String, CodingKey {
       case name
       case params
   }

   let valueEncoder: (Encoder) throws -> Void
}
Boris
  • 7
  • 2
  • I'm not sure how this solves the problem. If you could explain in a bit more detail I'd appreciate it. – Majster Nov 25 '22 at 09:05
  • You are not storing variables. You are passing them to encoder or decoder via closure. That's why you don't need Generic struct, only generic initialiser – Boris Nov 25 '22 at 09:16
  • I still don't quite see how I can use this to solve the problem I'm having. If you could add a more concrete example on how to tie everything together that would be handy. – Majster Nov 25 '22 at 10:22