Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
71 / 71
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
http_request_node
100.00% covered (success)
100.00%
71 / 71
100.00% covered (success)
100.00%
7 / 7
35
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 type
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 params
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 branches
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 make_curl
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 header_lines
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
11
 execute
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
19
1<?php
2// This file is part of Moodle - https://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle.  If not, see <https://www.gnu.org/licenses/>.
16
17namespace tool_autoflow\local\nodes;
18
19use tool_autoflow\local\engine\node_failed;
20use tool_autoflow\local\engine\node_result;
21use tool_autoflow\local\engine\node_type;
22use tool_autoflow\local\engine\param;
23
24/**
25 * Sends an HTTP request to another service and returns its answer.
26 *
27 * Requests go through Moodle's curl class, so the site's HTTP security settings
28 * (blocked hosts and allowed ports, which block local and private addresses by
29 * default) apply to the first URL and to every redirect. Only http and https are
30 * allowed, requests time out, redirects and response size are capped, and headers
31 * are checked so a placeholder value cannot add a header of its own.
32 *
33 * @package    tool_autoflow
34 * @copyright  2026 DevLMS <contact@devlms.com>
35 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
36 */
37final class http_request_node implements node_type {
38    /** Largest response body kept, in bytes. */
39    public const MAX_BYTES = 262144;
40
41    /** Methods the node can send. */
42    private const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
43
44    /** @var \Closure|null Makes the curl object; tests pass their own. */
45    private readonly ?\Closure $makecurl;
46
47    /**
48     * Create the node.
49     *
50     * @param \Closure|null $makecurl Returns a \curl. Defaults to {@see self::make_curl()}.
51     */
52    public function __construct(?\Closure $makecurl = null) {
53        $this->makecurl = $makecurl;
54    }
55
56    /**
57     * Type name.
58     *
59     * @return string
60     */
61    public function type(): string {
62        return 'action.http';
63    }
64
65    /**
66     * Parameters.
67     *
68     * @return param[]
69     */
70    public function params(): array {
71        return [
72            new param('method', 'select', true, self::METHODS),
73            new param('url', 'text', true),
74            new param('headers', 'json', false),
75            new param('body', 'json', false),
76        ];
77    }
78
79    /**
80     * Branches.
81     *
82     * @return string[]
83     */
84    public function branches(): array {
85        return [];
86    }
87
88    /**
89     * A curl object with the node's limits.
90     *
91     * @return \curl
92     */
93    public static function make_curl(): \curl {
94        global $CFG;
95        require_once($CFG->libdir . '/filelib.php');
96        $curl = new \curl();
97        $curl->setopt([
98            'CURLOPT_TIMEOUT' => 15,
99            'CURLOPT_CONNECTTIMEOUT' => 5,
100            'CURLOPT_MAXREDIRS' => 3,
101            'CURLOPT_MAXFILESIZE' => self::MAX_BYTES,
102            'CURLOPT_PROTOCOLS' => CURLPROTO_HTTP | CURLPROTO_HTTPS,
103        ]);
104        return $curl;
105    }
106
107    /**
108     * Header lines from a JSON object of names and plain values.
109     *
110     * @param mixed $headers
111     * @param bool $jsonbody Whether the body is JSON, which adds a Content-Type unless one is set.
112     * @return string[]
113     */
114    public static function header_lines(mixed $headers, bool $jsonbody): array {
115        if (!is_array($headers) || ($headers && array_is_list($headers))) {
116            throw new node_failed('Headers must be a JSON object of names and values.');
117        }
118        $lines = [];
119        $hastype = false;
120        foreach ($headers as $name => $value) {
121            $name = (string) $name;
122            if (!preg_match('/^[A-Za-z0-9!#$%&\'*+.^_\x60|~-]+$/', $name)) {
123                throw new node_failed("\"$name\" is not a valid header name.");
124            }
125            if (!is_scalar($value) || preg_match('/[\r\n\0]/', (string) $value)) {
126                throw new node_failed("The header $name must be one line of text.");
127            }
128            $hastype = $hastype || strtolower($name) === 'content-type';
129            $lines[] = $name . ': ' . $value;
130        }
131        if ($jsonbody && !$hastype) {
132            $lines[] = 'Content-Type: application/json';
133        }
134        return $lines;
135    }
136
137    /**
138     * Send the request.
139     *
140     * @param array $params method, url, headers, body
141     * @param array $context
142     * @return node_result status, body, json (the decoded body or null) and truncated.
143     */
144    public function execute(array $params, array $context): node_result {
145        $method = strtolower(trim((string) ($params['method'] ?? '')) ?: 'get');
146        if (!in_array($method, self::METHODS, true)) {
147            throw new node_failed('The method must be GET, POST, PUT, PATCH or DELETE, not ' . strtoupper($method) . '.');
148        }
149        $url = trim((string) ($params['url'] ?? ''));
150        $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
151        if (!in_array($scheme, ['http', 'https'], true) || !parse_url($url, PHP_URL_HOST)) {
152            throw new node_failed("The URL must start with http or https: \"$url\".");
153        }
154        $body = $params['body'] ?? [];
155        if (is_string($body) && trim($body) === '') {
156            $body = [];
157        }
158        if (!is_array($body) || ($body && array_is_list($body))) {
159            throw new node_failed('The body must be a JSON object.');
160        }
161        $jsonbody = $body !== [];
162        $payload = $jsonbody ? json_encode($body) : '';
163        $headers = self::header_lines($params['headers'] ?? [], $jsonbody);
164
165        $curl = $this->makecurl ? ($this->makecurl)() : self::make_curl();
166        if ($headers) {
167            $curl->setHeader($headers);
168        }
169        $response = $method === 'get'
170            ? $curl->get($url)
171            : $curl->post($url, $payload, ['CURLOPT_CUSTOMREQUEST' => strtoupper($method)]);
172        if (!empty($curl->error)) {
173            throw new node_failed("$url could not be reached: {$curl->error}");
174        }
175        $status = (int) ($curl->info['http_code'] ?? 0);
176        $response = (string) $response;
177        $truncated = strlen($response) > self::MAX_BYTES;
178        if ($truncated) {
179            $response = substr($response, 0, self::MAX_BYTES);
180        }
181        if ($status >= 400 || $status === 0) {
182            throw new node_failed("$url answered $status: " . shorten_text($response, 200));
183        }
184        $json = json_decode($response, true);
185        return new node_result([
186            'status' => $status,
187            'body' => $response,
188            'json' => is_array($json) ? $json : null,
189            'truncated' => $truncated,
190        ]);
191    }
192}