0

I'm trying to set up an API service. I have a simple login call that looks like this:

enum HttpMethod: Equatable {
    case get([URLQueryItem])
    case put(Data?)
    case post(Data?)

    var name: String {
        switch self {
        case .get: return "GET"
        case .put: return "PUT"
        case .post: return "POST"
        }
    }
}

struct Request<Response> {
    let url: URL
    let method: HttpMethod
    var headers: [String: String] = [:]
}

extension Request {
    static func postUserLogin(email: String, password:String) -> Self {
        var params = [String: Any]()
        params["username"] = email
        params["password"] = password
        let data = params.jsonData

        return Request(
            url: URL(string: NetworkingConstants.USER_LOGIN)!,
            method: .post(
                try? JSONEncoder().encode(data) //should this be something else???
            )
        )
    }
}

extension Dictionary {
    var jsonData: Data? {
        return try? JSONSerialization.data(withJSONObject: self, options: [.prettyPrinted])
    }
}

extension Request {
    var urlRequest: URLRequest {
        var request = URLRequest(url: url)

        switch method {
        case .post(let data), .put(let data):
            request.httpBody = data

        case let .get(queryItems):
            //removed for brevity
        default:
            break
        }

        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.allHTTPHeaderFields = headers
        request.httpMethod = method.name
        return request
    }
}

extension URLSession {
    func decode<Value: Decodable>(
        _ request: Request<Value>,
        using decoder: JSONDecoder = .init()
    ) async throws -> Value {
        let decoded = Task.detached(priority: .userInitiated) {
            let (data, _) = try await self.data(for: request.urlRequest)
            try Task.checkCancellation()
            return try decoder.decode(Value.self, from: data)
        }
        return try await decoded.value
    }
}

The problem is, instead of the Body of the request being sent to the server looking like this:

{
    "username": "me@you.com",
    "password": "abcdef"
}

It looks like this:

"eyJwYXNzd29yZCI6ImFhYWEiLCJ1cZSI6InRhQHQuY29tIn0="

What am I doing wrong? Here's how I make the call:

let request: Request<UserLoginResponse> = .postUserLogin(email: "me@you.com", password: "abcdef")
let response = try? await URLSession.shared.decode(request)

request.httpBody looks like this:

▿ Optional<Data>
  ▿ some : 74 bytes
    - count : 74
    ▿ pointer : 0x00007fcc74011200
      - pointerValue : 140516096283136
soleil
  • 12,133
  • 33
  • 112
  • 183
  • Are you certain that what you've given as the output (`"eyJw...`) is the real output? That looks corrupted. It's the beginning of Base64-encoded JSON, but looks like it's missing a couple of characters in the middle, and it decodes to a password "aaaa" rather than "abcdf". – Rob Napier Jul 10 '23 at 22:20

2 Answers2

3

Scott Thompson's answer is correct in the general case, and worth learning. In this specific case you can make it a bit simpler because you happen to have a [String: String] dictionary, which is already Encodable. So you don't need an extra encoding type:

static func postUserLogin(email: String, password:String) -> Self {
    var params = [String: String]()
    params["username"] = email
    params["password"] = password

    return Request(
        url: URL(string: NetworkingConstants.USER_LOGIN)!,
        method: .post(
            try? JSONEncoder().encode(params)
        )
    )
}

Your problem is that you're first JSONSerialization-encoding params into a Data that contains the JSON characters. You then JSONEncoder-encoding that Data, which by default Base64-encodes it. That's the "ey..." output you're getting. There's no need for two steps. You can get rid of jsonData entirely, and just use JSONEncoder directly.

Rob Napier
  • 286,113
  • 34
  • 456
  • 610
  • Thanks. This does work, but you're right, only for [String: String], and not [String: Any]. I guess I'll have to go the route on Scott's answer and define structs for each different type of inputs that I need to use. – soleil Jul 10 '23 at 22:41
  • 1
    If you have a JSON payload that you want structs for, do see https://app.quicktype.io. It will write all the code for you. – Rob Napier Jul 11 '23 at 00:01
2

JSONEncoder and JSONDecoder are designed to work directly with Swift types allowing you to skip the need to build generic dictionaries or arrays as you are doing where you have this code:

// You should use a Swift type instead of this
var params = [String: Any]()
params["username"] = email
params["password"] = password
let data = params.jsonData

In your case you should declare a struct and mark it as Codable then encode that (from a playground):

struct UserAuthentication: Codable {
    let username: String
    let password: String
}

let email = "fred"
let password = "foobar"

let userAuthentication = UserAuthentication(username: email, password: password)
let encoder = JSONEncoder()

do {
    let encoded = try encoder.encode(userAuthentication)
    print(String(data: encoded, encoding: .utf8)!)
} catch (let error) {
    print("Json Encoding Error \(error)")
}

Swift will represent your struct (in this case the UserAuthentication struct) as JSON using the names of the properties as the JSON keys and their values as the JSON values.

If you want to handle more complex cases, see:

Encoding and Decoding Custom Types

Scott Thompson
  • 22,629
  • 4
  • 32
  • 34
  • Thanks, this works. But what if I need to allow for a generic set of params in the `UserAuthentication` struct? Such as `let params: [String:Any]?` For example, if along with username and password, I need to pass to the backend some dictionary of extra data like `{"prefs": "cats", "age": 23}`. The keys will be strings but the values could be strings, ints, floats, etc. And the keys can vary. – soleil Jul 10 '23 at 22:50
  • If you really cannot know the list of keys and types, then you'll want to use something like a JSON type. Lots of people have written them (here's a small one: https://stackoverflow.com/questions/65901928/swift-jsonencoder-encoding-class-containing-a-nested-raw-json-object-literal/65902852#65902852) But you should make sure that's really your problem. Most server APIs take specific values or sometimes `[String:String]`, rather than "any key, with any JSON." (Nothing can encode every `[String:Any]` into JSON. JSON cannot encode a UIViewController or CBPeripheral, which are Any values.) – Rob Napier Jul 11 '23 at 13:48
  • You *could* use Dictionaries and Arrays and use [JSONSerialization](https://developer.apple.com/documentation/foundation/jsonserialization) instead of JSONEncoder/Decoder. But I generally prefer schemes that can preserve type information. – Scott Thompson Jul 11 '23 at 14:11