diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..9e2233c
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,21 @@
+name: "CodeQL"
+
+on: [ pull_request ]
+jobs:
+ lint:
+ name: CodeQL
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+ with:
+ fetch-depth: 2
+
+ - run: git checkout HEAD^2
+
+ - name: Install dependencies
+ run: composer install --profile --ignore-platform-reqs
+
+ - name: Run CodeQL
+ run: composer check
\ No newline at end of file
diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml
new file mode 100644
index 0000000..262fbb1
--- /dev/null
+++ b/.github/workflows/linter.yml
@@ -0,0 +1,21 @@
+name: "Linter"
+
+on: [ pull_request ]
+jobs:
+ lint:
+ name: Linter
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+ with:
+ fetch-depth: 2
+
+ - run: git checkout HEAD^2
+
+ - name: Install dependencies
+ run: composer install --profile --ignore-platform-reqs
+
+ - name: Run Linter
+ run: composer lint
\ No newline at end of file
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..f7ec70a
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,23 @@
+name: "Tests"
+
+on: [ pull_request ]
+jobs:
+ lint:
+ name: Tests
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+ with:
+ fetch-depth: 2
+
+ - run: git checkout HEAD^2
+
+ - name: Install dependencies
+ run: composer install --profile --ignore-platform-reqs
+
+ - name: Run Tests
+ run: php -S localhost:8000 tests/router.php &
+ composer test
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..17b4f31
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+vendor
+*.cache
+composer.lock
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..07b6e37
--- /dev/null
+++ b/README.md
@@ -0,0 +1,113 @@
+# Utopia Fetch
+Lite & fast micro PHP library that provides a convenient and flexible way to perform HTTP requests in PHP applications.
+
+# Usage
+The library provides a static method `Client::fetch()` that returns a `Response` object.
+
+The `Client::fetch()` method accepts the following parameters:
+- `url` - A **String** containing the URL to which the request is sent.
+- `method` - A **String** containing the HTTP method for the request. The default method is `GET`.
+- `headers` - An **associative array** of HTTP headers to send.
+- `body` - An **associative array** of data to send as the body of the request.
+- `query` - An **associative array** of query parameters.
+- `timeout` - An **integer** representing the maximum number of seconds to allow cURL functions to execute, the default is `15`.
+
+The `Response` object has the following methods:
+- `isOk()` - Returns **true** if the response status code is in the range 200-299, **false** otherwise.
+- `getBody()` - Returns the response body as a **String**.
+- `getHeaders()` - Returns an **associative array** of response headers.
+- `getStatusCode()` - Returns the response status code as an **integer**.
+- `getMethod()` - Returns the request method as a **String**.
+- `getUrl()` - Returns the request URL as a **String**.
+- `text()` - Returns the response body as a **String**.
+- `json()` - Returns the response body as an **associative array**.
+- `blob()` - Converts the response body to blob and return it as a **String**.
+
+## Examples
+### GET request
+```php
+ 'bar'
+];
+
+$resp = Client::fetch(
+ url: $url,
+ method: $method,
+ query: $query
+);
+
+if($resp->isOk()) {
+ print("Status Code: " . $resp->getStatusCode() . "\n");
+ print("Response Headers:\n");
+ print_r($resp->getHeaders());
+ print("Response Body:\n");
+ print($resp->getBody());
+}
+else {
+ print("Error: " . $resp->getStatusCode() . "\n");
+ print("Response Headers:\n");
+ print_r($resp->getHeaders());
+ print("Response Body:\n");
+ print($resp->getBody());
+}
+```
+### POST request
+```php
+ 'application/json',
+];
+$body = [
+ 'name' => 'John Doe',
+];
+$query = [
+ 'foo' => 'bar'
+];
+
+$resp = Client::fetch(
+ url: $url,
+ method: $method,
+ headers: $headers,
+ body: $body,
+ query: $query
+);
+
+print_r($resp->json());
+```
+### Send a file
+```php
+ 'application/json',
+];
+$filePath = strval(realpath(__DIR__ . '/tests/resources/logo.png')); // Absolute path to the file
+
+$body = [
+ 'file' => new \CURLFile($filePath, 'image/png', 'logo.png')
+];
+
+$resp = Client::fetch(
+ url: $url,
+ method: $method,
+ headers: $headers,
+ body: $body
+);
+
+print_r($resp->json());
+```
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..42e0070
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,26 @@
+{
+ "name": "utopia-php/fetch",
+ "description": "A simple library that provides an interface for making HTTP Requests.",
+ "type": "library",
+ "license": "MIT",
+ "require": {
+ "php": ">=8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.5",
+ "laravel/pint": "^1.5.0",
+ "phpstan/phpstan": "^1.10"
+ },
+ "scripts": {
+ "lint": "./vendor/bin/pint --test --config pint.json",
+ "format": "./vendor/bin/pint --config pint.json",
+ "check": "./vendor/bin/phpstan analyse -c phpstan.neon",
+ "test": "./vendor/bin/phpunit --configuration phpunit.xml --debug"
+ },
+ "autoload": {
+ "psr-4": {
+ "Utopia\\Fetch\\": "src/"
+ }
+ },
+ "authors": []
+}
\ No newline at end of file
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..51e3685
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,5 @@
+parameters:
+ level: 8
+ paths:
+ - src
+ - tests
\ No newline at end of file
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..ec39a7e
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,16 @@
+
+
+
+ ./tests/
+
+
+
\ No newline at end of file
diff --git a/pint.json b/pint.json
new file mode 100644
index 0000000..684f282
--- /dev/null
+++ b/pint.json
@@ -0,0 +1,3 @@
+{
+ "preset": "psr12"
+}
diff --git a/src/Client.php b/src/Client.php
new file mode 100644
index 0000000..4de2977
--- /dev/null
+++ b/src/Client.php
@@ -0,0 +1,130 @@
+ $data
+ * @param string $prefix
+ * @return array
+ */
+ private static function flatten(array $data, string $prefix = ''): array
+ {
+ $output = [];
+ foreach ($data as $key => $value) {
+ $finalKey = $prefix ? "{$prefix}[{$key}]" : $key;
+
+ if (is_array($value)) {
+ $output += self::flatten($value, $finalKey); // @todo: handle name collision here if needed
+ } else {
+ $output[$finalKey] = $value;
+ }
+ }
+
+ return $output;
+ }
+ /**
+ * This method is used to make a request to the server
+ * @param string $url
+ * @param array $headers
+ * @param string $method
+ * @param array|array $body
+ * @param array $query
+ * @param int $timeout
+ * @return Response
+ */
+ public static function fetch(
+ string $url,
+ array $headers = [],
+ string $method = self::METHOD_GET,
+ array $body = [],
+ array $query = [],
+ int $timeout = 15
+ ): Response {
+ // Process the data before making the request
+ if (!in_array($method, [self::METHOD_PATCH, self::METHOD_GET, self::METHOD_CONNECT, self::METHOD_DELETE, self::METHOD_POST, self::METHOD_HEAD, self::METHOD_OPTIONS, self::METHOD_PUT, self::METHOD_TRACE ])) { // If the method is not supported
+ throw new FetchException("Unsupported HTTP method");
+ }
+ if(isset($headers['content-type'])) {
+ match ($headers['content-type']) { // Convert the body to the appropriate format
+ self::CONTENT_TYPE_APPLICATION_JSON => $body = json_encode($body),
+ self::CONTENT_TYPE_APPLICATION_FORM_URLENCODED, self::CONTENT_TYPE_MULTIPART_FORM_DATA => $body = self::flatten($body),
+ self::CONTENT_TYPE_GRAPHQL => $body = $body[0],
+ default => $body = $body,
+ };
+ }
+ $headers = array_map(function ($i, $header) { // convert headers to appropriate format
+ return $i . ':' . $header;
+ }, array_keys($headers), $headers);
+ if($query) { // if the request has a query string, append it to the request URI
+ $url = rtrim($url, '?');
+ $url .= '?' . http_build_query($query);
+ }
+ $responseHeaders = [];
+ // Initialize the curl session
+ $ch = curl_init();
+ // Set the request URI
+ curl_setopt($ch, CURLOPT_URL, $url);
+ // Set the request headers
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+ // Set the request method
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
+ // Set the request body
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
+ // Save the response headers
+ curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) {
+ $len = strlen($header);
+ $header = explode(':', $header, 2);
+
+ if (count($header) < 2) { // ignore invalid headers
+ return $len;
+ }
+
+ $responseHeaders[strtolower(trim($header[0]))] = trim($header[1]);
+ return $len;
+ });
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 60);
+ curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
+ curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ $responseBody = curl_exec($ch); // Execute the curl session
+ $responseStatusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+
+ if (curl_errno($ch)) {
+ $errorMsg = curl_error($ch);
+ }
+ curl_close($ch);
+
+ if (isset($errorMsg)) {
+ throw new FetchException($errorMsg);
+ }
+ $response = new Response(
+ statusCode: $responseStatusCode,
+ headers: $responseHeaders,
+ body: $responseBody
+ );
+ return $response;
+ }
+}
diff --git a/src/FetchException.php b/src/FetchException.php
new file mode 100644
index 0000000..ac6a9bd
--- /dev/null
+++ b/src/FetchException.php
@@ -0,0 +1,19 @@
+message}\n"; // Return the class name, code and message
+ }
+}
diff --git a/src/Response.php b/src/Response.php
new file mode 100644
index 0000000..7c1cde0
--- /dev/null
+++ b/src/Response.php
@@ -0,0 +1,109 @@
+
+ */
+ private array $headers;
+ /**
+ * Response Status Code
+ *
+ * @var int
+ */
+ private int $statusCode;
+
+ /**
+ * Response constructor
+ * @param int $statusCode
+ * @param mixed $body
+ * @param array $headers
+ * @return void
+ */
+ public function __construct(
+ int $statusCode,
+ $body,
+ array $headers,
+ ) {
+ $this->body = $body;
+ $this->headers = $headers;
+ $this->statusCode = $statusCode;
+ }
+ # Getters
+ /**
+ * This method is used to get the response body as string
+ * @return mixed
+ */
+ public function getBody(): mixed
+ {
+ return $this->body;
+ }
+ /**
+ * This method is used to get the response headers
+ * @return array
+ */
+ public function getHeaders(): array
+ {
+ return $this->headers;
+ }
+ /**
+ * This method is used to get the response status code
+ * @return int
+ */
+ public function getStatusCode(): int
+ {
+ return $this->statusCode;
+ }
+ // Methods
+
+ /**
+ * This method is used to convert the response body to text
+ * @return string
+ */
+ public function text(): string
+ {
+ return \strval($this->body);
+ }
+ /**
+ * This method is used to convert the response body to JSON
+ * @return mixed
+ */
+ public function json(): mixed
+ {
+ $data = \json_decode($this->body, true);
+ if($data === null) { // Throw an exception if the data is null
+ throw new \Exception('Error decoding JSON');
+ }
+ return $data;
+ }
+
+ /*
+ * This method is used to convert the response body to blob
+ * @return string
+ */
+ public function blob(): string
+ {
+ $bin = "";
+ for($i = 0, $j = strlen($this->body); $i < $j; $i++) {
+ $bin .= decbin(ord($this->body)) . " ";
+ }
+ return $bin;
+ }
+}
diff --git a/tests/ClientTest.php b/tests/ClientTest.php
new file mode 100644
index 0000000..0f1edcd
--- /dev/null
+++ b/tests/ClientTest.php
@@ -0,0 +1,285 @@
+ $body
+ * @param array $headers
+ * @param array $query
+ * @return void
+ */
+ public function testFetch(
+ $url,
+ $method,
+ $body = [],
+ $headers = [],
+ $query = []
+ ): void {
+ $resp = null;
+ try {
+ $resp = Client::fetch(
+ url: $url,
+ method: $method,
+ headers: $headers,
+ body: $body,
+ query: $query
+ );
+ } catch (FetchException $e) {
+ echo $e;
+ return;
+ }
+ if ($resp->getStatusCode()===200) { // If the response is OK
+ $respData = $resp->json(); // Convert body to array
+ $this->assertEquals($respData['method'], $method); // Assert that the method is equal to the response's method
+ if($method != Client::METHOD_GET) {
+ if(empty($body)) { // if body is empty then response body should be an empty string
+ $this->assertEquals($respData['body'], '');
+ } else {
+ if($headers['content-type']!="application/x-www-form-urlencoded") {
+ $this->assertEquals( // Assert that the body is equal to the response's body
+ $respData['body'],
+ json_encode($body) // Converting the body to JSON string
+ );
+ }
+ }
+ }
+ $this->assertEquals($respData['url'], $url); // Assert that the url is equal to the response's url
+ $this->assertEquals(
+ json_encode($respData['query']), // Converting the query to JSON string
+ json_encode($query) // Converting the query to JSON string
+ ); // Assert that the args are equal to the response's args
+ $respHeaders = json_decode($respData['headers'], true); // Converting the headers to array
+ $host = $respHeaders['Host'];
+ if(array_key_exists('Content-Type', $respHeaders)) {
+ $contentType = $respHeaders['Content-Type'];
+ } else {
+ $contentType = $respHeaders['content-type'];
+ }
+ $contentType = explode(';', $contentType)[0];
+ $this->assertEquals($host, $url); // Assert that the host is equal to the response's host
+ if(empty($headers)) {
+ if(empty($body)) {
+ $this->assertEquals($contentType, 'application/x-www-form-urlencoded');
+ } else {
+ $this->assertEquals($contentType, 'application/json');
+ }
+ } else {
+ $this->assertEquals($contentType, $headers['content-type']); // Assert that the content-type is equal to the response's content-type
+ }
+ } else { // If the response is not OK
+ echo "Please configure your PHP inbuilt SERVER";
+ }
+ }
+ /**
+ * Test for sending a file in the request body
+ * @dataProvider sendFileDataSet
+ * @return void
+ */
+ public function testSendFile(
+ string $path,
+ string $contentType,
+ string $fileName
+ ): void {
+ $resp = null;
+ try {
+ $resp = Client::fetch(
+ url: 'localhost:8000',
+ method: Client::METHOD_POST,
+ headers: [
+ 'content-type' => 'multipart/form-data'
+ ],
+ body: [
+ 'file' => new \CURLFile(strval(realpath($path)), $contentType, $fileName)
+ ],
+ query: []
+ );
+ } catch (FetchException $e) {
+ echo $e;
+ return;
+ }
+ if ($resp->getStatusCode()===200) { // If the response is OK
+ $respData = $resp->json(); // Convert body to array
+ if(isset($respData['method'])) {
+ $this->assertEquals($respData['method'], Client::METHOD_POST);
+ } // Assert that the method is equal to the response's method
+ $this->assertEquals($respData['url'], 'localhost:8000'); // Assert that the url is equal to the response's url
+ $this->assertEquals(
+ json_encode($respData['query']), // Converting the query to JSON string
+ json_encode([]) // Converting the query to JSON string
+ ); // Assert that the args are equal to the response's args
+ $files = [ // Expected files array from response
+ 'file' => [
+ 'name' => $fileName,
+ 'full_path'=> $fileName,
+ 'type'=> $contentType,
+ 'error'=> 0
+ ]
+ ];
+ $resp_files = json_decode($respData['files'], true);
+ $this->assertEquals($files['file']['name'], $resp_files['file']['name']);
+ $this->assertEquals($files['file']['full_path'], $resp_files['file']['full_path']);
+ $this->assertEquals($files['file']['type'], $resp_files['file']['type']);
+ $this->assertEquals($files['file']['error'], $resp_files['file']['error']);
+ } else { // If the response is not OK
+ echo "Please configure your PHP inbuilt SERVER";
+ }
+ }
+ /**
+ * Test for getting a file as a response
+ * @dataProvider getFileDataSet
+ * @return void
+ */
+ public function testGetFile(
+ string $path,
+ string $type
+ ): void {
+ $resp = null;
+ try {
+ $resp = Client::fetch(
+ url: 'localhost:8000/'.$type,
+ method: Client::METHOD_GET,
+ headers: [],
+ body: [],
+ query: []
+ );
+ } catch (FetchException $e) {
+ echo $e;
+ return;
+ }
+ if ($resp->getStatusCode()===200) { // If the response is OK
+ $data = fopen($path, 'rb');
+ $size=filesize($path);
+ if($data && $size) {
+ $contents= fread($data, $size);
+ fclose($data);
+ $this->assertEquals($resp->getBody(), $contents); // Assert that the body is equal to the expected file contents
+ } else {
+ echo "Invalid file path in testcase";
+ }
+ } else { // If the response is not OK
+ echo "Please configure your PHP inbuilt SERVER";
+ }
+ }
+ /**
+ * Test for redirect
+ * @return void
+ */
+ public function testRedirect(): void
+ {
+ $resp = null;
+ try {
+ $resp = Client::fetch(
+ url: 'localhost:8000/redirect',
+ method: Client::METHOD_GET,
+ headers: [],
+ body: [],
+ query: []
+ );
+ } catch (FetchException $e) {
+ echo $e;
+ return;
+ }
+ if ($resp->getStatusCode()===200) { // If the response is OK
+ $respData = $resp->json(); // Convert body to array
+ $this->assertEquals($respData['page'], "redirectedPage"); // Assert that the page is the redirected page
+ } else { // If the response is not OK
+ echo "Please configure your PHP inbuilt SERVER";
+ }
+ }
+ /**
+ * Data provider for testFetch
+ * @return array>
+ */
+ public function dataSet(): array
+ {
+ return [
+ 'get' => [
+ 'localhost:8000',
+ Client::METHOD_GET
+ ],
+ 'getWithQuery' => [
+ 'localhost:8000',
+ Client::METHOD_GET,
+ [],
+ [],
+ [
+ 'name' => 'John Doe',
+ 'age' => '30',
+ ],
+ ],
+ 'postNoBody' => [
+ 'localhost:8000',
+ Client::METHOD_POST
+ ],
+ 'postJsonBody' => [
+ 'localhost:8000',
+ Client::METHOD_POST,
+ [
+ 'name' => 'John Doe',
+ 'age' => 30,
+ ],
+ [
+ 'content-type' => 'application/json'
+ ],
+ ],
+ 'postFormDataBody' => [
+ 'localhost:8000',
+ Client::METHOD_POST,
+ [
+ 'name' => 'John Doe',
+ 'age' => 30,
+ ],
+ [
+ 'content-type' => 'application/x-www-form-urlencoded'
+ ],
+ ]
+ ];
+ }
+
+ /**
+ * Data provider for testSendFile
+ * @return array>
+ */
+ public function sendFileDataSet(): array
+ {
+ return [
+ 'imageFile' => [
+ __DIR__.'/resources/logo.png',
+ 'image/png',
+ 'logo.png'
+ ],
+ 'textFile' => [
+ __DIR__.'/resources/test.txt',
+ 'text/plain',
+ 'text.txt'
+ ],
+ ];
+ }
+ /**
+ * Data provider for testGetFile
+ * @return array>
+ */
+ public function getFileDataset(): array
+ {
+ return [
+ 'imageFile' => [
+ __DIR__.'/resources/logo.png',
+ 'image'
+ ],
+ 'textFile' => [
+ __DIR__.'/resources/test.txt',
+ 'text'
+ ],
+ ];
+ }
+}
diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php
new file mode 100644
index 0000000..dbe61b6
--- /dev/null
+++ b/tests/ResponseTest.php
@@ -0,0 +1,74 @@
+ $headers
+ * @param int $statusCode
+ * @return void
+ */
+ public function testClassConstructorAndGetters(
+ $body,
+ $headers,
+ $statusCode
+ ): void {
+ $resp = new Response(
+ body: $body,
+ headers: $headers,
+ statusCode: $statusCode
+ );
+ $this->assertEquals($body, $resp->getBody());
+ $this->assertEquals($headers, $resp->getHeaders());
+ $this->assertEquals($statusCode, $resp->getStatusCode());
+ }
+
+ /**
+ * Data
+ * @dataProvider dataSet
+ * @param string $body
+ * @param array $headers
+ * @param int $statusCode
+ * @return void
+ */
+ public function testClassMethods(
+ $body,
+ $headers,
+ $statusCode
+ ) {
+ $resp = new Response(
+ body: $body,
+ headers: $headers,
+ statusCode: $statusCode,
+ );
+ $this->assertEquals($body, $resp->getBody()); // Assert that the body is equal to the response's body
+ $jsonBody = \json_decode($body, true); // Convert JSON string to object
+ $this->assertEquals($jsonBody, $resp->json()); // Assert that the JSON body is equal to the response's JSON body
+ $bin = ""; // Convert string to binary
+ for($i = 0, $j = strlen($body); $i < $j; $i++) {
+ $bin .= decbin(ord($body)) . " ";
+ }
+ $this->assertEquals($bin, $resp->blob()); // Assert that the blob body is equal to the response's blob body
+ }
+ /**
+ * Data provider for testClassConstructorAndGetters and testClassMethods
+ * @return array>
+ */
+ public function dataSet()
+ {
+ return [
+ 'dummyResponse'=>[
+ '{"name":"John Doe","age":30}',
+ [
+ 'content-type' => 'application/json'
+ ],
+ 200
+ ],
+ ];
+ }
+}
diff --git a/tests/resources/logo.png b/tests/resources/logo.png
new file mode 100644
index 0000000..4f5f4b0
Binary files /dev/null and b/tests/resources/logo.png differ
diff --git a/tests/resources/test.txt b/tests/resources/test.txt
new file mode 100644
index 0000000..2c0ef6d
--- /dev/null
+++ b/tests/resources/test.txt
@@ -0,0 +1 @@
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque volutpat lacinia posuere. Aliquam erat volutpat. Suspendisse id enim quis odio ultricies suscipit. Suspendisse at mauris pharetra, hendrerit risus in, sodales dui. Vivamus pellentesque in dolor a vulputate. Mauris at accumsan lacus. Donec ac facilisis arcu, ut tempus tellus. Vestibulum tempus sit amet purus et congue.
\ No newline at end of file
diff --git a/tests/router.php b/tests/router.php
new file mode 100644
index 0000000..5583fdf
--- /dev/null
+++ b/tests/router.php
@@ -0,0 +1,39 @@
+ $method,
+ 'url' => $url,
+ 'query' => $query,
+ 'body' => $body,
+ 'headers' => json_encode($headers),
+ 'files' => json_encode($files),
+ 'page' => $curPageName,
+];
+
+echo json_encode($resp);