diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..c5acb4de --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +*.php diff=php + +/.gitignore export-ignore +/.php_cs export-ignore +/.travis.yml export-ignore + +/tests export-ignore + +/phpunit.xml.dist export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..f5c790e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/build +/vendor +/composer.lock \ No newline at end of file diff --git a/.php_cs b/.php_cs new file mode 100644 index 00000000..fc527459 --- /dev/null +++ b/.php_cs @@ -0,0 +1,10 @@ +finder( + Symfony\CS\Finder\DefaultFinder::create() + ->exclude('vendor') + ->exclude('build') + ->in(__DIR__) + ) +; \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..7bf2e47e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,27 @@ +language: php + +sudo: false + +install: composer update + +php: + - 5.4 + - 5.5 + - 5.6 + +cache: + directories: + - $HOME/.composer/cache + +matrix: + fast_finish: true + +before_script: + - echo 'date.timezone = "Europe/Berlin"' >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini + +script: + - phpunit + +notifications: + email: + - jerome@kreait.com \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..7e21c539 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## Unreleased + +* Initial release \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..c4de5dcd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 kreait GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..4f6c0ea1 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Firebase PHP Client + +[![Latest Stable Version](https://poser.pugx.org/kreait/firebase-php/v/stable.png)](https://packagist.org/packages/kreait/firebase-php) +[![Build Status](https://secure.travis-ci.org/kreait/firebase-php.png?branch=master)](http://travis-ci.org/kreait/firebase-php) + +A PHP client library for [http://www.firebase.com](http://www.firebase.com). + +### Installation + +The recommended way to install Firebase is through +[Composer](http://getcomposer.org). + +```bash +# Install Composer +curl -sS https://getcomposer.org/installer | php +``` + +Next, run the Composer command to install the latest stable version: + +```bash +composer require kreait/firebase-php +``` + +After installing, you need to require Composer's autoloader: + +```php +require 'vendor/autoload.php'; +``` + +### Usage + +```php +use Kreait\Firebase\Firebase; +use Kreait\Firebase\Reference; + +$firebase = new Firebase('https://my-application-1234.firebaseio.com'); + +$allData = $firebase->get(); + +for ($i = 1; $i <= 5; $i++) { + $firebase->set() +} \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..9251025e --- /dev/null +++ b/composer.json @@ -0,0 +1,37 @@ +{ + "name": "kreait/firebase-php", + "description": "Firebase REST API client", + "keywords": ["firebase", "rest", "api", "http"], + "homepage": "https://github.com/kreait/firebase-php", + "license": "MIT", + "authors": [ + { + "name": "Jérôme Gamez", + "homepage": "https://github.com/jeromegamez", + "email": "jerome@kreait.com" + } + ], + "require": { + "php": ">=5.4", + "ext-curl": "*", + "egeloen/http-adapter": "~0.5@dev", + "psr/log": "~1.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.4", + "phpspec/prophecy-phpunit": "~1.0", + "monolog/monolog": "~1.12", + "guzzlehttp/guzzle": "~5.1" + }, + "autoload": { + "psr-4": { "Kreait\\Firebase\\": "src/" } + }, + "autoload-dev": { + "psr-4": { "Kreait\\Firebase\\": "tests/" } + }, + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 00000000..f28af62e --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,22 @@ + + + + + ./tests + + + + + + . + + ./tests + ./vendor + + + + diff --git a/src/Firebase.php b/src/Firebase.php new file mode 100644 index 00000000..acc66261 --- /dev/null +++ b/src/Firebase.php @@ -0,0 +1,157 @@ +logger = new \Psr\Log\NullLogger(); + $this->http = $http ?: new CurlHttpAdapter(); + + $configuration = $this->http->getConfiguration(); + $configuration->setBaseUrl(Utils::normalizeBaseUrl($baseUrl)); + $configuration->setKeepAlive(true); + } + + /** + * {@inheritdoc} + */ + public function get($location = null) + { + return $this->send($location, RequestInterface::METHOD_GET); + } + + /** + * {@inheritdoc} + */ + public function set($data, $location) + { + return $this->send($location, RequestInterface::METHOD_PUT, $data); + } + + /** + * {@inheritdoc} + */ + public function push($data, $location) + { + return $this->send($location, RequestInterface::METHOD_POST, $data); + } + + /** + * {@inheritdoc} + */ + public function update($data, $location) + { + return $this->send($location, RequestInterface::METHOD_PATCH, $data); + } + + /** + * {@inheritdoc} + */ + public function delete($location) + { + return $this->send($location, RequestInterface::METHOD_DELETE); + } + + /** + * Sends the request and returns the processed response data. + * + * @param string $location The location. + * @param string $method The HTTP method. + * @param array|object|null $data The data. + * + * @throws FirebaseException + * + * @return array|string|void The processed response data. + */ + private function send($location, $method, $data = null) + { + $location = (string) $location; // In case it is null + + if ($data && !is_array($data) && !is_object($data)) { + throw new \InvalidArgumentException(sprintf('array or object expected, %s given'), gettype($data)); + } + + $relativeUrl = sprintf('/%s.json', Utils::normalizeLocation($location)); + + $headers = [ + 'accept' => 'application/json', + 'accept-charset' => 'utf-8', + ]; + + $this->logger->debug(sprintf('%s request to %s', $method, $relativeUrl), ['data_sent' => $data]); + $request = $this->http->getConfiguration()->getMessageFactory()->createRequest( + $relativeUrl, + $method, + RequestInterface::PROTOCOL_VERSION_1_1, + $headers, + json_encode($data) + ); + + try { + $response = $this->http->sendRequest($request); + } catch (HttpAdapterException $e) { + $response = $e->hasResponse() ? $e->getResponse() : null; + throw FirebaseException::httpError($request, $response); + } + + switch ($response->getStatusCode()) { + case 400: + $this->logger->warning('Invalid location or PUT/POST data'); + throw FirebaseException::invalidDataOrLocation($request, $response); + case 403: + $this->logger->warning('Forbidden'); + throw FirebaseException::forbiddenAction($request, $response); + case 404: + $this->logger->warning('Request made of HTTP instead of HTTPS'); + throw FirebaseException::requestMadeOverHttpsInsteadOfHttp($request, $response); + case 417: + $this->logger->warning('No namespace specified'); + throw FirebaseException::noNameSpaceSpecified($request, $response); + } + + if (!$response->hasBody()) { + $this->logger->debug( + sprintf('Received valid, empty response from %s request to %s', $method, $relativeUrl) + ); + + return; + } + + $contents = $response->getBody()->getContents(); + $this->logger->debug( + sprintf('Received valid response from %s request to %s', $method, $relativeUrl), + ['data_received' => $contents] + ); + + return json_decode($contents, true); + } +} diff --git a/src/FirebaseException.php b/src/FirebaseException.php new file mode 100644 index 00000000..7016b0df --- /dev/null +++ b/src/FirebaseException.php @@ -0,0 +1,135 @@ +request !== null; + } + + public function getRequest() + { + return $this->request; + } + + public function setRequest(RequestInterface $request = null) + { + $this->request = $request; + } + + public function hasResponse() + { + return $this->response !== null; + } + + public function getResponse() + { + return $this->response; + } + + public function setResponse(ResponseInterface $response = null) + { + $this->response = $response; + } + + public static function baseUrlIsInvalid($url) + { + return new self(sprintf('The base url "%s" is invalid.', $url)); + } + + public static function baseUrlSchemeMustBeHttps($url) + { + return new self(sprintf('The base url must point to an https URL, "%s" given.', $url)); + } + + public static function locationIsInvalid($location) + { + return new self(sprintf('A location "%s" is invalid.', $location)); + } + + public static function nodeKeyContainsForbiddenChars($key, $forbiddenChars) + { + return new self( + sprintf( + 'The node key "%s" contains on of the following invalid characters: %s', + $key, + $forbiddenChars + ) + ); + } + + public static function locationKeyHasTooManyLevels($allowed, $given) + { + return new self(sprintf('A location key must not have more than %s levels, %s given.', $allowed, $given)); + } + + public static function locationKeyIsTooLong($allowed, $given) + { + return new self(sprintf('A location key must not be longer than %s bytes, %s bytes given.', $allowed, $given)); + } + + public static function requestMadeOverHttpsInsteadOfHttp() + { + return new self('Firebase requests HTTPS connections, HTTP connection given.'); + } + + public static function invalidDataOrLocation(RequestInterface $request = null, ResponseInterface $response = null) + { + $e = new self('Invalid location, PUT or POST data.'); + $e->setRequest($request); + $e->setResponse($response); + + return $e; + } + + public static function noNameSpaceSpecified(RequestInterface $request = null, ResponseInterface $response = null) + { + $e = new self('The API call did not specify a namespace.'); + $e->setRequest($request); + $e->setResponse($response); + + return $e; + } + + public static function forbiddenAction(RequestInterface $request = null, ResponseInterface $response = null) + { + $e = new self('Forbidden action. Please review your client settings or check the Firebase settings.'); + $e->setRequest($request); + $e->setResponse($response); + + return $e; + } + + public static function httpError(RequestInterface $request = null, ResponseInterface $response = null) + { + $e = new self('HTTP Error'); + $e->setRequest($request); + $e->setResponse($response); + + return $e; + } +} diff --git a/src/FirebaseInterface.php b/src/FirebaseInterface.php new file mode 100644 index 00000000..67c1ba78 --- /dev/null +++ b/src/FirebaseInterface.php @@ -0,0 +1,61 @@ +firebase = $firebase; + $this->referenceUrl = Utils::normalizeLocation($referenceUrl); + $this->logger = new NullLogger(); + } + + /** + * {@inheritdoc} + */ + public function set($data, $location = null) + { + $fullLocation = Utils::normalizeLocation(sprintf('%s/%s', $this->referenceUrl, $location)); + + return $this->firebase->set($data, $fullLocation); + } + + /** + * {@inheritdoc} + */ + public function push($data, $location = null) + { + $fullLocation = Utils::normalizeLocation(sprintf('%s/%s', $this->referenceUrl, $location)); + + return $this->firebase->push($data, $fullLocation); + } + + /** + * {@inheritdoc} + */ + public function update($data, $location = null) + { + $fullLocation = Utils::normalizeLocation(sprintf('%s/%s', $this->referenceUrl, $location)); + + return $this->firebase->update($data, $fullLocation); + } + + /** + * {@inheritdoc} + */ + public function delete($location = null) + { + $fullLocation = Utils::normalizeLocation(sprintf('%s/%s', $this->referenceUrl, $location)); + + return $this->firebase->delete($fullLocation); + } + + /** + * {@inheritdoc} + */ + public function get($location = null) + { + $fullLocation = Utils::normalizeLocation(sprintf('%s/%s', $this->referenceUrl, $location)); + + return $this->firebase->get($fullLocation); + } +} diff --git a/src/ReferenceInterface.php b/src/ReferenceInterface.php new file mode 100644 index 00000000..0245699a --- /dev/null +++ b/src/ReferenceInterface.php @@ -0,0 +1,37 @@ + FirebaseInterface::MAX_TREE_DEPTH) { + throw FirebaseException::locationKeyHasTooManyLevels(FirebaseInterface::MAX_TREE_DEPTH, $count); + } + + array_walk($parts, ['self', 'validateNodeKey']); + + return implode('/', $parts); + } + + private static function validateNodeKey($key) + { + $pattern = sprintf('/[%s]/', preg_quote(FirebaseInterface::FORBIDDEN_NODE_KEY_CHARS, '/')); + + if (preg_match($pattern, $key)) { + throw FirebaseException::nodeKeyContainsForbiddenChars($key, FirebaseInterface::FORBIDDEN_NODE_KEY_CHARS); + } + + if (($length = mb_strlen($key, '8bit')) > FirebaseInterface::MAX_NODE_KEY_LENGTH_IN_BYTES) { + throw FirebaseException::locationKeyIsTooLong(FirebaseInterface::MAX_NODE_KEY_LENGTH_IN_BYTES, $length); + } + } +} diff --git a/tests/FirebaseTest.php b/tests/FirebaseTest.php new file mode 100644 index 00000000..b8c425a9 --- /dev/null +++ b/tests/FirebaseTest.php @@ -0,0 +1,46 @@ +f = new Firebase('https://example.com'); + } + + public function testDefaultState() + { + $this->assertAttributeInstanceOf('Ivory\HttpAdapter\CurlHttpAdapter', 'http', $this->f); + } + + public function testInitialState() + { + $f = new Firebase('https://example.com', $http = new CurlHttpAdapter()); + + $this->assertAttributeSame($http, 'http', $f); + } + + public function secretProvider() + { + return [ + [uniqid()], + ]; + } +} diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php new file mode 100644 index 00000000..c36b5ef4 --- /dev/null +++ b/tests/UtilsTest.php @@ -0,0 +1,131 @@ +assertEquals($expected ?: $given, Utils::normalizeLocation($given)); + } + + public function testNormalizeInvalidLocation() + { + $max = FirebaseInterface::MAX_TREE_DEPTH; + $maxPlusOne = $max + 1; + + $this->setExpectedException( + '\Kreait\Firebase\FirebaseException', + sprintf('A location key must not have more than %s levels, %s given.', $max, $maxPlusOne) + ); + + Utils::normalizeLocation(str_pad('', $maxPlusOne * 2, "x/")); + } + + /** + * @param string $location + * @dataProvider locationsWithInvalidCharProvider + */ + public function testNormalizeLocationWithInvalidChar($location) + { + $this->setExpectedException( + '\Kreait\Firebase\FirebaseException', + sprintf( + 'The node key "%s" contains on of the following invalid characters: %s', + $location, + FirebaseInterface::FORBIDDEN_NODE_KEY_CHARS) + ); + + Utils::normalizeLocation($location); + } + + public function testNormalizeLocationWithTooLongNodeKey() + { + $max = FirebaseInterface::MAX_NODE_KEY_LENGTH_IN_BYTES; + $maxPlusOne = $max + 1; + + $location = str_pad('', $maxPlusOne, 'x'); + + $this->setExpectedException( + '\Kreait\Firebase\FirebaseException', + sprintf( + 'A location key must not be longer than %s bytes, %s bytes given.', + $max, + $maxPlusOne + ) + ); + + Utils::normalizeLocation($location); + } + + /** + * @param string $given + * @param string|null $expected + * + * @dataProvider baseUrlProvider + */ + public function testNormalizeBaseUrl($given, $expected = null) + { + $this->assertEquals($expected ?: $given, Utils::normalizeBaseUrl($given)); + } + + /** + * @expectedException \Kreait\Firebase\FirebaseException + * @expectedExceptionMessage The base url must point to an https URL, "http://foo.bar" given. + */ + public function testNormalizeNonHttpsBaseUrl() + { + Utils::normalizeBaseUrl('http://foo.bar'); + } + + /** + * @expectedException \Kreait\Firebase\FirebaseException + * @expectedExceptionMessage The base url "invalid_base_url" is invalid. + */ + public function testNormalizeInvalidBaseUrl() + { + Utils::normalizeBaseUrl('invalid_base_url'); + } + + public function locationProvider() + { + return [ + [''], + ['location'], + ['location/with/a/certain/depth'], + ['/location/should/not/have/leading/slash', 'location/should/not/have/leading/slash'], + ['location/should/not/have/trailing/slash/', 'location/should/not/have/trailing/slash'], + ]; + } + + public function baseUrlProvider() + { + return [ + ['https://foo.bar'], + ['https://foo.bar/', 'https://foo.bar'], + ]; + } + + public function locationsWithInvalidCharProvider() + { + $chars = str_split(FirebaseInterface::FORBIDDEN_NODE_KEY_CHARS); + $array = []; + foreach ($chars as $c) { + $array[] = [sprintf('%s%s%s', uniqid(), $c, uniqid())]; + } + + return $array; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 00000000..d69302f4 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,9 @@ +