├── LICENSE ├── README.md ├── Tests ├── Client │ ├── BitlyClientTest.php │ ├── api_limit_reached.json │ ├── invalid.json │ ├── response.json │ └── response_statuscode.json └── Testing │ └── BitlyClientFakeTest.php ├── composer.json ├── config └── bitly.php └── src ├── BitlyServiceProvider.php ├── Client └── BitlyClient.php ├── Exceptions ├── AccessDeniedException.php └── InvalidResponseException.php ├── Facade └── Bitly.php └── Testing └── BitlyClientFake.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Wessel Strengholt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Laravel Bitly Package 2 | ===================== 3 | 4 | A laravel package for generating Bitly short URLs. 5 | 6 | For more information see [Bitly](https://bitly.com/) 7 | 8 | [![Build Status](https://github.com/Shivella/laravel-bitly/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/Shivella/laravel-bitly/actions) [![Latest Stable Version](https://poser.pugx.org/shivella/laravel-bitly/v/stable)](https://packagist.org/packages/shivella/laravel-bitly) [![License](https://poser.pugx.org/shivella/laravel-bitly/license)](https://packagist.org/packages/shivella/laravel-bitly) [![Total Downloads](https://poser.pugx.org/shivella/laravel-bitly/downloads)](https://packagist.org/packages/shivella/laravel-bitly) 9 | 10 | ## Requirements ## 11 | 12 | Laravel 5.1 or later 13 | 14 | 15 | Installation 16 | ------------ 17 | Installation is a quick 3 step process: 18 | 19 | 1. Download laravel-bitly using composer 20 | 2. Enable the package in app.php 21 | 3. Configure your Bitly credentials 22 | 4. (Optional) Configure the package facade 23 | 24 | ### Step 1: Download laravel-bitly using composer 25 | 26 | Add shivella/laravel-bitly by running the command: 27 | 28 | ``` 29 | composer require shivella/laravel-bitly 30 | ``` 31 | 32 | ### Step 2: Enable the package in app.php 33 | 34 | Register the Service in: **config/app.php** 35 | 36 | ``` php 37 | Shivella\Bitly\BitlyServiceProvider::class, 38 | ```` 39 | 40 | ### Step 3: Configure Bitly credentials 41 | 42 | ``` 43 | php artisan vendor:publish --provider="Shivella\Bitly\BitlyServiceProvider" 44 | ``` 45 | 46 | Add this in you **.env** file 47 | 48 | ``` 49 | BITLY_ACCESS_TOKEN=your_secret_bitly_access_token 50 | ``` 51 | 52 | ### Step 4 (Optional): Configure the package facade 53 | 54 | Register the Bitly Facade in: **config/app.php** 55 | 56 | ```php 57 | [ 61 | 62 | 'App' => Illuminate\Support\Facades\App::class, 63 | 'Artisan' => Illuminate\Support\Facades\Artisan::class, 64 | 'Auth' => Illuminate\Support\Facades\Auth::class, 65 | // ... 66 | 'Bitly' => Shivella\Bitly\Facade\Bitly::class, 67 | ], 68 | // ... 69 | ]; 70 | ```` 71 | 72 | Usage 73 | ----- 74 | 75 | ```php 76 | getUrl('https://www.google.com/'); // http://bit.ly/nHcn3 79 | ```` 80 | 81 | Or if you want to use facade, add this in your class after namespace declaration: 82 | 83 | ```php 84 | make(Kernel::class)->bootstrap(); 123 | 124 | // swap Bitly client by a fake 125 | $app->singleton('bitly', function () { 126 | return new BitlyClientFake(); 127 | }); 128 | 129 | return $app; 130 | } 131 | } 132 | ``` 133 | 134 | As an alternative you may use `\Shivella\Bitly\Facade\Bitly::fake()` method to swap regular client by a fake. 135 | -------------------------------------------------------------------------------- /Tests/Client/BitlyClientTest.php: -------------------------------------------------------------------------------- 1 | guzzle = $this->createClientInterfaceMock(); 36 | $this->request = $this->createRequestMock(); 37 | $this->response = $this->createResponseInterfaceMock(); 38 | $this->stream = $this->createStreamInterfaceMock(); 39 | 40 | $this->bitlyClient = new BitlyClient($this->guzzle, 'test-token'); 41 | } 42 | 43 | public function testGetUrl() 44 | { 45 | $this->guzzle->expects(self::once()) 46 | ->method('send') 47 | ->willReturn($this->response); 48 | 49 | $this->response->expects(self::once()) 50 | ->method('getStatusCode') 51 | ->willReturn(200); 52 | 53 | $this->response->expects(self::once()) 54 | ->method('getBody') 55 | ->willReturn($this->stream); 56 | 57 | $this->stream->expects(self::once()) 58 | ->method('getContents') 59 | ->willReturn(file_get_contents(__DIR__ . '/response.json')); 60 | 61 | $this->assertSame('http://bit.ly/1VmfKqV', $this->bitlyClient->getUrl('https://www.test.com/foo')); 62 | } 63 | 64 | public function testGetUrlInvalidResponseException() : void 65 | { 66 | $this->guzzle->expects(self::once()) 67 | ->method('send') 68 | ->willReturn($this->response); 69 | 70 | $this->response->expects(self::once()) 71 | ->method('getStatusCode') 72 | ->willReturn(403); 73 | 74 | $this->response->expects(self::any()) 75 | ->method('getBody') 76 | ->willReturn($this->stream); 77 | 78 | self::expectException(\Shivella\Bitly\Exceptions\InvalidResponseException::class); 79 | 80 | $this->bitlyClient->getUrl('https://www.test.com/foo'); 81 | } 82 | 83 | public function testGetUrlInvalidResponse() 84 | { 85 | $this->guzzle->expects(self::once()) 86 | ->method('send') 87 | ->willReturn($this->response); 88 | 89 | $this->response->expects(self::once()) 90 | ->method('getStatusCode') 91 | ->willReturn(200); 92 | 93 | $this->response->expects(self::once()) 94 | ->method('getBody') 95 | ->willReturn($this->stream); 96 | 97 | $this->stream->expects(self::once()) 98 | ->method('getContents') 99 | ->willReturn(file_get_contents(__DIR__ . '/invalid.json')); 100 | 101 | $this->expectException(\Shivella\Bitly\Exceptions\InvalidResponseException::class); 102 | 103 | $this->bitlyClient->getUrl('https://www.test.com/foo'); 104 | } 105 | 106 | public function testGetUrlInvalidResponseNotFound() 107 | { 108 | $this->guzzle->expects(self::once()) 109 | ->method('send') 110 | ->willReturn($this->response); 111 | 112 | $this->response->expects(self::once()) 113 | ->method('getStatusCode') 114 | ->willReturn(400); 115 | 116 | $this->response->expects(self::any()) 117 | ->method('getBody') 118 | ->willReturn($this->stream); 119 | 120 | self::expectException(\Shivella\Bitly\Exceptions\InvalidResponseException::class); 121 | 122 | $this->bitlyClient->getUrl('https://www.test.com/foo'); 123 | } 124 | 125 | public function testGetUrlInvalidResponseInvalidStatusCodeResponse() 126 | { 127 | $this->guzzle->expects(self::once()) 128 | ->method('send') 129 | ->willReturn($this->response); 130 | 131 | $this->response->expects(self::once()) 132 | ->method('getStatusCode') 133 | ->willReturn(200); 134 | 135 | $this->response->expects(self::once()) 136 | ->method('getBody') 137 | ->willReturn($this->stream); 138 | 139 | $this->stream->expects(self::once()) 140 | ->method('getContents') 141 | ->willReturn(file_get_contents(__DIR__ . '/response_statuscode.json')); 142 | 143 | self::expectException('\Shivella\Bitly\Exceptions\InvalidResponseException'); 144 | 145 | $this->bitlyClient->getUrl('https://www.test.com/foo'); 146 | } 147 | 148 | public function testApiLimitReached() 149 | { 150 | $this->guzzle->expects(self::once()) 151 | ->method('send') 152 | ->willReturn($this->response); 153 | 154 | $this->response->expects(self::once()) 155 | ->method('getStatusCode') 156 | ->willReturn(200); 157 | 158 | $this->response->expects(self::once()) 159 | ->method('getBody') 160 | ->willReturn($this->stream); 161 | 162 | $this->stream->expects(self::once()) 163 | ->method('getContents') 164 | ->willReturn(file_get_contents(__DIR__ . '/api_limit_reached.json')); 165 | 166 | self::expectException(\Shivella\Bitly\Exceptions\InvalidResponseException::class); 167 | 168 | $this->bitlyClient->getUrl('https://www.test.com/foo'); 169 | } 170 | 171 | /** 172 | * @return MockObject|ClientInterface 173 | */ 174 | private function createClientInterfaceMock() : MockObject 175 | { 176 | return $this->getMockBuilder(ClientInterface::class)->getMock(); 177 | } 178 | 179 | /** 180 | * @return MockObject|Request 181 | */ 182 | private function createRequestMock() : MockObject 183 | { 184 | return self::getMockBuilder(Request::class) 185 | ->disableOriginalConstructor() 186 | ->getMock(); 187 | } 188 | 189 | /** 190 | * @return MockObject|ResponseInterface 191 | */ 192 | private function createResponseInterfaceMock() : MockObject 193 | { 194 | return self::getMockBuilder(ResponseInterface::class)->getMock(); 195 | } 196 | 197 | /** 198 | * @return MockObject|StreamInterface 199 | */ 200 | private function createStreamInterfaceMock() : MockObject 201 | { 202 | return self::getMockBuilder(StreamInterface::class)->getMock(); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /Tests/Client/api_limit_reached.json: -------------------------------------------------------------------------------- 1 | {"status_code":200,"status_txt":"RATE_LIMIT_EXCEEDED","data":{"url":"http://bit.ly/2nk8zqP","hash":"2nk8zqP","global_hash":"SmaYx","long_url":"http://www.laravel.com/","new_hash":1}} 2 | -------------------------------------------------------------------------------- /Tests/Client/invalid.json: -------------------------------------------------------------------------------- 1 | {"status_code": 403, "data": {}, "status_txt": "OK"} 2 | -------------------------------------------------------------------------------- /Tests/Client/response.json: -------------------------------------------------------------------------------- 1 | {"created_at":"1970-01-01T00:00:00+0000","id":"bit.ly/1VmfKqV","link":"http://bit.ly/1VmfKqV","custom_bitlinks":[],"long_url":"http://www.laravel.com","archived":false,"tags":[],"deeplinks":[],"references":{"group":""}} -------------------------------------------------------------------------------- /Tests/Client/response_statuscode.json: -------------------------------------------------------------------------------- 1 | {"status_code":503,"status_txt":"OK","data":{"url":"http://bit.ly/2nk8zqP","hash":"2nk8zqP","global_hash":"SmaYx","long_url":"http://www.laravel.com/","new_hash":1}} 2 | -------------------------------------------------------------------------------- /Tests/Testing/BitlyClientFakeTest.php: -------------------------------------------------------------------------------- 1 | bitlyClient = new BitlyClientFake(); 16 | } 17 | 18 | public function testGetUrl() 19 | { 20 | $shortUrlFoo = $this->bitlyClient->getUrl('https://www.test.com/foo'); 21 | 22 | $this->assertTrue(strlen($shortUrlFoo) < 22); 23 | $this->assertStringContainsString('://bit.ly', $shortUrlFoo); 24 | 25 | $shortUrlBar = $this->bitlyClient->getUrl('https://www.test.com/bar'); 26 | $this->assertNotSame($shortUrlFoo, $shortUrlBar); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shivella/laravel-bitly", 3 | "description": "Laravel package for generating bitly url", 4 | "keywords": [ 5 | "laravel", 6 | "bitly", 7 | "package", 8 | "short", 9 | "url", 10 | "link" 11 | ], 12 | "homepage": "https://github.com/Shivella/laravel-bitly", 13 | "license": "MIT", 14 | "support": { 15 | "issues": "https://github.com/Shivella/laravel-bitly/issues", 16 | "source": "https://github.com/Shivella/laravel-bitly" 17 | }, 18 | "authors": [ 19 | { 20 | "name": "Wessel Strengholt", 21 | "email": "wessel.strengholt@gmail.com" 22 | } 23 | ], 24 | "extra": { 25 | "laravel": { 26 | "providers": [ 27 | "Shivella\\Bitly\\BitlyServiceProvider" 28 | ], 29 | "aliases": { 30 | "Bitly": "Shivella\\Bitly\\Facade\\Bitly" 31 | } 32 | } 33 | }, 34 | "require": { 35 | "php": ">=7.1", 36 | "ext-json": "*", 37 | "illuminate/support": "^5.8 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", 38 | "guzzlehttp/guzzle": "~6.0 || ^7.0.1 || ^7.1 || ^7.2 || ^7.3 || ^7.4 || ^7.5" 39 | }, 40 | "require-dev": { 41 | "symfony/http-foundation": "^5.0 || ^7.2", 42 | "phpunit/phpunit": "^9.5 || ^10.5" 43 | }, 44 | "autoload": { 45 | "psr-4": { 46 | "Shivella\\Bitly\\": "src" 47 | } 48 | }, 49 | "autoload-dev": { 50 | "psr-4": { 51 | "Shivella\\Bitly\\Test\\": "Tests" 52 | } 53 | }, 54 | "scripts": { 55 | "tests": [ 56 | "phpunit Tests" 57 | ] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /config/bitly.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | 11 | return [ 12 | /* 13 | |-------------------------------------------------------------------------- 14 | | Access Token 15 | |-------------------------------------------------------------------------- 16 | | 17 | | Enter here your access token generated from: https://bitly.com/a/oauth_apps 18 | */ 19 | 20 | 'accesstoken' => env('BITLY_ACCESS_TOKEN', ''), 21 | ]; 22 | -------------------------------------------------------------------------------- /src/BitlyServiceProvider.php: -------------------------------------------------------------------------------- 1 | 4 | * 5 | * For the full copyright and license information, please view the LICENSE 6 | * file that was distributed with this source code. 7 | */ 8 | 9 | namespace Shivella\Bitly; 10 | 11 | use GuzzleHttp\Client; 12 | use Illuminate\Contracts\Support\DeferrableProvider; 13 | use Illuminate\Support\ServiceProvider; 14 | use Shivella\Bitly\Client\BitlyClient; 15 | 16 | /** 17 | * BitlyServiceProvider registers Bitly client as an application service. 18 | */ 19 | class BitlyServiceProvider extends ServiceProvider implements DeferrableProvider 20 | { 21 | /** 22 | * {@inheritdoc} 23 | */ 24 | public function register() 25 | { 26 | $this->app->singleton('bitly', function () { 27 | return new BitlyClient(new Client(), $this->app->make('config')->get('bitly.accesstoken', '')); 28 | }); 29 | 30 | $this->app->bind(BitlyClient::class, 'bitly'); 31 | } 32 | 33 | /** 34 | * {@inheritdoc} 35 | */ 36 | public function boot() 37 | { 38 | if ( ! $this->app->runningInConsole()) { 39 | return; 40 | } 41 | 42 | $configPath = $this->app->make('path.config'); 43 | 44 | $this->publishes([__DIR__ . '/../config/bitly.php' => $configPath.'/bitly.php']); 45 | } 46 | 47 | /** 48 | * {@inheritdoc} 49 | */ 50 | public function provides() 51 | { 52 | return [ 53 | BitlyClient::class, 54 | 'bitly' 55 | ]; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Client/BitlyClient.php: -------------------------------------------------------------------------------- 1 | 4 | * 5 | * For the full copyright and license information, please view the LICENSE 6 | * file that was distributed with this source code. 7 | */ 8 | 9 | namespace Shivella\Bitly\Client; 10 | 11 | use GuzzleHttp\ClientInterface; 12 | use GuzzleHttp\Exception\RequestException; 13 | use GuzzleHttp\Psr7\Request; 14 | use Shivella\Bitly\Exceptions\AccessDeniedException; 15 | use Shivella\Bitly\Exceptions\InvalidResponseException; 16 | use Symfony\Component\HttpFoundation\Response; 17 | 18 | use function json_decode; 19 | use function json_encode; 20 | 21 | /** 22 | * Class BitlyClient 23 | */ 24 | class BitlyClient 25 | { 26 | /** @var ClientInterface */ 27 | private $client; 28 | 29 | /** @var string $token */ 30 | private $token; 31 | 32 | /** 33 | * @param ClientInterface $client 34 | * @param string $token 35 | */ 36 | public function __construct(ClientInterface $client, $token) 37 | { 38 | $this->client = $client; 39 | $this->token = $token; 40 | } 41 | 42 | /** 43 | * @param string $url raw URL. 44 | * @param string|null $domain 45 | * @param string|null $group_guid 46 | * 47 | * @return string shorten URL. 48 | * @throws \GuzzleHttp\Exception\GuzzleException 49 | * @throws \Shivella\Bitly\Exceptions\AccessDeniedException 50 | * @throws \Shivella\Bitly\Exceptions\InvalidResponseException 51 | */ 52 | public function getUrl(string $url, ?string $domain = null, ?string $group_guid = null): string 53 | { 54 | $requestUrl = 'https://api-ssl.bitly.com/v4/shorten'; 55 | 56 | $header = [ 57 | 'Authorization' => 'Bearer ' . $this->token, 58 | 'Content-Type' => 'application/json', 59 | ]; 60 | 61 | $data = array_filter([ 62 | 'long_url' => $url, 63 | 'domain' => $domain, 64 | 'group_guid' => $group_guid, 65 | ]); 66 | 67 | try { 68 | $request = new Request('POST', $requestUrl, $header, json_encode($data)); 69 | 70 | $response = $this->client->send($request); 71 | } catch (RequestException $e) { 72 | if ($e->getResponse() !== null && $e->getResponse()->getStatusCode() === Response::HTTP_FORBIDDEN) { 73 | throw new AccessDeniedException('Invalid access token.', $e->getCode(), $e); 74 | } 75 | 76 | throw new InvalidResponseException($e->getMessage(), $e->getCode(), $e); 77 | } 78 | 79 | $statusCode = $response->getStatusCode(); 80 | $content = $response->getBody()->getContents(); 81 | 82 | if ($statusCode === Response::HTTP_FORBIDDEN) { 83 | throw new AccessDeniedException('Invalid access token.'); 84 | } 85 | 86 | if ( ! in_array($statusCode, [Response::HTTP_OK, Response::HTTP_CREATED])) { 87 | throw new InvalidResponseException('The API does not return a 200 or 201 status code. Response: '.$content); 88 | } 89 | 90 | $data = json_decode($content, true); 91 | 92 | if (isset($data['link'])) { 93 | return $data['link']; 94 | } 95 | 96 | if (isset($data['data']['link'])) { 97 | return $data['data']['link']; 98 | } 99 | 100 | throw new InvalidResponseException('The response does not contain a shortened link. Response: '.$content); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Exceptions/AccessDeniedException.php: -------------------------------------------------------------------------------- 1 | 4 | * 5 | * For the full copyright and license information, please view the LICENSE 6 | * file that was distributed with this source code. 7 | */ 8 | 9 | namespace Shivella\Bitly\Exceptions; 10 | 11 | /** 12 | * AccessDeniedException is thrown on external API access failure. 13 | */ 14 | class AccessDeniedException extends InvalidResponseException 15 | { 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidResponseException.php: -------------------------------------------------------------------------------- 1 | 4 | * 5 | * For the full copyright and license information, please view the LICENSE 6 | * file that was distributed with this source code. 7 | */ 8 | 9 | namespace Shivella\Bitly\Exceptions; 10 | 11 | /** 12 | * InvalidResponseException indicates an invalid or unexpected REST API response from the external service. 13 | * 14 | * @see \Shivella\Bitly\Client\BitlyClient 15 | */ 16 | class InvalidResponseException extends \Exception 17 | { 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/Facade/Bitly.php: -------------------------------------------------------------------------------- 1 | 4 | * 5 | * For the full copyright and license information, please view the LICENSE 6 | * file that was distributed with this source code. 7 | */ 8 | 9 | namespace Shivella\Bitly\Facade; 10 | 11 | use Illuminate\Support\Facades\Facade; 12 | use Shivella\Bitly\Testing\BitlyClientFake; 13 | 14 | /** 15 | * Bitly is a facade for the Bitly client. 16 | * 17 | * @see \Shivella\Bitly\Client\BitlyClient 18 | * 19 | * @method string getUrl(string $url) 20 | */ 21 | class Bitly extends Facade 22 | { 23 | /** 24 | * {@inheritdoc} 25 | */ 26 | protected static function getFacadeAccessor() 27 | { 28 | return 'bitly'; 29 | } 30 | 31 | /** 32 | * Replace the bound instance with a fake. 33 | * 34 | * @return \Shivella\Bitly\Testing\BitlyClientFake 35 | */ 36 | public static function fake() 37 | { 38 | static::swap($fake = new BitlyClientFake); 39 | 40 | return $fake; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Testing/BitlyClientFake.php: -------------------------------------------------------------------------------- 1 | Attention: URLs generated via this class will not respond correctly, do not use it in production environment. 14 | * 15 | * @see \Shivella\Bitly\Client\BitlyClient 16 | * @see \Shivella\Bitly\Facade\Bitly::fake() 17 | */ 18 | class BitlyClientFake extends BitlyClient 19 | { 20 | public function __construct() 21 | { 22 | // Unlike with other methods, PHP will not generate an E_STRICT level error message when __construct() is overridden 23 | // with different parameters than the parent __construct() method has. 24 | // @see https://www.php.net/manual/en/language.oop5.decon.php 25 | } 26 | 27 | /** 28 | * @param string $url raw URL. 29 | * @return string shorten URL. 30 | */ 31 | public function getUrl(string $url, ?string $domain = NULL, ?string $group_guid = NULL): string 32 | { 33 | return 'http://bit.ly/'.substr(sha1($url), 0, 6); 34 | } 35 | } 36 | --------------------------------------------------------------------------------