A picture takes 1000 words

So, I can post a simple message, what about a picture? That turned out to be a bit of a headache, dragging out of storage 20 year old memories of scraping email data with Perl.

Oh, Form data. Sigh.

I created a command line testing tool for some of the ideas before they go into APItizer called APIng. More notes are there.

References

Testing Calls

Making Tiny Test Images

Using NetCat

        # Start up netcat on port 8080 
        nc -l -p 8080 
        # if that does not work try, but that means calls will only resolve at http://localhost:8080
        nc -l localhost 8080  
        # or 
        brew install netcat
        netcat -l -p 8080
        # live dangerously if you get sick of restarting it
        # You might have to ^C a few times to catch it during a sleep
        while [ 1 ] ; do ; netcat -l -p 8080 ; sleep 1 ; done

Go to http://localhost:8080 and see the call show up there, run curl commands from a different terminal, etc. NetCat does not respond so make sure to include timeouts for calls that will wait for a response.

Checking Parsing

Does it parse? Service that will reply with json about what it thinks it got back semantically, rather than just a reflection.

cURL Examples To Crib From

cURL commands sent to BOTH NetCat and httpbin can be very instructive on what your code should be doing.

Mistakes were made

To post an image on Mastodon, FIRST comes posting the image file(s), getting back the ID(s) and then using the returned data to make a status post. I kept doing SOMETHING wrong. So this is why there is that reminder above that NetCat and https://httpbin.org/ are your friends. I looked into Postman, but it is ginormous.

First, when posting the image file…

Second when adding the MediaIDs

    public static func arrayToQueryItems(baseStringForKey:String, array:[CustomStringConvertible]) -> [URLQueryItem] {
        var queries:[URLQueryItem] = []
        queries.reserveCapacity(array.count)
        for item in array {
            queries.append(URLQueryItem(name: "\(baseStringForKey)[]", value: String(describing: item)))
        }

        return queries
    }

The background code in APItizer

//
//  APItizer
//  https://github.com/carlynorama/APItizer
//
//  MultiPartFormEncoder.swift
//  Created by Carlyn Maw on 2/13/23.
//

import Foundation

//MARK: Multipart Form Encoding
//https://www.rfc-editor.org/rfc/rfc7578
public enum MultiPartFormEncoder {
    
    public static func header(boundary:String) -> [String:String] {
        ["Content-Type": "multipart/form-data; boundary=\(boundary)"]
    }
    
    public static func makeBodyData(formItems:Dictionary<String, CustomStringConvertible>, withTermination:Bool = true) throws -> (boundary:String, body:Data) {
        let boundary = "Boundary--\(UUID().uuidString)"
        var bodyData = Data()
        for (key, value) in formItems {
            bodyData = try appendTextField(data: bodyData, label: key, value: String(describing: value), boundary: boundary)
        }
        if withTermination {
            bodyData = appendTerminationBoundary(data: bodyData, boundary: boundary)
        }
        return (boundary, bodyData)
    }

    //Media uploads will fail if fileName is not included, regardless of MIME/Type.
    public static func makeBodyData(stringItems:Dictionary<String, CustomStringConvertible>, attachments:[String:Attachable], withTermination:Bool = true) throws -> (boundary:String, body:Data) {
        let boundary = "Boundary--\(UUID().uuidString)"
        var bodyData = Data()
        for (key, value) in stringItems {
            bodyData = try appendTextField(data: bodyData, label: key, value: String(describing: value), boundary: boundary)
        }

        for (key, value) in attachments {
            bodyData = try appendDataField(data: bodyData, label: key, dataToAdd: value.data, mimeType: value.mimeType, fileName: value.fileName, boundary: boundary)
        }
        
        if withTermination {
            bodyData = appendTerminationBoundary(data: bodyData, boundary: boundary)
        }
        return (boundary, bodyData)
    }

    //TODO: All this copying... inout instead? make extension?

    static func appendTextField(data:Data, label key: String, value: String, boundary:String) throws -> Data {
        var copy = data
        let formFieldData = try textFormField(label:key, value:value, boundary:boundary)
        copy.append(formFieldData)
        return copy
    }

    static func appendDataField(data:Data, label key: String, dataToAdd: Data, mimeType: String, fileName:String? = nil, boundary:String) throws -> Data {
        var copy = data
        let formFieldData = try dataFormField(label:key, data: dataToAdd, mimeType: mimeType, fileName:fileName, boundary:boundary)
        copy.append(formFieldData)
        return copy
    }
    
    static func appendEncodable<T:Encodable>(data:Data, object:T, boundary:String) throws -> Data {
        var copy = data
        let queries = QueryEncoder.makeQueryItems(from: object)
        for query in queries {
            if let value = query.value {
                copy.append(try textFormField(label: query.name, value: value, boundary: boundary))
            }
        }
        return copy
    }

    static func appendTerminationBoundary(data:Data, boundary:String) -> Data {
        var copy = data
        let boundaryData = "--\(boundary)--".data(using: .utf8)
        copy.append(boundaryData!) //TODO throw instead
        return copy
    }


    static func textFormField(label key: String, value: String, boundary:String) throws -> Data {
        var fieldString = "--\(boundary)\r\n"
        fieldString += "Content-Disposition: form-data; name=\"\(key)\"\r\n"
        fieldString += "Content-Type: text/plain; charset=UTF-8\r\n"
        fieldString += "\r\n"
        fieldString += "\(value)\r\n"

       let fieldData = fieldString.data(using: .utf8)
       if fieldData == nil {
        throw APItizerError("couldn't make data from field \(key), \(value) with \(boundary)")
       }
        return fieldData!
    }

    static func dataFormField(label key: String, data: Data, mimeType: String, fileName:String? = nil, boundary:String) throws -> Data {
        var fieldData = Data()

        try fieldData.append("--\(boundary)\r\n")
        if let fileName {
            try fieldData.append("Content-Disposition: form-data; name=\"\(key)\"; filename=\"\(fileName)\";\r\n")
        } else {
            try fieldData.append("Content-Disposition: form-data; name=\"\(key)\"\r\n")
        }
        
        try fieldData.append("Content-Type: \(mimeType)\r\n")
        try fieldData.append("\r\n")
        fieldData.append(data)
        try fieldData.append("\r\n")

        return fieldData as Data
    }

}

Struct -> Dictionary helpers

//
//  APItizer
//  https://github.com/carlynorama/APItizer
//
//  DictionaryEncoder.swift
//  Created by Carlyn Maw on 2/13/23.
//

import Foundation


public enum DictionaryEncoder {
    
    public static func makeDictionary(from itemToEncode:Any) -> [String:String]? {
        let mirror = Mirror(reflecting: itemToEncode)
        var dictionary:[String:String] = [:]

        for child in mirror.children  {
            if let key:String = child.label {
                // print("key: \(key), value: \(child.value)")
                // print(child.value)
                // print(String(describing: child.value))
                if child.value is ExpressibleByNilLiteral  {
                    let typeDescription = object_getClass(child.value)?.description() ?? ""
                    if !typeDescription.contains("Null") && !typeDescription.contains("Empty") {
                        let (_, some) = Mirror(reflecting: child.value).children.first!
                        //print(some)
                        dictionary[key] = String(describing: some)
                    }
                } else {
                    dictionary[key] = String(describing: child.value)
                }
            }
            else { print("No key.") }
        }
        return dictionary
    }


        //Look at QueryEncoder for other clean up tasks.
    public static func makeDictionary(fromEncodable itemToEncode:Encodable) -> [String:String] {
        let encoder = JSONEncoder()
        func encode<T>(_ value: T) throws -> [String: Any] where T : Encodable {
            let data = try encoder.encode(value)
            return try JSONSerialization.jsonObject(with: data, options: .allowFragments) as! [String: Any]
        }
        guard let dictionary = try? encode(itemToEncode) else {
            //print("got nothing")
            return [:]
        }
        var cleanedUp:[String:String] = [:]
        for (key, value) in dictionary {
            var stringValue = "\(value)"
            if stringValue == "(\n)" { stringValue = "" }
            if stringValue.hasPrefix("(") { stringValue = stringValue.trimmingCharacters(in: CharacterSet(charactersIn: "()\n"))}
            //print(stringValue)
            if !stringValue.isEmpty  {
                cleanedUp[key] = "\(stringValue)"
            }
        }
        
        return cleanedUp
    }

}

Oh and then actually posting…


    func post_FormBody(baseUrl:URL, formData:Dictionary<String, CustomStringConvertible>, withAuth:Bool = true) async throws -> Data {
        let (boundary, dataToSend) = try MultiPartFormEncoder.makeBodyData(formItems: formData, withTermination: true)
        return try await post_FormBody(baseUrl:baseUrl, dataToSend:dataToSend, boundary: boundary, withAuth: withAuth)
    }

    func post_FormBody(baseUrl:URL, dataToSend:Data, boundary:String, withAuth:Bool = true) async throws -> Data {
        //cachePolicy: URLRequest.CachePolicy, timeoutInterval: TimeInterval
        var request = URLRequest(url: baseUrl)
        request.httpMethod = "POST"
        if withAuth { try addAuth(request: &request) }

        //necessary b/c not the default on upload. See APIng
        request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")

        return try await requestService.postData(urlRequest: request, data: dataToSend)

    }

With the same posting code over in the RequestService from last week since all the heavy lifting is in the URLRequest and the Data.