1 <?php
2 /**
3 * Contains the Request library for Alphred
4 *
5 * PHP version 5
6 *
7 * @package Alphred
8 * @copyright Shawn Patrick Rice 2014-2015
9 * @license http://opensource.org/licenses/MIT MIT
10 * @version 1.0.0
11 * @author Shawn Patrick Rice <rice@shawnrice.org>
12 * @link http://www.github.com/shawnrice/alphred
13 * @link http://shawnrice.github.io/alphred
14 * @since File available since Release 1.0.0
15 *
16 */
17
18
19 namespace Alphred;
20
21 /**
22 * Generic, light-weight, low-functionality wrapper around PHP's cURL library
23 *
24 * This Requests library should be good enough for most requests, as long as you
25 * aren't doing anything special or crazy. If you outgrow it, then you should either
26 * (1) use Guzzle, or (2) write your own requests library that has better coverage.
27 *
28 * Granted, this should handle MOST use cases. I don't know if it handles file uploads.
29 * Theoretically, it does, but I wouldn't bank on it, and, if it doesn't, I will not
30 * expand the functionality to cover file uploads.
31 *
32 * With this, you can easily make GET or POST requests. Set extra headers. Easily set
33 * a user-agent. Set parameters. And cache the data for later retrieval.
34 *
35 *
36 */
37 class Request {
38
39 /**
40 * The internal cURL handler
41 * @var Resource
42 */
43 private $handler;
44
45 /**
46 * An internal structuring of the request object for cache creation
47 * @var array
48 */
49 private $object;
50
51 /**
52 * Creates a request object
53 *
54 * Currently, all the options apply to caching. So the three that are understood are:
55 * 1. `cache`,
56 * 2. `cache_life`, and
57 * 3. `cache_bin`.
58 *
59 * `cache` is a boolean that can turn on/off caching. It is recommended that you turn it on.
60 * `cache_life` is how long the cache will live. In other words, no attempts to get new data
61 * will be made until the data saved is older than the cache life (in seconds). It defaults to
62 * `3600` (one hour).
63 * `cache_bin` is the sub-directory in the workflow's cache folder where the results are saved.
64 * If `cache_bin` is set to `false` while caching is turned on, then all the results will be saved
65 * directly into the workflow's cache directory.
66 *
67 * Cache files are saved as md5 hashes of the request object. So, if you change anything about the
68 * request, then it will be considered a new cache file. Data is saved to the cache _only_ if
69 * we receive an HTTP response code less than 400.
70 *
71 * My advice is not to touch these options and let the cache work with its default behavior.
72 *
73 * A further note on `cache_bin`: the 'cache_bin' option, if `true`, will create a cache_bin
74 * that is a directory in the cache directory named after the hostname. So if the url is
75 * `http://api.github.com/api....` then the `cache_bin` will be `api.github.com`, and all
76 * cached data will be saved in that directory. Otherwise, if you pass a string, then that will
77 * become the directory it will be saved under.
78 *
79 *
80 * @param string $url the URL to request
81 * @param array $options [description]
82 */
83 public function __construct(
84 $url,
85 $options = array( 'cache' => true, 'cache_ttl' => 600, 'cache_bin' => true )
86 ) {
87
88 // Create the cURL handler
89 $this->handler = curl_init();
90 // Default to `GET`, which is, (not) coincidentally, the cURL default
91 $this->object['request_type'] = 'get';
92
93 // Empty parameters array
94 $this->object['parameters'] = [];
95 // Empty headers array
96 $this->headers = [];
97
98 // If the request object was initialized with a URL, then set the URL
99 $this->set_url( $url );
100
101 // Set the cache options
102 $this->set_caches( $options );
103
104 // Set some reasonable defaults
105 curl_setopt_array( $this->handler, [ CURLOPT_RETURNTRANSFER => 1, CURLOPT_FAILONERROR => 1 ]);
106
107 }
108
109 /**
110 * Sets the cache options
111 *
112 * @param array $options an array of cache options
113 */
114 private function set_caches( $options ) {
115 if ( ! isset( $options['cache_bin' ] ) ) {
116 // exit early if no cache bin is set
117 return;
118 }
119 // Here we can automatically set the cache bin to the URL hostname
120 if ( true === $options[ 'cache_bin' ] ) {
121 $options[ 'cache_bin' ] = parse_url( $this->object['url'], PHP_URL_HOST );
122 }
123
124 if ( isset( $options['cache'] ) && $options['cache'] ) {
125 if ( isset( $options['cache_ttl'] ) ) {
126 $this->cache_life = $options['cache_ttl'];
127 }
128 if ( isset( $options['cache_bin'] ) ) {
129 $this->cache_bin = $options['cache_bin'];
130 }
131 }
132 }
133
134
135 /**
136 * Executes the cURL request
137 *
138 * If you set `$code` to `true`, then this function will return an associative array as:
139 * ````php
140 * [ 'code' => HTTP_RESPONSE_CODE,
141 * 'data' => RESPONSE_DATA
142 * ];
143 *````
144 * If you get cached data, then the code will be "faked" as a 302, which is appropriate.
145 *
146 * If there is an error, then the code will be 0. So, if you manage to get expired cache
147 * data, then the code will be 0 and there will be data. If there is no expired cache data,
148 * then you will receive an array of `[ 0, false ]`.
149 *
150 * This method does not cache data unless the response code is less than 400. If you need
151 * better data integrity than that, use Guzzle or write your own request library. Or improve
152 * this one by putting in a pull request on the Github repo.
153 *
154 * @param boolean $code whether or not to return an HTTP response code
155 * @return string|array the response data, or an array with the code
156 */
157 public function execute( $code = false ) {
158
159 // Set a preliminary HTTP response code of 0 (not defined)
160 $this->code = 0;
161
162 // Build the fields
163 $this->build_fields();
164
165 // By now, the cURL request should be entirely built, so let's see proceed. First, we'll look to see if there
166 // is valid, cached data. If so, return that. If not, try to get new data. If that fails, try to return expired
167 // cache data. If that fails, then fail.
168
169 if ( isset( $this->cache_life ) ) {
170 if ( $data = $this->get_cached_data() ) {
171 // Debug-level log message
172 Log::console( "Getting the data from cache, aged " . $this->get_cache_age() . " seconds.", 0 );
173
174 // Close the cURL handler for good measure; we don't need it anymore
175 curl_close( $this->handler );
176
177 if ( false === $code ) {
178 // Just return the data
179 return $data;
180 } else {
181 // They wanted an HTTP code, and we don't have a real one for them because we're getting this
182 // from the internal cache, so we'll just fake a 302 response code
183 return [ 'code' => 302, 'data' => $data ];
184 }
185 }
186 }
187
188 // Well, we need to actually ask the server for some data, so let us go ahead and make the request
189 $this->results = curl_exec( $this->handler );
190
191 // This is the error message.
192 $error = curl_error( $this->handler );
193 // This is the error number; anything greater than 0 means something went wrong
194 $errno = curl_errno( $this->handler );
195
196 // Let's do some error checking on the request now, and then try to execute some fallbacks if there
197 // actually was a problem. First, check to make sure that the errno (cURL error code) is 0, which
198 // indicates success.
199 if ( $errno === 0 ) {
200 // The cURL request was successful, so log a debug message of "success"
201 Log::console( "cURL query successful.", 0 );
202 } else if ( $data = $this->get_cached_data_anyway() ) {
203 // Try to get expired cached results....
204 // This could work with error code 6 (cannot resolve) because that _could_
205 // indicate that we just don't have an internet connection right now.
206
207 // Let the console know we're using old data, with a level of `WARNING`
208 Log::console( 'Could not complete request, but, instead, using expired cache data.', 2 );
209 // Close the handler
210 curl_close( $this->handler );
211
212 if ( false === $code ) {
213 // Just return the results
214 return $data;
215 } else {
216 // The requested the code as well, so we'll return an array with the code:
217 return [ 'code' => 0, 'data' => $data ];
218 }
219 } else {
220 // Let them know what debuggin information follows
221 Log::console( 'Request completely failed, and no cached data exists. cURL debug information follows:', 3 );
222 // Log the error number (if that helps them)
223 Log::console( "cURL error number: {$errno}", 3 );
224 // Log the error message
225 Log::console( "cURL error message: `{$error}`.", 3 );
226 // We might as well close the handler
227 curl_close( $this->handler );
228 if ( false === $code ) {
229 // And let's just return false to get out of this failed function. Alas...
230 return false;
231 } else {
232 // But they also wanted the code, so we'll return 0 for the code
233 return [ 'code' => 0, 'data' => false ];
234 }
235 }
236
237 // Get the information about the last request
238 $info = curl_getinfo( $this->handler );
239 // This might bug out if connection failed.
240 $this->code = $info['http_code'];
241
242 // Close the cURL handler
243 curl_close( $this->handler );
244
245 // Cache the data if the cache life is greater than 0 seconds ...AND... the HTTP code is less than 400
246 if ( isset( $this->cache_life ) && ( $this->cache_life > 0 ) && ( $this->code < 400 ) ) {
247 $this->save_cache_data( $this->results );
248 }
249
250 if ( false === $code ) {
251 // Just return the results
252 return $this->results;
253 } else {
254 // The requested the code as well, so we'll return an array with the code:
255 return [ 'code' => $this->code, 'data' => $this->results ];
256 }
257 }
258
259 /**
260 * Builds the post fields array
261 *
262 * @since 1.0.0
263 */
264 private function build_post_fields() {
265 if ( count( $this->object['parameters'] ) > 0 ) {
266 curl_setopt( $this->handler, CURLOPT_POSTFIELDS, http_build_query( $this->object['parameters'] ) );
267 }
268 }
269
270 /**
271 * Builds the post fields array
272 *
273 * @since 1.0.0
274 */
275 private function build_get_fields() {
276 // If parameters are set, then append them
277 if ( count( $this->object['parameters'] ) > 0 ) {
278 // Add the ? that is needed for an appropriate `GET` request, and append the params
279 $url = $this->object['url'] . '?' . http_build_query( $this->object['parameters'] );
280 // Set the new URL that will include the parameters.
281 curl_setopt( $this->handler, CURLOPT_URL, $url );
282 }
283 }
284
285 /**
286 * Builds the fields out of parameters array
287 *
288 * @since 1.0.0
289 */
290 private function build_fields() {
291 // If the method is `GET`, then we need to append the parameters to
292 // the URL to make sure that it goes alright. Post parameters are included
293 // separately and should already be set.
294 if ( 'get' == $this->object['request_type'] ) {
295 $this->build_get_fields();
296 } else if ( 'post' == $this->object['request_type'] ) {
297 $this->build_post_fields();
298 } else {
299 // This should never happen. I mean it. There is no way that I can think of that this exception will be
300 // thrown. If it is, then please report it, and send me the code you used to make it happen.
301 throw new Exception( "You should never see this, but somehow you are making an unsupported request", 4 );
302 }
303 }
304
305 /**
306 * Sets basic authorization for a cURL request
307 *
308 * If you need more advanced authorization methods, and if you cannot make them happen with
309 * headers, then use a different library. I recommend Guzzle.
310 *
311 * @since 1.0.0
312 *
313 * @param string $username a username
314 * @param string $password a password
315 */
316 public function set_auth( $username, $password ) {
317 curl_setopt( $this->handler, CURLOPT_HTTPAUTH, CURLAUTH_BASIC );
318 curl_setopt( $this->handler, CURLOPT_USERPWD, "{$username}:{$password}" );
319 $this->object['username'] = $username;
320 }
321
322 /**
323 * Sets the URL for the cURL request
324 *
325 * @todo Add in custom exception
326 * @since 1.0.0
327 *
328 * @throws Exception when $url is not a valid URL
329 * @param string $url a valid URL
330 */
331 public function set_url( $url ) {
332 // Validate the URL to make sure that it is one
333 if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
334 throw new Exception("The url provided ({$url}) is not valid.");
335 }
336 curl_setopt( $this->handler, CURLOPT_URL, filter_var( $url, FILTER_SANITIZE_URL ) );
337 $this->object['url'] = $url;
338 }
339
340 /**
341 * Sets the `user agent` for the cURL request
342 *
343 * @since 1.0.0
344 *
345 * @param string $agent a user agent
346 */
347 public function set_user_agent( $agent ) {
348 curl_setopt( $this->handler, CURLOPT_USERAGENT, $agent );
349 $this->object['agent'] = $agent;
350 }
351
352 /**
353 * Sets the headers on a cURL request
354 *
355 * @since 1.0.0
356 *
357 * @param string|array $headers sets extra headers for the cURL request
358 */
359 public function set_headers( $headers ) {
360 if ( ! is_array( $headers ) ) {
361 // Just transform it into an array
362 $headers = [ $headers ];
363 }
364 curl_setopt( $this->handler, CURLOPT_HTTPHEADER, $headers );
365 $this->object['headers'] = $headers;
366
367 }
368
369 /**
370 * Adds a header into the headers array
371 *
372 * You can actually pass multiple headers with an array, or just pass a single
373 * header with a string.
374 *
375 * @since 1.0.0
376 *
377 * @param string|array $header the header to add
378 */
379 public function add_header( $header ) {
380 // Check the variable. We expect string, but let's be sure.
381 if ( is_string( $header ) ) {
382 // Since it's a string, just push it into the headers array.
383 array_push( $this->headers, $header );
384 } else if ( is_array( $header ) ) {
385 // Well, they sent an array, so let's just assume that they want to set
386 // multiple headers here.
387 foreach( $header as $head ) :
388 if ( is_string( $head ) ) {
389 // Push each header into the headers array. We can't really check
390 // to make sure that these headers are okay or fine. So we'll just
391 // have to deal with failure later if they aren't.
392 array_push( $this->headers, $head );
393 } else {
394 // We're going to assume it's an array, so we'll push them together
395 // for the error message.
396 $head = implode( "|", $head );
397 // Bad header. Throw an exception.
398 throw new Exception( "You can't push these headers ({$h}).", 4 );
399 }
400 endforeach;
401 } else {
402 // Bad header. Throw an exception.
403 throw new Exception( "You can't push this header: `{$h}`", 4 );
404 }
405 // Set the headers in the cURL array.
406 $this->set_headers( $this->headers );
407 }
408
409 /**
410 * Sets the request to use `POST` rather than `GET`
411 *
412 * @since 1.0.0
413 */
414 public function use_post() {
415 // Update the curl handler to use post
416 curl_setopt( $this->handler, CURLOPT_POST, 1 );
417 // Update the internal object to use post
418 $this->object['request_type'] = 'post';
419 }
420
421 /**
422 * Adds a parameter to the parameters array
423 *
424 * @since 1.0.0
425 *
426 * @param string $key the name of the parameter
427 * @param string $value the value of the parameter
428 */
429 public function add_parameter( $key, $value ) {
430 $this->object['parameters'][$key] = $value;
431 }
432
433 /**
434 * Adds parameters to the request
435 *
436 * @since 1.0.0
437 *
438 * @throws Exception when passed something other than an array
439 * @param array $params an array of parameters
440 */
441 public function add_parameters( $params ) {
442 if ( ! is_array( $params ) ) {
443 throw new Exception( 'Parameters must be defined as an array', 4 );
444 }
445 foreach( $params as $key => $value ) :
446 $this->add_parameter( $key, $value );
447 endforeach;
448 }
449
450 /**
451 * Gets cached data
452 *
453 * This method first checks if the cache file exists. If `$ignore_life` is true,
454 * then it will return the data without checking the life. Otherwise, we'll check
455 * to make sure that the `$cache_life` is set. Next, we check the age of the cache.
456 * If any of these fail, then we return false, which indicates we should get new
457 * data. Otherwise, we retrieve the cache.
458 *
459 * @since 1.0.0
460 *
461 * @return string|boolean the data saved in the cache or false
462 */
463 private function get_cached_data( $ignore_life = false ) {
464
465 // Does the cache file exist?
466 if ( ! file_exists( $this->get_cache_file() ) ) {
467 return false;
468 }
469
470 // We don't care if the cache is expired. If there is data, give it
471 // to us anyway (this is good when there is no internet connection, but
472 // we really want the data).
473 if ( $ignore_life ) {
474 return file_get_contents( $this->get_cache_file() );
475 }
476
477 // Is the cache life set?
478 if ( ! isset( $this->cache_life ) ) {
479 return false;
480 }
481
482 // Has the has expired?
483 if ( $this->cache_life < $this->get_cache_age() ) {
484 return false;
485 }
486
487 // Return the contents of the cached file
488 return file_get_contents( $this->get_cache_file() );
489 }
490
491 /**
492 * Retrieves cached data regardless of cache life
493 *
494 * @since 1.0.0
495 *
496 * @return string|boolean returns the cached data or `false` if none exists
497 */
498 private function get_cached_data_anyway() {
499 return $this->get_cached_data( true );
500 }
501
502 /**
503 * Saves data to a cache file
504 *
505 * @since 1.0.0
506 *
507 * @param string $data the data to save to the cache
508 */
509 private function save_cache_data( $data ) {
510 // Make sure that the cache directory exists
511 $this->create_cache_dir();
512
513 // Debug-level log message
514 \Alphred\Log::log( "Saving cached data to `" . $this->get_cache_file() . "`", 0, 'debug' );
515
516 // Save the data
517 file_put_contents( $this->get_cache_file(), $data );
518 }
519
520 /**
521 * Creates a cache key based on the request object
522 *
523 * @since 1.0.0
524 *
525 * @return string a cache key
526 */
527 private function get_cache_key() {
528 return md5( json_encode( $this->object ) );
529 }
530
531 /**
532 * Returns the file cache
533 *
534 * @since 1.0.0
535 *
536 * @return string the full path to the cache file
537 */
538 private function get_cache_file() {
539 return $this->get_cache_dir() . '/' . $this->get_cache_key();
540 }
541
542 /**
543 * Returns the directory for the cache
544 *
545 * @since 1.0.0
546 *
547 * @return string full path to cache directory
548 */
549 private function get_cache_dir() {
550 $path = \Alphred\Globals::get( 'alfred_workflow_cache' );
551 if ( isset( $this->cache_bin ) && $this->cache_bin ) {
552 $path .= '/' . $this->cache_bin;
553 }
554 return $path;
555 }
556
557 /**
558 * Creates a cache directory if it does not exist
559 *
560 * @since 1.0.0
561 *
562 * @throws \Alphred\RunningOutsideOfAlfred when environmental variables are not set
563 * @return boolean success or failure if directory has been made
564 */
565 private function create_cache_dir() {
566 if ( ! \Alphred\Globals::get( 'alfred_workflow_cache' ) ) {
567 throw new \Alphred\RunningOutsideOfAlfred( 'Cache directory unknown', 4 );
568 }
569 if ( ! file_exists( $this->get_cache_dir() ) ) {
570 // Debug-level log message
571 \Alphred\Log::log( "Creating cache dir `" . $this->get_cache_dir() . "`", 0, 'debug' );
572 return mkdir( $this->get_cache_dir(), 0775, true );
573 }
574 }
575
576 /**
577 * Gets the age of a cache file
578 *
579 * @since 1.0.0
580 *
581 * @return integer the age of a file in seconds
582 */
583 private function get_cache_age() {
584 if ( ! file_exists( $this->get_cache_file() ) ) {
585 // Cache does not exist
586 return false;
587 }
588 return time() - filemtime( $this->get_cache_file() );
589 }
590
591 /**
592 * Clears a cache bin
593 *
594 * Call the file with no arguments if you aren't using a cache bin; however, this
595 * will choke on sub-directories.
596 *
597 * @throws Exception when encountering a sub-directory
598 *
599 * @param string|boolean $bin the name of the cache bin (or a URL if you're setting them automatically)
600 * @return null
601 */
602 public function clear_cache( $bin = false ) {
603 // Get the cache directory
604 $dir = \Alphred\Globals::cache();
605 if ( ! $bin ) {
606 return self::clear_directory( $dir );
607 }
608 if ( filter_var( $bin, FILTER_VALIDATE_URL ) ) {
609 $dir = $dir . '/' . parse_url( $bin, PHP_URL_HOST );
610 } else {
611 $dir = "{$dir}/{$bin}";
612 }
613 // Clear the directory
614 return self::clear_directory( $dir );
615 }
616
617 /**
618 * Clears all the files out of a directory
619 *
620 * @since 1.0.0
621 * @throws Exception when encountering a sub-directory
622 *
623 * @param string $dir a path to a directory
624 */
625 private function clear_directory( $dir ) {
626 if ( ! file_exists( $dir ) || ! is_dir( $dir ) || '.' === $dir ) {
627 // Throw an exception because this is a bad request to clear the cache
628 throw new Exception( "Cannot clear directory: `{$dir}`", 3 );
629 }
630
631 \Alphred\Log::console( "Clearing cache directory `{$dir}`.", 1 );
632
633 $files = array_diff( scandir( $dir ), [ '.', '..' ] );
634 foreach( $files as $file ) :
635 // Do not delete sub-directories
636 if ( is_dir( $file ) ) {
637 // We could expand this to support deleting sub-directories by just calling this method
638 // recursively, but it is better just to use the cache_bin and keep the caches separate.
639 throw new Exception( "Cannot delete subdirectory `{$file}` in `{$dir}`", 3 );
640 } else {
641 // Delete the file
642 unlink( "{$dir}/{$file}" );
643 }
644
645 endforeach;
646 }
647
648
649 /**********
650 * Old functions
651 **********/
652
653 // public function simple_download( $url, $destination = '', $mkdir = false ) {
654 // // Function to download a URL easily.
655 // $url = filter_var( $url, FILTER_SANITIZE_URL );
656 // if ( empty( $destination ) ) {
657 // return file_get_contents( $url ); }
658 // else {
659 // if ( file_exists( $destination ) && is_dir( $destination ) ) {
660 // $destination = $destination . '/' . basename( parse_url( $url, PHP_URL_PATH ) );
661 // }
662 // file_put_contents( $destination, file_get_contents( $url ) );
663 // }
664 // return $destination;
665 // }
666
667 // public function get_favicon( $url, $destination = '', $cache = true, $ttl = 604800 ) {
668 // $url = parse_url( $url );
669 // $domain = $url['host'];
670 // if ( $cache && $file = $this->cache( "{$domain}.png", 'favicons', $ttl ) ) {
671 // return $file;
672 // }
673 // $favicon = file_get_contents( "https://www.google.com/s2/favicons?domain={$domain}" );
674 // if ( empty( $destination ) ) {
675 // $destination = Globals::get( 'alfred_workflow_cache' ) . '/favicons';
676 // }
677 // if ( ! file_exists( $destination ) && substr( $destination, -4 ) !== '.png' ) {
678 // mkdir( $destination, 0755, true );
679 // }
680 // if ( file_exists( $destination ) && is_dir( $desintation ) ) {
681 // $destination .= "/{$domain}.png";
682 // }
683
684 // file_put_contents( $destination, $favicon );
685 // return $destination;
686 // }
687
688
689 }