1<?php /** @noinspection DuplicatedCode */
2/**
3 * Plugin Airtable: Syncs Airtable Content to dokuWiki
4 *
5 * Syntax: <airtable>TYPE: xxx, TABLE: xxx, WHERE, .......</airtable> - will be replaced with airtable content
6 *
7 * @license    GPL 3 (https://www.gnu.org/licenses/quick-guide-gplv3.html)
8 * @author     Cameron Ward <cameronward007@gmail.com>
9 */
10// must be run within DokuWiki
11if(!defined('DOKU_INC')) die();
12
13/**
14 * Class InvalidAirtableString
15 *
16 * Handles the airtable query string exception and
17 *
18 */
19class InvalidAirtableString extends Exception {
20    public function errorMessage(): string {
21        return $this->getMessage();
22    }
23}
24
25/**
26 * All DokuWiki plugins to extend the parser/rendering mechanism
27 * need to inherit from this class
28 */
29class syntax_plugin_airtable extends DokuWiki_Syntax_Plugin {
30
31    /**
32     * Get the type of syntax this plugin defines.
33     *
34     * @param
35     * @return String <tt>'substition'</tt> (i.e. 'substitution').
36     * @public
37     * @static
38     */
39    function getType(): string {
40        return 'substition';
41    }
42
43    /**
44     * Define how this plugin is handled regarding paragraphs.
45     *
46     * <p>
47     * This method is important for correct XHTML nesting. It returns
48     * one of the following values:
49     * </p>
50     * <dl>
51     * <dt>normal</dt><dd>The plugin can be used inside paragraphs.</dd>
52     * <dt>block</dt><dd>Open paragraphs need to be closed before
53     * plugin output.</dd>
54     * <dt>stack</dt><dd>Special case: Plugin wraps other paragraphs.</dd>
55     * </dl>
56     * @param
57     * @return String <tt>'block'</tt>.
58     * @public
59     * @static
60     */
61    function getPType(): string {
62        return 'normal';
63    }
64
65    /**
66     * Where to sort in?
67     *
68     * @param
69     * @return Integer <tt>6</tt>.
70     * @public
71     * @static
72     */
73    function getSort(): int {
74        return 1;
75    }
76
77    /**
78     * Connect lookup pattern to lexer.
79     *
80     * @param $mode //The desired rendermode.
81     * @return void
82     * @public
83     * @see render()
84     */
85    function connectTo($mode) {
86        $this->Lexer->addEntryPattern('{{airtable>', $mode, 'plugin_airtable');
87    }
88
89    function postConnect() {
90        $this->Lexer->addExitPattern('}}', 'plugin_airtable');
91    }
92
93    /**
94     * Handler to prepare matched data for the rendering process.
95     *
96     * <p>
97     * The <tt>$aState</tt> parameter gives the type of pattern
98     * which triggered the call to this method:
99     * </p>
100     * <dl>
101     * <dt>DOKU_LEXER_ENTER</dt>
102     * <dd>a pattern set by <tt>addEntryPattern()</tt></dd>
103     * <dt>DOKU_LEXER_MATCHED</dt>
104     * <dd>a pattern set by <tt>addPattern()</tt></dd>
105     * <dt>DOKU_LEXER_EXIT</dt>
106     * <dd> a pattern set by <tt>addExitPattern()</tt></dd>
107     * <dt>DOKU_LEXER_SPECIAL</dt>
108     * <dd>a pattern set by <tt>addSpecialPattern()</tt></dd>
109     * <dt>DOKU_LEXER_UNMATCHED</dt>
110     * <dd>ordinary text encountered within the plugin's syntax mode
111     * which doesn't match any pattern.</dd>
112     * </dl>
113     * @param $match   //String The text matched by the patterns.
114     * @param $state   //Integer The lexer state for the match.
115     * @param $pos     //Integer The character position of the matched text.
116     * @param $handler //Object Reference to the Doku_Handler object.
117     * @return array The current lexer state for the match.
118     * @public
119     * @see render()
120     * @static
121     * @throws InvalidEmbed
122     */
123    function handle($match, $state, $pos, $handler): array {
124        switch($state) {
125            case DOKU_LEXER_EXIT:
126            case DOKU_LEXER_ENTER :
127                /** @var array $data */
128                $data = array();
129                return $data;
130
131            case DOKU_LEXER_SPECIAL:
132            case DOKU_LEXER_MATCHED :
133                break;
134
135            case DOKU_LEXER_UNMATCHED :
136                if(!empty($match)) {
137                    try {
138                        define('BASE_ID', $this->getConf('Base_ID'));
139                        define('API_KEY', $this->getConf('API_Key'));
140                        define('MAX_RECORDS', $this->getConf('Max_Records'));
141                        $user_string  = $match;
142                        $display_type = $this->getDisplayType($user_string); //check type is set correctly
143                        // MAIN PROGRAM:
144                        switch(true) { //parse string based on type set
145                            case ($display_type === "tbl"):
146                                $parameter_array               = $this->parseTableString($user_string);
147                                $api_response                  = $this->sendTableRequest($parameter_array);
148                                $parameter_array['thumbnails'] = $this->findMedia($api_response);
149                                if(count($api_response['records']) == 1) { //if query resulted in one record, render as a template:
150                                    $html = $this->renderRecord($parameter_array, $api_response['records'][0]);
151                                } else {
152                                    $html = $this->renderTable($parameter_array, $api_response);
153                                }
154                                return array('airtable_html' => $html);
155                            case ($display_type === "record"):
156                                $parameter_array               = $this->parseRecordString($user_string);
157                                $api_response                  = $this->sendRecordRequest($parameter_array);
158                                $parameter_array['thumbnails'] = $this->findMedia($api_response);
159                                $html                          = $this->renderRecord($parameter_array, $api_response);
160                                return array('airtable_html' => $html);
161                            case ($display_type === "img"):
162                                $parameter_array = $this->parseImageString($user_string);
163                                $api_response    = $this->sendRecordRequest($parameter_array);
164                                $thumbnails      = $this->findMedia($api_response);
165                                if($thumbnails === false or $thumbnails === null) {
166                                    throw new InvalidAirtableString("Unknown 'parseImageRequest' error");
167                                }
168                                $html = $this->renderMedia($parameter_array, $thumbnails, "max-width: 250px;");
169                                return array('airtable_html' => $html);
170                            case ($display_type === "txt"):
171                                $parameter_array = $this->parseTextString($user_string);
172                                $api_response    = $this->sendRecordRequest($parameter_array);
173                                $html            = $this->renderText($parameter_array, $api_response);
174                                return array('airtable_html' => $html);
175                            default:
176                                throw new InvalidEmbed("Unknown Embed Type");
177                        }
178                    } catch(InvalidAirtableString $e) {
179                        $html = "<p style='color: red; font-weight: bold;'>Airtable Error: " . $e->getMessage() . "</p>";
180                        return array('airtable_html' => $html);
181                    }
182                }
183        }
184        $data = array();
185        return $data;
186    }
187
188    /**
189     * Handle the actual output creation.
190     *
191     * <p>
192     * The method checks for the given <tt>$aFormat</tt> and returns
193     * <tt>FALSE</tt> when a format isn't supported. <tt>$aRenderer</tt>
194     * contains a reference to the renderer object which is currently
195     * handling the rendering. The contents of <tt>$aData</tt> is the
196     * return value of the <tt>handle()</tt> method.
197     * </p>
198     * @param $mode      //String The output format to generate.
199     * @param $renderer  Doku_Renderer A reference to the renderer object.
200     * @param $data      //Array The data created by the <tt>handle()</tt>
201     *                   method.
202     * @return Boolean <tt>TRUE</tt> if rendered successfully, or
203     *                   <tt>FALSE</tt> otherwise.
204     * @public
205     * @see          handle()
206     * @noinspection PhpParameterNameChangedDuringInheritanceInspection
207     */
208    public
209    function render($mode, Doku_Renderer $renderer, $data): bool {
210        //<airtable>Type: Image, Table: tblwWxohDeMeAAzdW, WHERE: {Ref #} = 19, image-size: small, alt-tag: marble-machine-x</airtable>
211
212        if($mode != 'xhtml') return false;
213
214        if(!empty($data['airtable_html'])) {
215            $renderer->doc .= $data['airtable_html'];
216            return true;
217        } else {
218            return false;
219        }
220    }
221
222    /**
223     * Method for rendering a table
224     *
225     * @param $parameter_array
226     * @param $api_response
227     * @return string
228     * @throws InvalidAirtableString
229     */
230    private
231    function renderTable($parameter_array, $api_response): string {
232        $html = '<div style="overflow-x: auto"><table class="airtable-table"><thead><tr>';
233        foreach($parameter_array['fields'] as $field) {
234            $html .= '<th>' . $field . '</th>';
235        }
236        $html .= '</tr></thead><tbody>';
237        foreach($api_response['records'] as $record) {
238            $html .= '<tr>';
239            foreach($parameter_array['fields'] as $field) {
240                if(is_array($record['fields'][$field])) {
241                    if($image = $this->findMedia($record['fields'][$field])) {
242                        $field = $this->renderMedia($parameter_array, $image);
243                        $html  .= '<td>' . $field . '</td>';
244                        continue;
245                    }
246                }
247                $html .= '<td>' . $this->renderAnyExternalLinks(htmlspecialchars($record['fields'][$field])) . '</td>';
248            }
249            $html .= '</tr>';
250        }
251        $html .= '</tbody></table></div>';
252        return $html;
253    }
254
255    /**
256     * Private Method for rendering a single record.
257     * Fields and field data appear on the left. If there is an image present,
258     * it will appear to the top right of the text
259     *
260     * @param $parameter_array
261     * @param $api_response
262     * @return string
263     * @throws InvalidAirtableString
264     */
265    private
266    function renderRecord($parameter_array, $api_response): string {
267        $fields = $parameter_array['fields'];
268        $html   = '<div class="airtable-record">';
269        if($parameter_array['thumbnails'] !== false) {
270            $parameter_array['image-size'] = "large";
271            $image_styles                  = 'float: right; max-width: 350px; margin-left: 10px';
272            $html                          .= $this->renderMedia($parameter_array, $parameter_array['thumbnails'], $image_styles);
273        }
274        foreach($fields as $field) {
275            if(!array_key_exists($field, $api_response['fields'])) { //if field is not present in array:
276                throw new InvalidAirtableString("Invalid field name: " . htmlspecialchars($field));
277            }
278            if(is_array($api_response['fields'][$field])) {
279                continue;
280            }
281            $html .= '
282            <div>
283                <h3>' . $field . '</h3>
284                <p>' . $this->renderAnyExternalLinks($api_response['fields'][$field]) . '</p>
285            </div>';
286        }
287        $html .= '<div style="clear: both;"></div>';
288        $html .= '</div>';
289        return $html;
290    }
291
292    /**
293     * Generates HTML for rendering a single image:
294     *
295     * @param        $data
296     * @param        $images
297     * @param string $image_styles
298     * @return string
299     * @throws InvalidAirtableString
300     */
301    private
302    function renderImage($data, $images, $image_styles = ""): string {
303        if(!key_exists('thumbnails', $images)) {
304            throw new InvalidAirtableString('Could not find thumbnails in image query');
305        }
306        if($data['position'] == "centre") {
307            $position = "mediacenter";
308        } elseif($data['position'] == 'right') {
309            $position = "mediaright";
310        } elseif($data['position'] == "left") {
311            $position = "medialeft";
312        } else {
313            $position = '';
314        }
315
316        if(!key_exists('image-size', $data)) {
317            $data['image-size'] = 'large';
318        }
319        return '
320        <div>
321            <a href="' . $images['thumbnails']['full']['url'] . '" target="_blank" rel="noopener" title="' . $images["filename"] . '">
322                <img alt ="' . $data['alt-tag'] . '" src="' . $images['thumbnails'][$data['image-size']]['url'] . '" style="' . $image_styles . '" class="airtable-image ' . $position . '">
323            </a>
324        </div>';
325    }
326
327    /**
328     * Private method for rendering text.
329     *
330     * @param $parameter_array
331     * @param $api_response
332     * @return string
333     * @throws InvalidAirtableString
334     */
335    private
336    function renderText($parameter_array, $api_response): string {
337        $fields = $parameter_array['fields'];
338        $html   = '';
339        foreach($fields as $field) {
340            if(!array_key_exists($field, $api_response['fields'])) { //if field is not present in array:
341                throw new InvalidAirtableString("Invalid field name: " . htmlspecialchars($field));
342            }
343            $html .= $this->renderAnyExternalLinks(htmlspecialchars($api_response['fields'][$field])) . ' ';
344        }
345        $html = rtrim($html);
346        return $html;
347    }
348
349    /**
350     * Method that chooses the correct rendering type for the given media:
351     *
352     * @param        $data
353     * @param        $media
354     * @param string $media_styles
355     * @return string
356     * @throws InvalidAirtableString
357     */
358    private
359    function renderMedia($data, $media, $media_styles = ""): string {
360        $type = $media['type'];
361        if($type == 'image/jpeg' || $type == 'image/jpg' || $type == 'image/png') {
362            return $this->renderImage($data, $media, $media_styles);
363        }
364        if($type == 'video/mp4' || $type == 'video/quicktime') {
365            return $this->renderVideo($media, $media_styles);
366        }
367        throw new InvalidAirtableString("Unknown media type: " . $type);
368    }
369
370    /**
371     * Generates HTML for rendering a video
372     *
373     * @param $video
374     * @param $video_styles
375     * @return string
376     */
377    private
378    function renderVideo($video, $video_styles): string {
379        return '<video controls class="airtable-video" style="' . $video_styles . '"><source src="' . $video["url"] . '" type="video/mp4"></video>';
380    }
381
382    /**
383     * Sets the required parameters for type: table
384     *
385     * @param $user_string
386     * @return array
387     * @throws InvalidAirtableString
388     */
389    private
390    function parseTableString($user_string): array {
391        $table_parameter_types  = array("type" => true, "table" => true, "fields" => true, "record-url" => true, "where" => "", "order-by" => "", "order" => "asc", "max-records" => "");
392        $table_parameter_values = array("order" => ["asc", "desc"]);
393        $table_query            = $this->decodeRecordURL($this->getParameters($user_string));
394        return $this->checkParameters($table_query, $table_parameter_types, $table_parameter_values);
395    }
396
397    /**
398     * Sets the required parameters for type: record
399     * @param $user_string
400     * @return array
401     * @throws InvalidAirtableString
402     */
403    private
404    function parseRecordString($user_string): array {
405        $record_parameter_types  = array("type" => true, "record-url" => true, "table" => true, "fields" => true, "record-id" => true, "alt-tag" => "");
406        $record_parameter_values = array();
407        $record_query            = $this->decodeRecordURL($this->getParameters($user_string));
408        return $this->checkParameters($record_query, $record_parameter_types, $record_parameter_values);
409    }
410
411    /**
412     * Sets the required parameters for type: image
413     * Also sets accepted values for specific parameters
414     *
415     * @param $user_string
416     * @return array The decoded string with the parameter names stored as keys
417     * @throws InvalidAirtableString
418     */
419    private
420    function parseImageString($user_string): array {
421        $image_parameter_types  = array("type" => true, "record-url" => true, 'table' => true, 'record-id' => true, "alt-tag" => "", "image-size" => "large", "position" => "block"); // accepted parameter names with default values or true if parameter is required.
422        $image_parameter_values = array("image-size" => ["", "small", "large", "full"], "position" => ['', 'left', 'centre', 'right', 'block']); // can be empty (substitute default), small, large, full
423        $image_query            = $this->decodeRecordURL($this->getParameters($user_string));
424        return $this->checkParameters($image_query, $image_parameter_types, $image_parameter_values);
425    }
426
427    /**
428     * Sets the required parameters for type: text
429     *
430     * @param $user_string
431     * @return array
432     * @throws InvalidAirtableString
433     */
434    private
435    function parseTextString($user_string): array {
436        $text_parameter_types  = array("type" => true, "table" => true, "fields" => true, "record-id" => true, "record-url" => true);
437        $text_parameter_values = array();
438        $text_query            = $this->decodeRecordURL($this->getParameters($user_string));
439        return $this->checkParameters($text_query, $text_parameter_types, $text_parameter_values);
440    }
441
442    /**
443     * parse query string and return the type
444     *
445     * @param $user_string //data between airtable tags e.g.: <airtable>user_string</airtable>
446     * @return string //the display type (image, table, text)
447     * @throws InvalidAirtableString
448     */
449    private
450    function getDisplayType($user_string): string {
451        $type = substr($user_string, 0, strpos($user_string, " | "));
452        if($type == "") {
453            throw new InvalidAirtableString("Missing Type Parameter / Not Enough Parameters");
454        }
455        $decoded_string = explode("type: ", strtolower($type))[1];
456        $decoded_type   = str_replace('"', '', $decoded_string);
457        if($decoded_type == null) {
458            throw new InvalidAirtableString("Missing Type Parameter");
459        }
460        $decoded_type   = strtolower($decoded_type);
461        $accepted_types = array("img", "image", "picture", "text", "txt", "table", "tbl", "record");
462        if(array_search($decoded_type, $accepted_types) === false) {
463            throw new InvalidAirtableString(
464                "Invalid Type Parameter: " . htmlspecialchars($decoded_type) . "
465            <br>Accepted Types: " . implode(" | ", $accepted_types)
466            );
467        }
468        //Set to a standard type:
469        if($decoded_type == "img" || $decoded_type == "image" || $decoded_type == "picture") {
470            $decoded_type = "img";
471        }
472        if($decoded_type == "text") {
473            $decoded_type = "txt";
474        }
475        if($decoded_type == "table") {
476            $decoded_type = "tbl";
477        }
478        return $decoded_type;
479    }
480
481    /**
482     * Splits the query string into an associative array of Type => Value pairs
483     *
484     * @param $user_string string The user's airtable query
485     * @return array
486     */
487    private
488    function getParameters(string $user_string): array {
489        $query        = array();
490        $string_array = explode(' | ', $user_string);
491        foreach($string_array as $item) {
492            $parameter                        = explode(": ", $item); //creates key value pairs for parameters e.g. [type] = "image"
493            $query[strtolower($parameter[0])] = trim(str_replace('"', '', $parameter[1])); //removes quotes
494        }
495        if(array_key_exists("fields", $query)) { // separate field names into an array if it exists
496            $fields          = array_map("trim", explode(",", $query['fields'])); //todo: url encode fields here?
497            $query['fields'] = $fields;
498        }
499        return $query;
500    }
501
502    /**
503     * Extracts the table, view and record ID's from record-url
504     *
505     * @param $query
506     * @return mixed
507     * @throws InvalidAirtableString
508     */
509    private
510    function decodeRecordURL($query) {
511        if(array_key_exists("record-url", $query)) {
512            //// "tbl\w+|viw\w+|rec\w+/ig" One line preg match?
513            preg_match("/tbl\w+/i", $query["record-url"], $table); //extract table, view, record from url
514            preg_match("/viw\w+/i", $query["record-url"], $view);
515            preg_match("/rec\w+/i", $query["record-url"], $record_id);
516
517            $query['table']     = urlencode($table[0]); //url encode each part
518            $query['view']      = urlencode($view[0]);
519            $query['record-id'] = urlencode($record_id[0]);
520            return $query;
521        } else {
522            throw new InvalidAirtableString("Missing record-url parameter");
523        }
524    }
525
526    /**
527     * Checks query parameters to make sure:
528     *      Required parameters are present
529     *      Missing parameters are substituted with default params
530     *      Parameter values match expected values
531     *
532     * @param $query_array         array
533     * @param $required_parameters array
534     * @param $parameter_values    array
535     * @return array // query array with added default parameters
536     * @throws InvalidAirtableString
537     */
538    private
539    function checkParameters(array &$query_array, array $required_parameters, array $parameter_values): array {
540        foreach($required_parameters as $key => $value) {
541            if(!array_key_exists($key, $query_array)) { // if parameter is missing:
542                if($value === true) { // check if parameter is required
543                    throw new InvalidAirtableString("Missing Parameter: " . $key);
544                }
545                $query_array[$key] = $value; // substitute default
546            }
547            if(($query_array[$key] == null || $query_array[$key] === "") && $value === true) { //if parameter is required but value is not present
548                throw new InvalidAirtableString("Missing Parameter Value for: '" . $key . "'.");
549            }
550            if(array_key_exists($key, $parameter_values)) { //check accepted parameter_values array
551                if(!in_array($query_array[$key], $parameter_values[$key])) { //if parameter value is not accepted:
552                    $message = "Invalid Parameter Value: '" . htmlspecialchars($query_array[$key]) . "' for Key: '" . $key . "'.
553                    <br>Possible values: " . implode(" | ", $parameter_values[$key]);
554                    if(in_array("", $parameter_values[$key])) {
555                        $message .= " or ''";
556                    }
557                    throw new InvalidAirtableString($message);
558                }
559            }
560        }
561        return $query_array;
562    }
563
564    /**
565     * Method for checking text and replacing links with <a> tags for external linking
566     * If there are no links present, return the text with no modification
567     *
568     * @param $string // The string to find links in
569     * @return string
570     */
571    private
572    function renderAnyExternalLinks($string): string {
573        $regular_expression = "/(?i)\b((?:https?:\/\/|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}\/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'\".,<>?«»“”‘’]))/";
574
575        if(preg_match_all($regular_expression, $string, $url_matches)) { // store all url matches in the $url array
576            foreach($url_matches[0] as $link) {
577                if(strstr($link, ':') === false) { //if link is missing http, add it to the front of the url
578                    $url = 'http://' . $link;
579                } else {
580                    $url = $link;
581                }
582                $search  = $link;
583                $replace = '<a href = "' . $url . '" title = "' . $link . '" target = "_blank" rel = "noopener" class = "urlextern">' . $url . '</a>';
584                $string  = str_replace($search, $replace, $string);
585            }
586        }
587        return $string;
588    }
589
590    /**
591     * Recursive method to find an array (needle) within the JSON api_response (haystack)
592     *
593     * @param        $haystack
594     * @param string $needle
595     * @return false|array
596     */
597    private
598    function findMedia($haystack, $needle = "type") {
599        foreach($haystack as $key) {
600            if(is_array($key)) {
601                if(array_key_exists($needle, $key)) {
602                    return $key;
603                }
604                $search = $this->findMedia($key, $needle);
605                if($search === false) {
606                    continue;
607                } else {
608                    return $search; // image attachment found
609                }
610            }
611        }
612        return false;
613    }
614
615    /**
616     * Method to encode a record request
617     *
618     * @param $data
619     * @return false|string //JSON String
620     * @throws InvalidAirtableString
621     */
622    private
623    function sendRecordRequest($data) {
624        $request = $data['table'] . '/' . urlencode($data['record-id']);
625        return $this->sendRequest($request);
626    }
627
628    /**
629     * Method to encode a table request
630     *
631     * @param $data
632     * @return false|string
633     * @throws InvalidAirtableString
634     */
635    private
636    function sendTableRequest($data) {
637        $request = $data['table'] . '?';
638        //Add each field to the request string
639        foreach($data['fields'] as $index => $field) {
640            if($index >= 1) {
641                $request .= '&' . urlencode('fields[]') . '=' . urlencode($field);
642            } else {
643                $request .= urlencode('fields[]') . '=' . urlencode($field); //don't add a '&' for the first field
644            }
645        }
646
647        //add filter:
648        if(key_exists('where', $data)) {
649            if($data['where'] !== "") {
650                $request .= '&filterByFormula=' . urlencode($data['where']);
651            }
652        }
653
654        //Set max records:
655        if(key_exists('max-records', $data)) {
656            if($data['max-records'] !== "") {
657                if((int) $data['max-records'] <= MAX_RECORDS) {
658                    $max_records = $data['max-records'];
659                } else {
660                    $max_records = MAX_RECORDS;
661                }
662            } else {
663                $max_records = MAX_RECORDS;
664            }
665        } else {
666            $max_records = MAX_RECORDS;
667        }
668        $request .= '&maxRecords=' . $max_records;
669
670        //set order by which field and order direction:
671        if(key_exists('order', $data)) {
672            $order = $data['order'];
673        } else {
674            $order = "asc";
675        }
676
677        if(key_exists('order-by', $data)) {
678            if($data['order-by'] !== "") {
679                $request .= '&' . urlencode('sort[0][field]') . '=' . urlencode($data['order-by']);
680                $request .= '&' . urlencode('sort[0][direction]') . '=' . urlencode($order);
681            }
682        }
683
684        return $this->sendRequest($request);
685    }
686
687    /**
688     * Method to call the airtable API
689     *
690     * @param $request
691     * @return false|string
692     * @throws InvalidAirtableString
693     */
694    private
695    function sendRequest($request) {
696        $url  = 'https://api.airtable.com/v0/' . BASE_ID . '/' . $request;
697        $curl = curl_init($url);
698        curl_setopt($curl, CURLOPT_URL, $url);
699        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
700
701        $headers = array(
702            'Authorization: Bearer ' . API_KEY
703        );
704
705        curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
706
707        //TODO: remove once in production:
708        curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, false);
709        curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);//
710
711        $api_response = json_decode(curl_exec($curl), true); //decode JSON to associative array
712
713        if(curl_getinfo($curl, CURLINFO_HTTP_CODE) != 200) {
714            if(key_exists("error", $api_response)) {
715                $message = $api_response['error']['message'];
716            } else {
717                $message = "Unknown API api_response error";
718            }
719            throw new InvalidAirtableString($message);
720        }
721        curl_close($curl);
722        return $api_response;
723    }
724}