friendship ended with social-app. php is my new best friend
1<?php
2
3declare(strict_types=1);
4
5use Fetch\Enum\ContentType;
6use Fetch\Enum\Method;
7use Fetch\Http\Client;
8use Fetch\Interfaces\ClientHandler as ClientHandlerInterface;
9use Fetch\Interfaces\Response as ResponseInterface;
10use Psr\Http\Client\ClientExceptionInterface;
11use Psr\Http\Message\RequestInterface;
12
13if (! function_exists('fetch')) {
14 /**
15 * Perform an HTTP request similar to JavaScript's fetch API.
16 *
17 * @param string|RequestInterface|null $resource URL to fetch or a pre-configured Request object
18 * @param array<string, mixed>|null $options Request options including:
19 * - method: HTTP method (string|Method enum)
20 * - headers: Request headers (array)
21 * - body: Request body (mixed)
22 * - json: JSON data to send as body (array, takes precedence over body)
23 * - form: Form data to send as body (array, takes precedence if no json)
24 * - multipart: Multipart form data (array, takes precedence if no json/form)
25 * - query: Query parameters (array)
26 * - base_uri: Base URI (string)
27 * - timeout: Request timeout in seconds (int)
28 * - retries: Number of retries (int)
29 * - auth: Basic auth credentials [username, password] (array)
30 * - token: Bearer token (string)
31 * @return ResponseInterface|ClientHandlerInterface|Client Response or handler for method chaining
32 *
33 * @throws ClientExceptionInterface If a client exception occurs
34 */
35 function fetch(string|RequestInterface|null $resource = null, ?array $options = []): ResponseInterface|ClientHandlerInterface|Client
36 {
37 $options = $options ?? [];
38
39 // If a Request object is provided, we can't use options with it
40 if ($resource instanceof RequestInterface) {
41 return fetch_client()->sendRequest($resource);
42 }
43
44 // If no resource is provided, return the client handler for chaining
45 if ($resource === null) {
46 return fetch_client();
47 }
48
49 // Process fetch-style options
50 $processedOptions = process_request_options($options);
51
52 // Handle base URI if provided
53 if (isset($options['base_uri'])) {
54 return handle_request_with_base_uri($resource, $options, $processedOptions);
55 }
56
57 // No base URI, use direct fetch with options
58 return fetch_client()->fetch($resource, $processedOptions);
59 }
60}
61
62if (! function_exists('process_request_options')) {
63 /**
64 * Process and normalize request options.
65 *
66 * @param array<string, mixed> $options Raw options
67 * @return array<string, mixed> Processed options
68 */
69 function process_request_options(array $options): array
70 {
71 $processedOptions = [];
72
73 // Method (default to GET)
74 $method = $options['method'] ?? Method::GET;
75 $methodValue = $method instanceof Method ? $method->value : (string) $method;
76 $processedOptions['method'] = $methodValue;
77
78 // Headers
79 if (isset($options['headers'])) {
80 $processedOptions['headers'] = $options['headers'];
81 }
82
83 // Content type and body handling
84 [$body, $contentType] = extract_body_and_content_type($options);
85
86 if ($body !== null) {
87 $processedOptions['body'] = $body;
88 if ($contentType !== null) {
89 $processedOptions['content_type'] = $contentType;
90 }
91 }
92
93 // Query parameters
94 if (isset($options['query'])) {
95 $processedOptions['query'] = $options['query'];
96 }
97
98 // Copy other direct pass options
99 $directPassOptions = [
100 'timeout', 'retries', 'auth', 'token',
101 'proxy', 'cookies', 'allow_redirects', 'cert', 'ssl_key', 'stream',
102 ];
103
104 foreach ($directPassOptions as $opt) {
105 if (isset($options[$opt])) {
106 $processedOptions[$opt] = $options[$opt];
107 }
108 }
109
110 return $processedOptions;
111 }
112}
113
114if (! function_exists('extract_body_and_content_type')) {
115 /**
116 * Extract body and content type from options.
117 *
118 * @param array<string, mixed> $options Request options
119 * @return array{0: mixed, 1: ?ContentType|string} Tuple of [body, contentType]
120 */
121 function extract_body_and_content_type(array $options): array
122 {
123 $body = null;
124 $contentType = null;
125
126 // Body handling - json takes precedence, then form, then multipart, then raw body
127 if (isset($options['json'])) {
128 $body = $options['json'];
129 $contentType = ContentType::JSON;
130 } elseif (isset($options['form'])) {
131 $body = $options['form'];
132 $contentType = ContentType::FORM_URLENCODED;
133 } elseif (isset($options['multipart'])) {
134 $body = $options['multipart'];
135 $contentType = ContentType::MULTIPART;
136 } elseif (isset($options['body'])) {
137 $body = $options['body'];
138 // IMPORTANT: Don't auto-convert arrays to JSON here
139 // The content type should be explicitly set
140 $rawContentType = $options['content_type'] ?? null;
141 $contentType = $rawContentType !== null ? ContentType::normalizeContentType($rawContentType) : null;
142 }
143
144 return [$body, $contentType];
145 }
146}
147
148if (! function_exists('handle_request_with_base_uri')) {
149 /**
150 * Handle a request with a base URI.
151 *
152 * @param string $resource URL to fetch
153 * @param array<string, mixed> $options Original options
154 * @param array<string, mixed> $processedOptions Processed options
155 * @return ResponseInterface The response
156 */
157 function handle_request_with_base_uri(string $resource, array $options, array $processedOptions): ResponseInterface
158 {
159 $client = fetch_client();
160 $handler = $client->getHandler();
161 $handler->baseUri($options['base_uri']);
162 $handler->withOptions($processedOptions);
163
164 // Extract body and content type if not already processed
165 [$body, $contentType] = extract_body_and_content_type($options);
166
167 if ($body !== null) {
168 if ($contentType !== null) {
169 $handler->withBody($body, $contentType);
170 } else {
171 $handler->withBody($body);
172 }
173 }
174
175 return $handler->sendRequest($processedOptions['method'], $resource);
176 }
177}
178
179if (! function_exists('fetch_client')) {
180 /**
181 * Get or configure the global fetch client instance.
182 *
183 * @param array<string, mixed>|null $options Global client options
184 * @param bool $reset Whether to reset the client instance
185 * @return Client The client instance
186 *
187 * @throws RuntimeException If client creation or configuration fails
188 */
189 function fetch_client(?array $options = null, bool $reset = false): Client
190 {
191 static $client = null;
192
193 try {
194 // Create a new client or reset the existing one
195 if ($client === null || $reset) {
196 $client = new Client(options: $options ?? []);
197 }
198 // Apply new options to the existing client if provided
199 elseif ($options !== null) {
200 // Get the existing handler
201 $handler = $client->getHandler();
202
203 // Only clone and apply options if there are options to apply
204 if (! empty($options)) {
205 try {
206 $handler = $handler->withClonedOptions($options);
207 } catch (Throwable $e) {
208 // More specific error message for options application failures
209 throw new RuntimeException(
210 sprintf('Failed to apply options to client: %s', $e->getMessage()),
211 0,
212 $e
213 );
214 }
215 }
216
217 // Create a new client with the modified handler
218 $client = new Client(handler: $handler);
219 }
220
221 return $client;
222 } catch (Throwable $e) {
223 // If it's already a RuntimeException from our code, re-throw it
224 if ($e instanceof RuntimeException && $e->getPrevious() !== null) {
225 throw $e;
226 }
227
228 // Otherwise, wrap the exception with more context
229 throw new RuntimeException(
230 sprintf('Error configuring fetch client: %s', $e->getMessage()),
231 0,
232 $e
233 );
234 }
235 }
236}
237
238if (! function_exists('request_method')) {
239 /**
240 * Common helper for making HTTP requests with various methods.
241 *
242 * @param string $method HTTP method (GET, POST, etc.)
243 * @param string $url URL to fetch
244 * @param mixed $data Request data (for body or query parameters)
245 * @param array<string, mixed>|null $options Additional request options
246 * @param bool $dataIsQuery Whether data is used as query parameters (true) or request body (false)
247 * @return ResponseInterface The response
248 *
249 * @throws ClientExceptionInterface If a client exception occurs
250 */
251 function request_method(string $method, string $url, mixed $data = null, ?array $options = [], bool $dataIsQuery = false): ResponseInterface
252 {
253 $options = $options ?? [];
254 $options['method'] = $method;
255
256 if ($data !== null) {
257 if ($dataIsQuery) {
258 $options['query'] = $data;
259 } elseif (is_array($data)) {
260 $options['json'] = $data; // Treat arrays as JSON by default
261 } else {
262 $options['body'] = $data;
263 }
264 }
265
266 return fetch($url, $options);
267 }
268}
269
270// We'll keep these convenience functions for PHP developers who prefer a more traditional API
271if (! function_exists('get')) {
272 /**
273 * Perform a GET request.
274 *
275 * @param string $url URL to fetch
276 * @param array<string, mixed>|null $query Query parameters
277 * @param array<string, mixed>|null $options Additional request options
278 * @return ResponseInterface The response
279 *
280 * @throws ClientExceptionInterface If a client exception occurs
281 */
282 function get(string $url, ?array $query = null, ?array $options = []): ResponseInterface
283 {
284 return request_method('GET', $url, $query, $options, true);
285 }
286}
287
288if (! function_exists('post')) {
289 /**
290 * Perform a POST request.
291 *
292 * @param string $url URL to fetch
293 * @param mixed $data Request body or JSON data
294 * @param array<string, mixed>|null $options Additional request options
295 * @return ResponseInterface The response
296 *
297 * @throws ClientExceptionInterface If a client exception occurs
298 */
299 function post(string $url, mixed $data = null, ?array $options = []): ResponseInterface
300 {
301 return request_method('POST', $url, $data, $options);
302 }
303}
304
305if (! function_exists('put')) {
306 /**
307 * Perform a PUT request.
308 *
309 * @param string $url URL to fetch
310 * @param mixed $data Request body or JSON data
311 * @param array<string, mixed>|null $options Additional request options
312 * @return ResponseInterface The response
313 *
314 * @throws ClientExceptionInterface If a client exception occurs
315 */
316 function put(string $url, mixed $data = null, ?array $options = []): ResponseInterface
317 {
318 return request_method('PUT', $url, $data, $options);
319 }
320}
321
322if (! function_exists('patch')) {
323 /**
324 * Perform a PATCH request.
325 *
326 * @param string $url URL to fetch
327 * @param mixed $data Request body or JSON data
328 * @param array<string, mixed>|null $options Additional request options
329 * @return ResponseInterface The response
330 *
331 * @throws ClientExceptionInterface If a client exception occurs
332 */
333 function patch(string $url, mixed $data = null, ?array $options = []): ResponseInterface
334 {
335 return request_method('PATCH', $url, $data, $options);
336 }
337}
338
339if (! function_exists('delete')) {
340 /**
341 * Perform a DELETE request.
342 *
343 * @param string $url URL to fetch
344 * @param mixed $data Request body or JSON data
345 * @param array<string, mixed>|null $options Additional request options
346 * @return ResponseInterface The response
347 *
348 * @throws ClientExceptionInterface If a client exception occurs
349 */
350 function delete(string $url, mixed $data = null, ?array $options = []): ResponseInterface
351 {
352 return request_method('DELETE', $url, $data, $options);
353 }
354}