diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..dd9a2b5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9e9519b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,19 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/art export-ignore +/docs export-ignore +/tests export-ignore +/.editorconfig export-ignore +/.php_cs.dist.php export-ignore +/psalm.xml export-ignore +/psalm.xml.dist export-ignore +/testbench.yaml export-ignore +/UPGRADING.md export-ignore +/phpstan.neon.dist export-ignore +/phpstan-baseline.neon export-ignore diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..13c367c --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing + +Contributions are **welcome** and will be fully **credited**. + +Please read and understand the contribution guide before creating an issue or pull request. + +## Etiquette + +This project is open source, and as such, the maintainers give their free time to build and maintain the source code +held within. They make the code freely available in the hope that it will be of use to other developers. It would be +extremely unfair for them to suffer abuse or anger for their hard work. + +Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the +world that developers are civilized and selfless people. + +It's the duty of the maintainer to ensure that all submissions to the project are of sufficient +quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. + +## Viability + +When requesting or submitting new features, first consider whether it might be useful to others. Open +source projects are used by many developers, who may have entirely different needs to your own. Think about +whether or not your feature is likely to be used by other users of the project. + +## Procedure + +Before filing an issue: + +- Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. +- Check to make sure your feature suggestion isn't already present within the project. +- Check the pull requests tab to ensure that the bug doesn't have a fix in progress. +- Check the pull requests tab to ensure that the feature isn't already in progress. + +Before submitting a pull request: + +- Check the codebase to ensure that your feature doesn't already exist. +- Check the pull requests to ensure that another person hasn't already submitted the feature or fix. + +## Requirements + +If the project maintainer has any additional requirements, you will find them listed here. + +- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer). + +- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. + +- **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. + +- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. + +- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. + +**Happy coding**! diff --git a/.github/FUNDING.md b/.github/FUNDING.md new file mode 100644 index 0000000..d5d6de4 --- /dev/null +++ b/.github/FUNDING.md @@ -0,0 +1 @@ +github: lemaur diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..fccf1be --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,66 @@ +name: Bug Report +description: Report an Issue or Bug with the Package +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + We're sorry to hear you have a problem. Can you help us solve it by providing the following details. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you expect to happen? + placeholder: I cannot currently do X thing because when I do, it breaks X thing. + validations: + required: true + - type: textarea + id: how-to-reproduce + attributes: + label: How to reproduce the bug + description: How did this occur, please add any config values used and provide a set of reliable steps if possible. + placeholder: When I do X I see Y. + validations: + required: true + - type: input + id: package-version + attributes: + label: Package Version + description: What version of our Package are you running? Please be as specific as possible + placeholder: 2.0.0 + validations: + required: true + - type: input + id: php-version + attributes: + label: PHP Version + description: What version of PHP are you running? Please be as specific as possible + placeholder: 8.2.0 + validations: + required: true + - type: input + id: laravel-version + attributes: + label: Laravel Version + description: What version of Laravel are you running? Please be as specific as possible + placeholder: 10.0.0 + validations: + required: true + - type: dropdown + id: operating-systems + attributes: + label: Which operating systems does with happen with? + description: You may select more than one. + multiple: true + options: + - macOS + - Windows + - Linux + - type: textarea + id: notes + attributes: + label: Notes + description: Use this field to provide any other notes that you feel might be relevant to the issue. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b8fad8a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: false +contact_links: + - name: Ask a question + url: https://github.com/lemaur/laravel-pinterest-api/discussions/new?category=q-a + about: Ask the community for help + - name: Request a feature + url: https://github.com/lemaur/laravel-pinterest-api/discussions/new?category=ideas + about: Share ideas for new features + - name: Report a security issue + url: https://github.com/lemaur/laravel-pinterest-api/security/policy + about: Learn how to notify us for sensitive bugs + - name: Report a bug + url: https://github.com/leMaur/toolbox/issues/new + about: Report a reproducible bug diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..c20724d --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,3 @@ +# Security Policy + +If you discover any security related issues, please email hello@lemaur.me instead of using the issue tracker. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..30c8a49 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" \ No newline at end of file diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..32f7754 --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,32 @@ +name: dependabot-auto-merge +on: pull_request_target + +permissions: + pull-requests: write + contents: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v1.3.6 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + + - name: Auto-merge Dependabot PRs for semver-minor updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + + - name: Auto-merge Dependabot PRs for semver-patch updates + if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/fix-php-code-style-issues.yml b/.github/workflows/fix-php-code-style-issues.yml new file mode 100644 index 0000000..097689b --- /dev/null +++ b/.github/workflows/fix-php-code-style-issues.yml @@ -0,0 +1,27 @@ +name: Fix PHP code style issues + +on: + push: + paths: + - '**.php' + +permissions: + contents: write + +jobs: + php-code-styling: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref }} + + - name: Fix PHP code style issues + uses: aglipanci/laravel-pint-action@2.2.0 + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Fix styling diff --git a/.github/workflows/phpmd.yml b/.github/workflows/phpmd.yml new file mode 100644 index 0000000..b94f017 --- /dev/null +++ b/.github/workflows/phpmd.yml @@ -0,0 +1,41 @@ +name: PHPMD + +on: + push: + branches: + - main + + schedule: + - cron: '31 6 * * 0' + +permissions: + contents: read + +jobs: + phpmd: + name: Run PHPMD scanning + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + coverage: none + tools: phpmd + + - name: Run PHPMD + run: phpmd . sarif codesize --reportfile phpmd-results.sarif + continue-on-error: true + + - name: Upload analysis results to GitHub + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: phpmd-results.sarif + wait-for-processing: true diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..9d41c0c --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,26 @@ +name: PHPStan + +on: + push: + paths: + - '**.php' + - 'phpstan.neon.dist' + +jobs: + phpstan: + name: phpstan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + + - name: Install composer dependencies + uses: ramsey/composer-install@v2 + + - name: Run PHPStan + run: ./vendor/bin/phpstan --error-format=github diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..57152e1 --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,51 @@ +name: run-tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest, windows-latest] + php: [8.2, 8.1] + laravel: [10.*] + stability: [prefer-lowest, prefer-stable] + include: + - laravel: 10.* + testbench: 8.* + carbon: ^2.63 + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Setup problem matchers + run: | + echo "::add-matcher::${{ runner.tool_cache }}/php.json" + echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update + composer update --${{ matrix.stability }} --prefer-dist --no-interaction + + - name: List Installed Dependencies + run: composer show -D + + - name: Execute tests + run: vendor/bin/pest diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml new file mode 100644 index 0000000..8c12ba9 --- /dev/null +++ b/.github/workflows/update-changelog.yml @@ -0,0 +1,31 @@ +name: "Update Changelog" + +on: + release: + types: [released] + +permissions: + contents: write + +jobs: + update: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: main + + - name: Update Changelog + uses: stefanzweifel/changelog-updater-action@v1 + with: + latest-version: ${{ github.event.release.name }} + release-notes: ${{ github.event.release.body }} + + - name: Commit updated CHANGELOG + uses: stefanzweifel/git-auto-commit-action@v4 + with: + branch: main + commit_message: Update CHANGELOG + file_pattern: CHANGELOG.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7f372d --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.idea +.phpunit.cache +build +composer.lock +coverage +docs +phpunit.xml +phpstan.neon +testbench.yaml +vendor +node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2f3c9b4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to `laravel-pinterest-api` will be documented in this file. + +## 0.1.0 - 2023-03-26 + +**Full Changelog**: https://github.com/leMaur/laravel-pinterest-api/commits/0.1.0 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..f131d54 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) lemaur + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2163cc8 --- /dev/null +++ b/README.md @@ -0,0 +1,343 @@ +# Laravel Pinterest Api + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/lemaur/laravel-pinterest-api.svg?style=flat-square)](https://packagist.org/packages/lemaur/laravel-pinterest-api) +[![Total Downloads](https://img.shields.io/packagist/dt/lemaur/laravel-pinterest-api.svg?style=flat-square)](https://packagist.org/packages/lemaur/laravel-pinterest-api) +[![License](https://img.shields.io/packagist/l/lemaur/laravel-pinterest-api.svg?style=flat-square&color=yellow)](https://github.com/leMaur/laravel-pinterest-api/blob/master/LICENSE.md) +[![Tests](https://img.shields.io/github/actions/workflow/status/lemaur/laravel-pinterest-api/run-tests.yml?label=tests&style=flat-square)](https://github.com/leMaur/laravel-pinterest-api/actions/workflows/run-tests.yml) +[![GitHub Sponsors](https://img.shields.io/github/sponsors/lemaur?style=flat-square&color=ea4aaa)](https://github.com/sponsors/leMaur) +[![Trees](https://img.shields.io/badge/dynamic/json?color=yellowgreen&style=flat-square&label=Trees&query=%24.total&url=https%3A%2F%2Fpublic.offset.earth%2Fusers%2Flemaur%2Ftrees)](https://ecologi.com/lemaur?r=6012e849de97da001ddfd6c9) + + +An Object-Oriented wrapper for consuming Pinterest API with Laravel. + +It uses [Pinterest API v5](https://developers.pinterest.com/docs/api/v5/). + +
+ +> **Warning**: This package DOESN'T store Pinterest's credentials, you should provide your own logic! +> 👉 Please read carefully the [Manage credentials](#manage-credentials) section! 👈 + + +
+ +## Support Me + +Hey folks, + +Do you like this package? Do you find it useful, and it fits well in your project? + +I am glad to help you, and I would be so grateful if you considered supporting my work. + +You can even choose 😃: +* You can [sponsor me 😎](https://github.com/sponsors/leMaur) with a monthly subscription. +* You can [buy me a coffee ☕ or a pizza 🍕](https://github.com/sponsors/leMaur?frequency=one-time&sponsor=leMaur) just for this package. +* You can [plant trees 🌴](https://ecologi.com/lemaur?r=6012e849de97da001ddfd6c9). By using this link we will both receive 30 trees for free and the planet (and me) will thank you. +* You can "Star ⭐" this repository (it's free 😉). + + +
+ +## Roadmap + +This package still in development. +You can vote for endpoints not yet covered by [requesting them here](https://github.com/lemaur/laravel-pinterest-api/discussions/new?category=polls). +If so, it would be great if you wanted [sponsor me](https://github.com/sponsors/leMaur) to support my work. + +- [ ] Pins + - [ ] list + - [x] create + - [ ] get + - [ ] delete + - [ ] update + - [ ] save + - [ ] analytics +- [ ] Boards + - [x] list + - [ ] create + - [ ] get + - [ ] delete + - [ ] update + - [ ] pins +- [ ] Board Sections +- [ ] Ad Accounts +- [ ] Ad Groups +- [ ] Ads +- [ ] Audience Insights +- [ ] Audiences +- [ ] Bulk +- [ ] Campaigns +- [ ] Catalogs +- [ ] Conversion Events +- [ ] Conversion Tags +- [ ] Customer Lists +- [ ] Integrations +- [ ] Interests +- [ ] Keywords +- [ ] Media +- [x] OAuth +- [ ] Order Lines +- [ ] Product Group Promotions +- [ ] Product Groups +- [ ] Resources +- [ ] Search +- [ ] Terms +- [ ] Terms of Service +- [ ] User Account + + +
+ +## Installation + +You can install the package via composer: +```bash +composer require lemaur/laravel-pinterest-api +``` + +You can publish the config file with: +```bash +php artisan vendor:publish --tag="pinterest-api-config" +``` + +
+ +## Manage credentials + +[Pinterest API Authentication](https://developers.pinterest.com/docs/getting-started/authentication/) follows the [OAuth2 standard](https://datatracker.ietf.org/doc/html/rfc6749/). + +The package contains helpful methods to obtain the credentials from Pinterest OAuth server, but where to store them is up to you! +Every project is different, with different requirements. You can store credentials on disk, or you can write them on DB and so on... + +> **Note**: You'll need to register a new app on Pinterest and get the app ID and secret key. +> But don't worry, we'll do it in the [next section](#require-pinterest-access). + +Here I'll show you how to configure your project to manage credentials. + +The authentication flow returns an object containing `access_token` and `refresh_token` within the expiration timestamps and other information. + +Here an example: +```json +{ + "access_token": "{an access token string prefixed with 'pina'}", + "refresh_token": "{a refresh token string prefixed with 'pinr'}", + "response_type": "authorization_code", + "token_type": "bearer", + "expires_in": 2592000, + "refresh_token_expires_in": 31536000, + "scope": "boards:read boards:write pins:read" +} +``` + +The package emits `Lemaur\Pinterest\Events\CredentialsRetrieved::class` event within `Lemaur\Pinterest\Data\OAuthData` object when it receives the credentials. +You can listen for this event in your project and store the credentials where you want. + +To do that, you need to create a new listener `php artisan make:listener --event=\\Lemaur\\Pinterest\\Events\\CredentialsRetrieved StorePinterestCredentials` + +And inside the `handle` method you can decide where to store the credentials. +```php +// file: app/Listeners/StorePinterestCredentials.php + +/** + * Handle the event. + * + * @param \Lemaur\Pinterest\Events\CredentialsRetrieved $event + * @return void + */ +public function handle(CredentialsRetrieved $event) +{ + // Store the credentials from `$event->oauth`. + // Where `$event->oauth` is an instance of `Lemaur\Pinterest\Data\OAuthData`. + + /** + * For e.g. you can extend your User model by adding a json column `pinterest_credentials` + * and store the credentials for each authenticated user. + * + * \Illuminate\Support\Facades\Auth::user()->update([ + * 'pinterest_credentials' = $event->oauth->toArray(), + * ]); + */ +} +``` + +Don't forget to register the listener in the `App\Providers\EventServiceProvider`. +```php +// file: app/Providers/EventServiceProvider.php + +/** + * The event listener mappings for the application. + * + * @var array> + */ +protected $listen = [ + \Lemaur\Pinterest\Events\CredentialsRetrieved::class => [ + \App\Listeners\StorePinterestCredentials::class, + ], +]; +``` + +Now it's time to publish the service provider: +```bash +php artisan vendor:publish --tag="pinterest-api-provider" +``` + +Inside the `register` method you will find a predefined implementation. + +As you can see in the `@TODO` comment, here is where you should pass the credentials you previously stored. +```php +public function register(): void +{ + $this->app->singleton(PinterestContract::class, fn (Application $app) => new PinterestService( + config: ConfigData::fromConfig($app['config']['pinterest']), + oauth: OAuthData::from([]), // @TODO: <-- please fill in the credentials... + )); +} +``` + +In the previous examples we stored the credentials in the user's table. +So here, we can fetch them from the authenticated user. +```php +public function register(): void +{ + $this->app->singleton(PinterestContract::class, fn (Application $app) => new PinterestService( + config: ConfigData::fromConfig($app['config']['pinterest']), + oauth: OAuthData::from(\Illuminate\Support\Facades\Auth::user()->pinterest_credentials), + )); +} +``` + + +
+ +## Require Pinterest Access + +### Register and get your app ID and secret key + +> **Note**: internally `app ID` is `client_id`, internally `secret key` is `client_secret`! + +1. Log into www.pinterest.com; open a new tab with the account that you’ll use to manage your apps. +2. Go to My Apps. +3. Select Connect app and complete the request form with your app information. +4. Submit your request to get trial access. +5. As soon as Pinterest completes the review, Pinterest will notify you by email. +6. Once you have received the email approval, go to My Apps to see your app ID and secret key. + +It's now time to copy/paste the app ID and secret key to the .env file. + +```dotenv +PINTEREST_API_CLIENT_ID="app ID" +PINTEREST_API_CLIENT_SECRET="secret key" +``` + +### Configure the redirect URI +The package provides a default redirect URI `/pinterest/callack`. You are free to change it in your configuration file. + +1. Back to your Pinterest account. +2. Go to My Apps and select your app. +3. Go to Configure and, in Redirect URIs, enter the desired URI and save. + +> **Note**: For local development it's preferred to use `http://localhost/pinterest/callback` + +```dotenv +PINTEREST_API_REDIRECT_URI=http://localhost/pinterest/callback +``` + +### Generate an access token + +1. To start the OAuth flow and request user access run `php artisan pinterest:get-access-code-link` +2. Copy/Paste the link to your browser and follow the instructions on screen. +3. At the end of the process you will see a white page with a text saying "All good! You can close this page." + +Now you are ready to call the Pinterest API. + + +
+ +# NOTES + +Pinterest provides an `access_token` valid for 30 days and a `refresh_token` valid for 1 year. +The package automatically retrieves a new access token every time it expires, as long as the refresh token is still valid. +After 1 year, when you try to call an API endpoint, the package will throw a `OAuthException` with a message informing you to request a new access code. + +If you are curious you can [read the codebase to learn more](https://github.com/leMaur/laravel-pinterest-api/blob/main/src/Services/Concerns/BuildBaseRequest.php#L18). + +Or you can use `Lemaur\Pinterest\Facades\Pinterest::oauth()->credentials()->accessTokenExpiresIn` to get the number of days before the access token will expire. +The same for the refresh token `Lemaur\Pinterest\Facades\Pinterest::oauth()->credentials()->refreshTokenExpiresIn`. + +
+ +## Testing + +The package offers a nifty fake method to help you write your tests. + +Here an example on how to use it: +```php +use Illuminate\Http\Client\Request; +use Lemaur\Pinterest\Enums\ContentTypeEnum; +use Lemaur\Pinterest\Facades\Pinterest; + +Pinterest::fake(); + +Pinterest::pin()->create( + boardId: 'abc123', + mediaSource: new ImageBase64Data(ContentTypeEnum::JPEG, 'image-base64-format...') +); + +Pinterst::assertSent(function (Request $request) { + return $request->url() == config('pinterest.base_url').'pins' && + $request['boardId'] == 'abc123' && + $request['mediaSource'] == [ + 'source_type' => 'image_base64', + 'content_type' => 'image/jpeg', + 'data' => 'image-base64-format...', + ]; +}); +``` + +Here listed you can find the all assertion methods it offers: + +```php +Pinterst::assertSent(callable $callback): void +``` + +```php +Pinterst::assertNotSent(callable $callback): void +``` + +```php +Pinterst::assertSentCount(int $count): void +``` + +```php +Pinterst::assertNothingSent(): void +``` + +
+ +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +
+ +## Contributing + +Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. + +
+ +## Security Vulnerabilities + +Please review [our security policy](../../security/policy) on how to report security vulnerabilities. + +
+ +## Credits + +- [Maurizio](https://github.com/lemaur) +- [All Contributors](../../contributors) + +
+ +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5897761 --- /dev/null +++ b/composer.json @@ -0,0 +1,74 @@ +{ + "name": "lemaur/laravel-pinterest-api", + "description": "", + "keywords": [ + "lemaur", + "laravel", + "pinterest", + "api", + "v5" + ], + "homepage": "https://github.com/lemaur/laravel-pinterest-api", + "license": "MIT", + "authors": [ + { + "name": "Maurizio", + "email": "hello@lemaur.me", + "role": "Developer" + } + ], + "require": { + "php": "^8.1", + "guzzlehttp/guzzle": "^7.5", + "illuminate/contracts": "^9.0|^10.0", + "spatie/laravel-package-tools": "^1.14.0" + }, + "require-dev": { + "laravel/pint": "^1.0", + "nunomaduro/collision": "^6.0|^7.0", + "nunomaduro/larastan": "^2.0.1", + "orchestra/testbench": "^8.0", + "pestphp/pest": "^2.0", + "pestphp/pest-plugin-arch": "^2.0", + "pestphp/pest-plugin-laravel": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0" + }, + "autoload": { + "psr-4": { + "Lemaur\\Pinterest\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Lemaur\\Pinterest\\Tests\\": "tests/" + } + }, + "scripts": { + "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", + "analyse": "vendor/bin/phpstan analyse --memory-limit=2G", + "test": "vendor/bin/pest", + "test-coverage": "vendor/bin/pest --coverage", + "format": "vendor/bin/pint" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "phpstan/extension-installer": true + } + }, + "extra": { + "laravel": { + "providers": [ + "Lemaur\\Pinterest\\PinterestServiceProvider" + ], + "aliases": { + "Pinterest": "Lemaur\\Pinterest\\Facades\\Pinterest" + } + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/pinterest.php b/config/pinterest.php new file mode 100644 index 0000000..1fdf19c --- /dev/null +++ b/config/pinterest.php @@ -0,0 +1,116 @@ + 'pinterest/callback', + + /* + |-------------------------------------------------------------------------- + | Route Middleware + |-------------------------------------------------------------------------- + | + */ + + 'middleware' => [], + + /* + |-------------------------------------------------------------------------- + | OAuth Url + |-------------------------------------------------------------------------- + | + */ + + 'oauth_url' => 'https://www.pinterest.com/oauth/', + + /* + |-------------------------------------------------------------------------- + | OAuth Client ID + |-------------------------------------------------------------------------- + | + */ + + 'client_id' => env('PINTEREST_API_CLIENT_ID', ''), + + /* + |-------------------------------------------------------------------------- + | OAuth Client Secret + |-------------------------------------------------------------------------- + | + */ + + 'client_secret' => env('PINTEREST_API_CLIENT_SECRET', ''), + + /* + |-------------------------------------------------------------------------- + | OAuth Redirect URI + |-------------------------------------------------------------------------- + | + */ + + 'redirect_uri' => env('PINTEREST_API_REDIRECT_URI', env('APP_URL').'/pinterest/callback'), + + /* + |-------------------------------------------------------------------------- + | Api Base Url + |-------------------------------------------------------------------------- + | + */ + + 'base_url' => env('PINTEREST_API_BASE_URL', 'https://api.pinterest.com/v5/'), + + /* + |-------------------------------------------------------------------------- + | Api Timeout + |-------------------------------------------------------------------------- + | + */ + + 'timeout' => env('PINTEREST_API_TIMEOUT', 30), + + /* + |-------------------------------------------------------------------------- + | Api Connect Timeout + |-------------------------------------------------------------------------- + | + */ + + 'connect_timeout' => env('PINTEREST_API_CONNECT_TIMEOUT', 15), + + /* + |-------------------------------------------------------------------------- + | Api Retry Strategy + |-------------------------------------------------------------------------- + | + */ + + 'retry' => [ + 'enabled' => env('PINTEREST_API_RETRY_ENABLED', true), + 'times' => env('PINTEREST_API_RETRY_TIMES', 3), + 'sleep' => env('PINTEREST_API_RETRY_SLEEP', 100), + ], + + /* + |-------------------------------------------------------------------------- + | Api Scopes + |-------------------------------------------------------------------------- + | + */ + + 'scopes' => [ + 'user_accounts:read', + 'boards:read', + 'boards:write', + 'pins:read', + 'pins:write', + ], + +]; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..7a88137 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,6 @@ +parameters: + ignoreErrors: + - + message: "#^Unsafe usage of new static\\(\\)\\.$#" + count: 1 + path: src/Data/Data.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..21dc694 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,14 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 4 + paths: + - src + - config + - routes + tmpDir: build/phpstan + checkOctaneCompatibility: true + checkModelProperties: true + checkMissingIterableValueType: false + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..3d17a94 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,36 @@ + + + + + tests + + + + + ./src + + + + + + + + + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..4b4f940 --- /dev/null +++ b/pint.json @@ -0,0 +1,6 @@ +{ + "preset": "laravel", + "rules": { + "declare_strict_types": true + } +} diff --git a/resources/stubs/PinterestServiceProvider.php.stub b/resources/stubs/PinterestServiceProvider.php.stub new file mode 100644 index 0000000..8a9f339 --- /dev/null +++ b/resources/stubs/PinterestServiceProvider.php.stub @@ -0,0 +1,32 @@ +app->singleton(PinterestContract::class, fn (Application $app) => new PinterestService( + config: ConfigData::fromConfig($app['config']['pinterest']), + oauth: OAuthData::from([]), // @TODO: <-- please fill in the credentials... + )); + } + + /** + * Bootstrap any application services. + */ + public function boot(): void + { + // + } +} diff --git a/routes/pinterest.php b/routes/pinterest.php new file mode 100644 index 0000000..e6c7aee --- /dev/null +++ b/routes/pinterest.php @@ -0,0 +1,12 @@ +middleware( + middleware: config('pinterest.route.middleware', []) +); diff --git a/src/Commands/PinterestCommand.php b/src/Commands/PinterestCommand.php new file mode 100644 index 0000000..7fe9126 --- /dev/null +++ b/src/Commands/PinterestCommand.php @@ -0,0 +1,34 @@ +getAccessCode(); + + $this->line('Copy/paste this link into your browser and follow the instructions provided.'); + $this->newLine(); + + $this->info($link); + $this->newLine(); + + return self::SUCCESS; + } +} diff --git a/src/Data/BoardData.php b/src/Data/BoardData.php new file mode 100644 index 0000000..7a64dc5 --- /dev/null +++ b/src/Data/BoardData.php @@ -0,0 +1,37 @@ +map(fn ($item) => self::fromApi($item)); + } +} diff --git a/src/Data/Concerns/HandlesArray.php b/src/Data/Concerns/HandlesArray.php new file mode 100644 index 0000000..48152d0 --- /dev/null +++ b/src/Data/Concerns/HandlesArray.php @@ -0,0 +1,15 @@ +getPublicProperties()->toArray(); + } +} diff --git a/src/Data/Concerns/HandlesCollection.php b/src/Data/Concerns/HandlesCollection.php new file mode 100644 index 0000000..920038c --- /dev/null +++ b/src/Data/Concerns/HandlesCollection.php @@ -0,0 +1,17 @@ +getPublicProperties(); + } +} diff --git a/src/Data/Concerns/InteractsWithPublicProperties.php b/src/Data/Concerns/InteractsWithPublicProperties.php new file mode 100644 index 0000000..add451f --- /dev/null +++ b/src/Data/Concerns/InteractsWithPublicProperties.php @@ -0,0 +1,22 @@ +getProperties(ReflectionProperty::IS_PUBLIC) + ) + ->mapWithKeys(fn (ReflectionProperty $property): array => [ + $property->getName() => $this->{$property->getName()}, + ]); + } +} diff --git a/src/Data/ConfigData.php b/src/Data/ConfigData.php new file mode 100644 index 0000000..9308f58 --- /dev/null +++ b/src/Data/ConfigData.php @@ -0,0 +1,42 @@ +map(fn ($item) => static::from($item)); + } +} diff --git a/src/Data/MediaSource/ImageBase64Data.php b/src/Data/MediaSource/ImageBase64Data.php new file mode 100644 index 0000000..144b4ce --- /dev/null +++ b/src/Data/MediaSource/ImageBase64Data.php @@ -0,0 +1,27 @@ + $this->data], + rules: ['data' => 'regex:[a-zA-Z0-9+\/=]+'] + )->validate(); + } +} diff --git a/src/Data/MediaSource/ImageUrlData.php b/src/Data/MediaSource/ImageUrlData.php new file mode 100644 index 0000000..8eda007 --- /dev/null +++ b/src/Data/MediaSource/ImageUrlData.php @@ -0,0 +1,14 @@ + $this->data], + rules: ['data' => 'regex:[a-zA-Z0-9+\/=]+'] + )->validate(); + } +} diff --git a/src/Data/MediaSource/MediaSourceData.php b/src/Data/MediaSource/MediaSourceData.php new file mode 100644 index 0000000..eb76e7a --- /dev/null +++ b/src/Data/MediaSource/MediaSourceData.php @@ -0,0 +1,20 @@ + $this->index], + rules: ['index' => ['integer', 'min:0']] + )->validate(); + } +} diff --git a/src/Data/MediaSource/MultipleImageUrlsData.php b/src/Data/MediaSource/MultipleImageUrlsData.php new file mode 100644 index 0000000..eb97914 --- /dev/null +++ b/src/Data/MediaSource/MultipleImageUrlsData.php @@ -0,0 +1,27 @@ + $this->index], + rules: ['index' => ['integer', 'min:0']] + )->validate(); + } +} diff --git a/src/Data/MediaSource/VideoIdData.php b/src/Data/MediaSource/VideoIdData.php new file mode 100644 index 0000000..d233ed1 --- /dev/null +++ b/src/Data/MediaSource/VideoIdData.php @@ -0,0 +1,15 @@ +access_token); + } + + public function isAccessTokenExpired(): bool|null + { + return $this->access_token_expired_at?->isPast(); + } + + public function isRefreshTokenExpired(): bool|null + { + return $this->refresh_token_expired_at?->isPast(); + } + + public function accessTokenExpiresIn(): DateInterval + { + return $this->access_token_expired_at->diff(); + } + + public function refreshTokenExpiresIn(): DateInterval + { + return $this->refresh_token_expired_at->diff(); + } + + public static function fromApi(array $data): self + { + if (($accessTokenExpiredAt = Arr::get($data, 'expires_in')) !== null) { + $accessTokenExpiredAt = Date::now()->addSeconds((int) $accessTokenExpiredAt); + } + + if (($refreshTokenExpiredAt = Arr::get($data, 'refresh_token_expires_in')) !== null) { + $refreshTokenExpiredAt = Date::now()->addSeconds((int) $refreshTokenExpiredAt); + } + + return new self( + access_code: Arr::get($data, 'access_code'), + access_token: Arr::get($data, 'access_token'), + refresh_token: Arr::get($data, 'refresh_token'), + access_token_expired_at: $accessTokenExpiredAt, + refresh_token_expired_at: $refreshTokenExpiredAt, + ); + } +} diff --git a/src/Data/PinData.php b/src/Data/PinData.php new file mode 100644 index 0000000..e14de4e --- /dev/null +++ b/src/Data/PinData.php @@ -0,0 +1,36 @@ +map(fn ($item) => self::fromApi($item)); + } +} diff --git a/src/Enums/ContentTypeEnum.php b/src/Enums/ContentTypeEnum.php new file mode 100644 index 0000000..24412dd --- /dev/null +++ b/src/Enums/ContentTypeEnum.php @@ -0,0 +1,12 @@ +query('code'), + state: $request->query('state'), + internalState: Cache::pull('pinterest_api::oauth_state') + ); + } +} diff --git a/src/Http/Responses/CallbackResponse.php b/src/Http/Responses/CallbackResponse.php new file mode 100644 index 0000000..35f3902 --- /dev/null +++ b/src/Http/Responses/CallbackResponse.php @@ -0,0 +1,37 @@ + $accessCode]))); + + return new Response( + content: 'All good! You can close this page.', + status: Response::HTTP_OK + ); + } +} diff --git a/src/Http/Responses/Contracts/CallbackResponseContract.php b/src/Http/Responses/Contracts/CallbackResponseContract.php new file mode 100644 index 0000000..b2d2b25 --- /dev/null +++ b/src/Http/Responses/Contracts/CallbackResponseContract.php @@ -0,0 +1,12 @@ +singleton(CallbackResponseContract::class, $callback); + } +} diff --git a/src/PinterestServiceProvider.php b/src/PinterestServiceProvider.php new file mode 100644 index 0000000..74b123a --- /dev/null +++ b/src/PinterestServiceProvider.php @@ -0,0 +1,34 @@ +name('laravel-pinterest-api') + ->hasConfigFile('pinterest') + ->hasCommand(PinterestCommand::class) + ->publishesServiceProvider('PinterestServiceProvider') + ->hasRoute('pinterest'); + } + + public function packageRegistered(): void + { + parent::packageRegistered(); + + $this->app->singleton(OAuthResourceContract::class, OAuthResource::class); + $this->app->singleton(CallbackResponseContract::class, CallbackResponse::class); + } +} diff --git a/src/Services/Concerns/BuildBaseRequest.php b/src/Services/Concerns/BuildBaseRequest.php new file mode 100644 index 0000000..a53b6a8 --- /dev/null +++ b/src/Services/Concerns/BuildBaseRequest.php @@ -0,0 +1,58 @@ +oauth->missingAccessToken()) { + $this->oauth()->requestAccessToken(); + } + + if ($this->oauth->isAccessTokenExpired()) { + $this->oauth()->refreshAccessToken(); + } + + if ($this->oauth->isRefreshTokenExpired()) { + throw new OAuthException('Pinterest refresh token expired. You should request a new access code by running: `php artisan pinterest:get-access-code-link`'); + } + + return $this->withBaseUrl() + ->withToken($this->oauth->access_token); + } + + public function buildRequestWithBasicBase64(): PendingRequest + { + $authHeader = 'Basic '.base64_encode($this->config->client_id.':'.$this->config->client_secret); + + return $this->withBaseUrl() + ->asForm() + ->withHeaders(['Authorization' => $authHeader]); + } + + public function withBaseUrl(): PendingRequest + { + return Http::baseUrl($this->config->base_url) + ->connectTimeout(seconds: $this->config->connect_timeout) + ->timeout(seconds: $this->config->timeout) + ->when( + value: $this->config->retry_enabled, + callback: fn (PendingRequest $client) => $client->retry( + times: $this->config->retry_times, + sleepMilliseconds: $this->config->retry_sleep + ) + ); + } +} diff --git a/src/Services/Contracts/PinterestContract.php b/src/Services/Contracts/PinterestContract.php new file mode 100644 index 0000000..871e1bd --- /dev/null +++ b/src/Services/Contracts/PinterestContract.php @@ -0,0 +1,18 @@ +config, + oauth: $this->oauth + ); + } + + public function board(): BoardResource + { + return new BoardResource( + service: $this + ); + } + + public function pin(): PinResource + { + return new PinResource( + service: $this + ); + } +} diff --git a/src/Services/PinterestFake.php b/src/Services/PinterestFake.php new file mode 100644 index 0000000..7a73b88 --- /dev/null +++ b/src/Services/PinterestFake.php @@ -0,0 +1,48 @@ +service->config->base_url => $this->response ?? Http::response([]), + ]); + } + + public function assertSent(callable $callback): void + { + Http::assertSent($callback); + } + + public function assertNotSent(callable $callback): void + { + Http::assertNotSent($callback); + } + + public function assertSentCount(int $count): void + { + Http::assertSentCount($count); + } + + public function assertNothingSent(): void + { + Http::assertNothingSent(); + } + + public function __call($method, $parameters) + { + return $this->service->{$method}(...$parameters); + } +} diff --git a/src/Services/Resources/BoardResource.php b/src/Services/Resources/BoardResource.php new file mode 100644 index 0000000..b0cb159 --- /dev/null +++ b/src/Services/Resources/BoardResource.php @@ -0,0 +1,72 @@ +service + ->buildRequestWithToken() + ->get(self::ENDPOINT, array_filter([ + 'ad_account_id' => $accountId, + 'bookmark' => $bookmark, + 'page_size' => $pageSize, + 'privacy' => $privacy, + ])) + ->throw() + ->json(); + } + + public function create(): array + { + throw new RuntimeException('Not implemented yet.'); + } + + public function get(): array + { + throw new RuntimeException('Not implemented yet.'); + } + + public function update(): array + { + throw new RuntimeException('Not implemented yet.'); + } + + public function delete(): bool + { + throw new RuntimeException('Not implemented yet.'); + } + + public function pins(): array + { + throw new RuntimeException('Not implemented yet.'); + } +} diff --git a/src/Services/Resources/Contracts/OAuthResourceContract.php b/src/Services/Resources/Contracts/OAuthResourceContract.php new file mode 100644 index 0000000..6bdf5e1 --- /dev/null +++ b/src/Services/Resources/Contracts/OAuthResourceContract.php @@ -0,0 +1,40 @@ +oauth->access_code || ! $this->oauth->refresh_token) { + throw new OAuthException("Unable to find Pinterest credentials. Please, check App\Providers\PinterestServiceProvider configuration."); + } + } + + public function credentials(): OAuthData + { + return $this->oauth; + } + + /** + * Request user access and receive the access code with your redirect URI + * + * @see https://developers.pinterest.com/docs/getting-started/authentication/#Generating%20an%20access%20token + */ + public function getAccessCode(): string + { + $state = Str::random(64); + + Cache::put('pinterest_api::oauth_state', $state, Date::now()->addMinutes(5)); + + $query = [ + 'client_id' => $this->config->client_id, + 'redirect_uri' => $this->config->oauth_redirect_uri, + 'response_type' => 'code', + 'scope' => implode(',', $this->config->scopes), + 'state' => $state, + ]; + + return Str::finish($this->config->oauth_url, '/').'?'.http_build_query($query); + } + + /** + * Exchange the code for an access token + * + * @see https://developers.pinterest.com/docs/getting-started/authentication/#3.%20Exchange%20the%20code%20for%20an%20access%20token + * + * @throws RequestException + */ + public function requestAccessToken(): array + { + $response = $this->service + ->buildRequestWithBasicBase64() + ->post(self::ENDPOINT, [ + 'redirect_uri' => $this->config->oauth_redirect_uri, + 'code' => $this->oauth->access_code, + 'grant_type' => 'authorization_code', + ]) + ->throw() + ->json(); + + event(new CredentialsRetrieved(OAuthData::fromApi($response))); + + return $response; + } + + /** + * Refreshing an access token + * + * @see https://developers.pinterest.com/docs/getting-started/authentication/#1.Refreshing%20an%20access%20token + * + * @throws RequestException + */ + public function refreshAccessToken(): array + { + $response = $this->service + ->buildRequestWithBasicBase64() + ->post(self::ENDPOINT, [ + 'refresh_token' => $this->oauth->refresh_token, + 'grant_type' => 'refresh_token', + ]) + ->throw() + ->json(); + + event(new CredentialsRetrieved(OAuthData::fromApi($response))); + + return $response; + } +} diff --git a/src/Services/Resources/PinResource.php b/src/Services/Resources/PinResource.php new file mode 100644 index 0000000..9fd9de0 --- /dev/null +++ b/src/Services/Resources/PinResource.php @@ -0,0 +1,100 @@ + $accountId]) + : self::ENDPOINT; + + return $this->service + ->buildRequestWithToken() + ->post($endpoint, array_filter([ + 'link' => $link, + 'title' => $title, + 'description' => $description, + 'dominant_color' => $dominantColor, + 'alt_text' => $altText, + 'board_id' => $boardId, + 'board_section_id' => $boardSectionId, + 'media_source' => $mediaSource->toArray(), + 'parent_pin_id' => $parentPinId, + ])) + ->throw() + ->json(); + } + + public function get(): array + { + throw new RuntimeException('Not implemented yet.'); + } + + public function delete(): bool + { + throw new RuntimeException('Not implemented yet.'); + } + + public function update(): array + { + throw new RuntimeException('Not implemented yet.'); + } + + public function save(): array + { + throw new RuntimeException('Not implemented yet.'); + } + + public function analytics(): array + { + throw new RuntimeException('Not implemented yet.'); + } +} diff --git a/tests/ArchTest.php b/tests/ArchTest.php new file mode 100644 index 0000000..db47936 --- /dev/null +++ b/tests/ArchTest.php @@ -0,0 +1,7 @@ +expect(['dd', 'dump', 'ray']) + ->each->not->toBeUsed(); diff --git a/tests/ExampleTest.php b/tests/ExampleTest.php new file mode 100644 index 0000000..57349d9 --- /dev/null +++ b/tests/ExampleTest.php @@ -0,0 +1,7 @@ +toBeTrue(); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..6b42f05 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,7 @@ +in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..48ccecc --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,23 @@ +