5

Context

I am working with the Firebase Database REST API and JSONDecoder / JSONEncoder. It's been working pretty well so far. However for removing data the expected returned response is null, and JSONDecoder doesn't seem to like that very much.

This is the type of query I am sending via Postman and what I am getting back (sensitive data excluded).

DELETE /somedata/-LC03I3oHcLhQ/members/ZnWsJtrZ5UfFS6agajbL2hFlIfG2.json
content-type: application/json
cache-control: no-cache
postman-token: ab722e0e-98ed-aaaa-bbbb-123f64696123
user-agent: PostmanRuntime/7.2.0
accept: */*
host: someapp.firebaseio.com
accept-encoding: gzip, deflate
content-length: 39


HTTP/1.1 200
status: 200
server: nginx
date: Thu, 02 Aug 2018 21:53:27 GMT
content-type: application/json; charset=utf-8
content-length: 4
connection: keep-alive
access-control-allow-origin: *
cache-control: no-cache
strict-transport-security: max-age=31556926; includeSubDomains; preload

null

As you can see the response code is 200 and the body is null.

Error

When I receive the response this is the error I get :

Swift.DecodingError.dataCorrupted(Swift.DecodingError.Context(codingPath: [], debugDescription: "The given data was not valid JSON.", underlyingError: Optional(Error Domain=NSCocoaErrorDomain Code=3840 "JSON text did not start with array or object and option to allow fragments not set." UserInfo={NSDebugDescription=JSON text did not start with array or object and option to allow fragments not set.}))))

I tried creating a custom type (NoReply) to handle this as per a previous post but to no-avail.

Code

This is where the error happens :

        resource: {
            let decoder = JSONDecoder()
            decoder.dateDecodingStrategy = .iso8601
            return try decoder.decode(Resource.self, from: $0)
        },
        error: {
            let decoder = JSONDecoder()
            return try decoder.decode(FirebaseError.self, from: $0)
        }

So apparently even if I feed a custom NoReply type (as per the post mentioned above) JSONDecoder doesn't like null.

Any suggestions ?


As a side note this is what their documentation says about the response for a DELETE operation :

A successful DELETE request is indicated by a 200 OK HTTP status code with a response containing JSON null.

ReyAnthonyRenacia
  • 17,219
  • 5
  • 37
  • 56
Nick
  • 321
  • 3
  • 16
  • It sounds like you should go with the documentation - check for "null" in the response. – Doug Stevenson Aug 02 '18 at 22:53
  • Have you tried putting the highest-level into an enum with cases .data, .null and decode that manually with a singleValueContainer to `String?` and if that fails decode normally and return .data(myStructure). – Fabian Aug 02 '18 at 23:01
  • @DougStevenson, yeah, sure :). Unfortunately JSONDecoder will fail before it gives you any access to the underlying data. – Nick Aug 02 '18 at 23:15
  • @Purpose, yes, I tried to implement my own ` init(from decoder: Decoder)` by using decoder.unkeyedContainer() or decoder.singleValueContainer(). But the failure point it actually before that. I could try and catch the returned `raw` data in the client, but I was hoping for a nicer solution. Thanks ! – Nick Aug 02 '18 at 23:24
  • @Nick have you tried decoding as an optional? – Fabian Aug 02 '18 at 23:25
  • @Purpose, yes I have. The client code unfortunately throws an error before that. The nasty part though is that it's actually not an "empty response", which the client could deal with. It's a response that contains the single string "null". So I have to branch that specific case out and return Data() -- which is what the code does in case the response is actually empty but with a valid 200 response status. No way to neatly handle that with JSONDecoder, without allowing fragements.Maybe Doug's answer wasn't so silly after all ;). – Nick Aug 02 '18 at 23:45
  • Why do you want to decode the response anyways? The documentation says you get null, so you won't get any object to deal with. So just go with something like this `if responseCode == 200 && String(data: responseData, encoding: .utf8) == "null" { /* success */ }` for that particular endpoint – heyfrank Aug 03 '18 at 09:55
  • @fl034 : at that level the code is very specific to handling things purely related to HTTP request / response concerns. What each endpoint expects in terms of query parameters, body, etc. and response values is handled higher up. So I have to convert that data received back to a resource. But your suggestion is very similar to what I used, instead transforming it to an empty JSON object (which gets correctly understood by JSONDecoder and passed up the food chain). Thanks ! – Nick Aug 03 '18 at 16:30

2 Answers2

6

Quick follow up

After a few successful hacks, the most elegant solution I came up with was the combination of :

  • using NoReply (as described by Zoul)
  • converting the null string to an empty JSON object structure ({})

So first, the conversion :

            if "null" == String(data: data, encoding: .utf8) {
                let json = Data("{}".utf8)

which is then fed back to the closure handling the request's response :

        resource: {
            let decoder = JSONDecoder()
            return try decoder.decode(Resource.self, from: $0)
        },

where the Resource is none other than :

public struct NoReply: Decodable {}

This works great now and allows me to handle the DELETE cases and GET cases on a non-existent object where it returns null.

Thanks for the help !

Nick
  • 321
  • 3
  • 16
  • 1
    Tip: You can convert a `String` to `Data` using `Data(string.utf8)`. – Jon Shier Aug 03 '18 at 22:55
  • @JonShier : updated the post with your suggestion. Thanks. – Nick Aug 06 '18 at 19:28
  • @Nick, how do you handle null value for any key? suppose, the JSON is { "somekey":null }. . Here somekey is a String? optional type. – Milan Kamilya Nov 19 '19 at 14:29
  • @MilanKamilya, I only had issues with null fragments returned by the Firebase APIs. For a `null` value of a particular key, I just used the `try container.decodeIfPresent` of my Decodable data models and it does this for me automatically (granted the property you're decoding is an Optional). – Nick Nov 19 '19 at 20:53
  • @Nick, I had marked the property as optional too, but it sends ValueNotFound error from JSONDecoder. By marking optional, it ensures that KeyNotFound error will not occur. So, I had to remove all null and corresponding properties before feeding to JSONDecoder. – Milan Kamilya Nov 20 '19 at 02:47
  • @MilanKamilya, I am not sure I understand. [`decodeIfPresent(_:forKey:)`](https://developer.apple.com/documentation/swift/keyeddecodingcontainer/2893218-decodeifpresent) should specifically handle this case. As per the doc : "This method returns nil if the container does not have a value associated with key, or if the value is null." However, if you just use `decode(_:forKey:)` then yeah, it will throw an error. – Nick Nov 20 '19 at 20:26
  • You are right decodeIfPresent(_:forKey:) should recognize that null. But, checking each property for each rest API is a tedious job. – Milan Kamilya Nov 21 '19 at 10:59
5

Unfortunately JSONDecoder does not expose the underlying JSONSerialization option (.allowFragments) that would support JSON fragments like a solo null value. You could try transforming the response or just using JSONSerialization directly. Unfortunately there's nothing elegant to be done here.

Jon Shier
  • 12,200
  • 3
  • 35
  • 37
  • Supposedly this was changed in https://forums.swift.org/t/allowing-top-level-fragments-in-jsondecoder/11750/10 GitHub link: https://github.com/apple/swift/pull/30615/files#diff-20486a3e986e2ca169265f8fb80e4e834bfbf4a1a691e109474391e7fd4c608aR1203 Still getting errors with null responses though :( – siefix May 28 '21 at 19:56