Remote::request() for Multipart/form-data

Hi all,

I want to send form data from a Uniform form directly into NocoDB (something like Airtaible). I have already managed to send the content against the API of NocoDB via a custom action.
But unfortunately NocoDB needs a multipart/form-data POST request if you want to send content that affects multiple tables. I have already adjusted the header in multipart/form-data and experimented a bit. But unfortunately I can’t find a way to send the content as multipart/form-data. In Postman it works fine.

I like to attach my CustomAction. Is it at all possible to send multipart/form-data from Remote::request()? Or do I have to go another way?

<?php

namespace Uniform\Actions;

use Exception;
use Kirby\Toolkit\A;
use Kirby\Http\Remote;
use Kirby\Toolkit\I18n;

class NocodbAction extends Action
{
    /**
     * Call a webhook
     */
    public function perform()
    {
        $url = $this->requireOption('url');
        $only = $this->option('only');
        $except = $this->option('except');
        $escape = $this->option('escapeHtml', true);

        if (is_array($only)) {
            $data = [];
            foreach ($only as $key) {
                $data[$key] = $this->form->data($key, '', $escape);
            }
        } else {
            $data = $this->form->data('', '', $escape);
        }

        if (is_array($except)) {
            foreach ($except as $key) {
                unset($data[$key]);
            }
        }

        $params = $this->option('params', []);
        // merge the optional 'static' data from the action array with the form data
        $data = array_merge(A::get($params, 'data', []), $data);
        $params['data'] = $this->transformData($data);

        if ($this->option('json') === true) {
            $headers = ['Content-Type: application/json'];
            $params['data'] = json_encode($params['data'], JSON_UNESCAPED_SLASHES);
        } else {
            $headers = ['Content-Type: multipart/form-data'];
            #$headers = [];
        }

        $params['headers'] = array_merge(A::get($params, 'headers', []), $headers);

        try {
            $test = $this->request($url, $params);
            $wurst = $test;
        } catch (Exception $e) {
            $this->fail(I18n::translate('uniform-webhook-error') . $e->getMessage());
        }
    }

    protected function getElementById($array, $id)
    {
        foreach ($array as $element) {
            if ($element->Id == intval($id)) {
                if (property_exists($element, "Title")) {
                    return ["Id" => $element->Id, "Title" => $element->Title];
                } elseif (property_exists($element, "Bereichsname")) {
                    return ["Id" => $element->Id, "Title" => $element->Bereichsname];
                } else {
                    return $element;
                }
            }
        }
        return null; // If element with given ID not found, return null
    }

    protected function getArrayByIDs($array, $ids)
    {
        $result = [];
        foreach ($ids as $id) {
            $result[] = $this->getElementById($array, $id);
        }
        return $result;
    }

    protected function transformData(array $data)
    {
        $nocodb = $this->options["nocodb_options"];
        return [
            "Email" => $data['email'],
            "Vorname" => $data['firstname'],
            "Nachname" => $data['lastname'],
            "Geburtstag" => $data['birthday'],
            "Handynummer" => $data['mobile'],
            "Adresse" => $data['adress'],
            "Stadt" => $data['city'],
            "PLZ" => (int) $data['plz'],
            "Aufbau" => $data["availability_pre_event"] == "on" ? true : false,
            "Verfügbarkeit_Anmerkung" => $data['availability_message'],
            "Bereichswunsch_Anmerkung" => $data["area_preference_message"],
            "Food" => $this->getElementById($nocodb["food"], $data["food"]),
            "Kleidung" => $this->getElementById($nocodb["clothing"], $data["clothing"]),
            "Availability" => $this->getArrayByIDs($nocodb["availability"], $data["availability"]),
            "Preverence 1" => $this->getElementById($nocodb["preference"], $data["area_preference"]),
            "Preverence 2" => $this->getElementById($nocodb["preference"], $data["area_second_preference"]),
        ];
    }

    /** 
     *
     * @param  string $url
     * @param  array $params
     * @return RemoteResponse
     */
    protected function request($url, $params)
    {
        return Remote::request($url, $params);
    }
}

Could you point to the API documentation for this multipart/form-data API request?

From your code example, I can’t really see what you are sending in your request, so would be interesting to see what is finally contained in the $params array you send.

From Postman, you can also export the successful request as PHP code. Would be helpful if you post this here as well.

This is also another challenge. There is no documentation for this kind of call.
I asked the team at NocoDB how to make this work with just one API call. They told me to do it exactly like the internal forms of NocoDB. So I created an appropriate form in NocoDB and took the API call from Google DevTools when the custom form is sent. This then also fit into Postman.

<?php

$curl = curl_init();

curl_setopt_array($curl, array(
  CURLOPT_URL => 'https://nocodb.example.com/api/v1/db/public/shared-view/3659c3ec-d548-43ac-88c5-a34097ae76ca/rows',
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_ENCODING => '',
  CURLOPT_MAXREDIRS => 10,
  CURLOPT_TIMEOUT => 0,
  CURLOPT_FOLLOWLOCATION => true,
  CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
  CURLOPT_CUSTOMREQUEST => 'POST',
  CURLOPT_POSTFIELDS => array('data' => '{
    "Vorname": "Hans",
    "Nachname": "Wurst",
    "Email": "hanswurst@gmail.com",
    "Handynummer": "012345678",
    "Adresse": "Mustermannstraße 135",
    "Geburtstag": "2023-03-07",
    "Stadt": "Frankfurt",
    "PLZ": 123123,
    "Aufbau": true,
    "Verfügbarkeit_Anmerkung": "qweqeqeqweqe",
    "Bereichswunsch_Anmerkung": "qweqeqweqweqwe",
    "Food": {
        "Id": 3,
        "Title": "Vegan"
    },
    "Kleidung": {
        "Id": 2,
        "Title": "T-Shirt M"
    },
    "Preverence 1": {
        "Id": 5,
        "Bereichsname": "Fototeam"
    },
    "Preverence 2": {
        "Id": 10,
        "Bereichsname": "Taskforce"
    }
}'),
  CURLOPT_HTTPHEADER => array(
    'xc-token: SecretToken'
  ),
));

$response = curl_exec($curl);

curl_close($curl);
echo $response;

And here is the Postman Console Log:

POST https://nocodb.example.com/api/v1/db/public/shared-view/3659c3ec-d548-43ac-88c5-a34097ae76ca/rows: {
  
  "Request Headers": {
    "xc-token": "SecretToken",
    "user-agent": "PostmanRuntime/7.31.3",
    "accept": "*/*",
    "postman-token": "3b2ca799-9ab3-4221-b58b-ebd221c3cbec",
    "host": "nocodb.example.com",
    "accept-encoding": "gzip, deflate, br",
    "connection": "keep-alive",
    "content-type": "multipart/form-data; boundary=--------------------------095261739504002641097116",
    "content-length": "810"
  },
  "Request Body": {
    "data": "{\n    \"Vorname\": \"Hans\",\n    \"Nachname\": \"Wurst\",\n    \"Email\": \"hanswurst@gmail.com\",\n    \"Handynummer\": \"012345678\",\n    \"Adresse\": \"Mustermannstraße 135\",\n    \"Geburtstag\": \"2023-03-07\",\n    \"Stadt\": \"Frankfurt\",\n    \"PLZ\": 123123,\n    \"Aufbau\": true,\n    \"Verfügbarkeit_Anmerkung\": \"qweqeqeqweqe\",\n    \"Bereichswunsch_Anmerkung\": \"qweqeqweqweqwe\",\n    \"Food\": {\n        \"Id\": 3,\n        \"Title\": \"Vegan\"\n    },\n    \"Kleidung\": {\n        \"Id\": 2,\n        \"Title\": \"T-Shirt M\"\n    },\n    \"Preverence 1\": {\n        \"Id\": 5,\n        \"Bereichsname\": \"Fototeam\"\n    },\n    \"Preverence 2\": {\n        \"Id\": 10,\n        \"Bereichsname\": \"Taskforce\"\n    }\n}"
  },
  "Response Headers": {
    "access-control-allow-origin": "*",
    "content-length": "620",
    "content-type": "application/json; charset=utf-8",
    "date": "Mon, 10 Apr 2023 08:40:04 GMT",
    "etag": "W/\"26c-FQRUwcJryNqI0o2yDIBwrakBYVU\"",
    "x-powered-by": "Express"
  },
  "Response Body": "{\"Fullname\":\"Hans Wurst\",\"Id\":91,\"CreatedAt\":\"2023-04-10T08:40:04.761Z\",\"UpdatedAt\":\"2023-04-10T08:40:04.761Z\",\"Handynummer\":\"012345678\",\"Adresse\":\"Mustermannstraße 135\",\"Stadt\":\"Frankfurt\",\"PLZ\":\"123123\",\"Aufbau\":true,\"Geburtstag\":\"2023-03-07\",\"Verfügbarkeit_Anmerkung\":\"qweqeqeqweqe\",\"Bereichswunsch_Anmerkung\":\"qweqeqweqweqwe\",\"Email\":\"hanswurst@gmail.com\",\"Vorname\":\"Hans\",\"Nachname\":\"Wurst\",\"nc_w7bn___Kleidung_id\":2,\"nc_w7bn___Food_id\":3,\"nc_w7bn___Bereiche_id\":null,\"nc_w7bn___Bereiche_id1\":null,\"nc_w7bn___Bereiche_id2\":null,\"nc_w7bn___Bereiche_id3\":null,\"nc_w7bn___Bereiche_id4\":5,\"nc_w7bn___Bereiche_id5\":10}"
}

and finally the controller code:


return function ($kirby, $page) {
    $form = new Form([
        'firstname' => [
            'rules' => ['required'],
            'message' => 'Bitte füge deinen Vorname hinzu.',
        ],
        'lastname' => [
            'rules' => ['required'],
            'message' => 'Bitte füge deinen Nachname hinzu.',
        ],
        'birthday' => [
            'rules' => ['required'],
            'message' => 'Bitte trage deinen Geburtstag ein.',
        ],
        'email' => [
            'rules' => ['required', 'email'],
            'message' => 'Deine E-mail Adresse ist notwendig.',
        ],
        'mobile' => [
            'rules' => ['required'],
            'message' => 'Bitte trage deine Handynummer ein.',
        ],
        'adress' => [
            'rules' => ['required'],
            'message' => 'Bitte trage deine Straße und Hausnummer ein.',
        ],
        'city' => [
            'rules' => ['required'],
            'message' => 'Bitte trage deine Stadt ein.',
        ],
        'plz' => [
            'rules' => ['required'],
            'message' => 'Bitte trage deine Postleitzahl ein.',
        ],
        'availability' => [
            'rules' => ['required'],
            'message' => 'Bitte wähle deine Verfügbarkeit aus.',
        ],
        'availability_message' => [],
        'availability_pre_event' => [
            'rules' => ['required'],
            'message' => 'Bitte sage uns, ob du schon Interesse an der Aufbauwoche hast.',
        ],
        'food' => [
            'rules' => ['required'],
            'message' => 'Bitte sage uns, was für Essen du möchtest.',
        ],
        'clothing' => [
            'rules' => ['required'],
            'message' => 'Bitte sage uns, welche Art von T-Shirt und Größe du gerne hättst.',
        ],
        'area_preference' => [
            'rules' => ['required'],
            'message' => 'Bitte wähle deine erste Präferenz aus.',
        ],
        'area_second_preference' => [
            'rules' => ['required'],
            'message' => 'Bitte wähle deine zweite Präferenz aus.',
        ],
        'area_preference_message' => [],
        'dsgvo' => [
            'rules' => ['in' => [["on"]]],
            'message' => 'Bitte stimme den Datenschutzbestimmungen zu, damit wir deine Anfrage bearbeiten dürfen.',
        ],
    ]);

    if ($kirby->request()->is('POST')) {
        $form->NocodbAction([
            'url' => $page->nocodb_url()->value(),
            'json' => false,
            'params' => [
                'method' => 'POST',
                'headers' => ['xc-token: ' . $page->nocodb_api_token()->value()]
            ],
            'nocodb_options' => [
                'availability' => $page->api_availability(),
                'clothing' => $page->api_clothing(),
                'food' => $page->api_food(),
                'preference' => $page->api_area_preference(),
            ]
        ]);
    }
    if ($kirby->request()->is('POST') and $page->form_toggle_debug()->toBool()) {
        $form->logAction([
            'file' => kirby()->roots()->site() . '/logging/helperform.log',
        ]);
    }

    $form->removeField("dsgvo");
    return compact('form');
};

I hope the helps to understand what I have in mind =)

Mm, however, as I already stated above, would be interesting to see what you are finally passing to the Remote::request() method. Also, what error is then returned? It seems, for example, that the multipart/form-data also needs the boundary attribute.

So the first step to debugging this, would be to move the request out of your form handling and do the request in a template or so, where you can easily dump the result.

So I think you’ve already pushed me in the right direction. The backend expected the body as a JSON string. I had to adjust that in the cURL request. Then it worked fine. Many thanks for thinking along.

 protected function request($url, $params)
    {

        $curl = curl_init();

        curl_setopt_array($curl, array(
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_ENCODING => '',
            CURLOPT_MAXREDIRS => 10,
            CURLOPT_TIMEOUT => 0,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
            CURLOPT_CUSTOMREQUEST => 'POST',
            CURLOPT_POSTFIELDS => array('data' => json_encode($params['data'], JSON_UNESCAPED_SLASHES)),
            CURLOPT_HTTPHEADER => $params["headers"],
        ));

        $response = curl_exec($curl);

        curl_close($curl);
        return $response;
    }