diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9975675 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..b4ae1c4 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# 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). + +- **Add tests!** - Your patch won't be accepted if it doesn't have tests. + +- **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/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..d65e6df --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,3 @@ +# Security Policy + +If you discover any security related issues, please email security@justbetter.nl instead of using the issue tracker. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50b321e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +vendor +composer.lock +.phpunit.result.cache diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..2fe24ac --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,16 @@ +The MIT License (MIT) + +Copyright (c) JustBetter + +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..515bfd9 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# Laravel Akeneo Client + +Connect to your Akeneo instance using the official [akeneo/api-php-client](https://github.com/akeneo/api-php-client). +This package will ease configuration, dependency injection and testing for Laravel. + +It also has an endpoint available to receive Akeneo events, if enabled. + +## Example usage + +```php +getProductApi()->get('1000'); +} +``` + +## Installation + +Install the composer package. + +```shell +composer require justbetter/laravel-akeneo-client +``` + +By default, this package will require the latest version of `akeneo/api-php-client`. You should take a look at the +[compatibility table](https://github.com/akeneo/api-php-client) to see what version you need for your project. + +```shell +composer require akeneo/api-php-client "^9.0" +``` + +## Setup + +Optionally, publish the configuration of the package. + +```shell +php artisan vendor:publish --provider="JustBetter\AkeneoClient\ServiceProvider" --tag=config +``` + +## Configuration + +Add the following values to your `.env` file. + +``` +AKENEO_URL= +AKENEO_CLIENT_ID= +AKENEO_SECRET= +AKENEO_USERNAME= +AKENEO_PASSWORD= + +AKENEO_EVENT_SECRET= +``` + +## Events + +If you have [enabled](https://help.akeneo.com/pim/serenity/articles/manage-event-subscription.html) the event +subscription in Akeneo you will be to listen to these events. + +The event webhook is `/akeneo/event`. This can be configured using the `prefix` in your `akeneo` config file. + +[All](https://api.akeneo.com/events-reference/events-reference-serenity/products.html) events are available. + +```php + Http::response([ + 'identifier' => '1000', + 'enabled' => true, + 'family' => 'hydras', + 'categories' => [], + 'groups' => [], + 'parent' => null, + 'values' => [ + 'name' => [ + [ + 'locale' => 'nl_NL', + 'scope' => 'ecommerce', + 'data' => 'Ziggy', + ], + ], + ], + ]), +]); + +// Get the product with the fake response +$response = $akeneo->getProductApi()->get('1000'); +``` + +## Quality + +To ensure the quality of this package, run the following command: + +```shell +composer quality +``` + +This will execute three tasks: + +1. Makes sure all tests are passed +2. Checks for any issues using static code analysis +3. Checks if the code is correctly formatted + +## 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 + +- [Ramon Rietdijk](https://github.com/ramonrietdijk) +- [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..c261ff2 --- /dev/null +++ b/composer.json @@ -0,0 +1,59 @@ +{ + "name": "justbetter/laravel-akeneo-client", + "description": "A Laravel package for the Akeneo API", + "type": "package", + "license": "MIT", + "homepage": "https://github.com/justbetter/laravel-akeneo-client", + "authors": [ + { + "name": "Ramon Rietdijk", + "email": "ramon@justbetter.nl", + "role": "Developer" + } + ], + "require": { + "php": "^8.0", + "akeneo/api-php-client": "*", + "guzzlehttp/guzzle": "^7.4", + "laravel/framework": "^9.0" + }, + "require-dev": { + "laravel/pint": "^1.1", + "nunomaduro/larastan": "^2.1", + "orchestra/testbench": "^7.0", + "phpstan/phpstan-mockery": "^1.1", + "phpunit/phpunit": "^9.5.10" + }, + "autoload": { + "psr-4": { + "JustBetter\\AkeneoClient\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "JustBetter\\AkeneoClient\\Tests\\": "tests" + } + }, + "scripts": { + "test": "phpunit", + "analyse": "phpstan", + "style": "pint --test", + "quality": [ + "@test", + "@analyse", + "@style" + ] + }, + "config": { + "sort-packages": true + }, + "extra": { + "laravel": { + "providers": [ + "JustBetter\\AkeneoClient\\ServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/akeneo.php b/config/akeneo.php new file mode 100644 index 0000000..73fa2d9 --- /dev/null +++ b/config/akeneo.php @@ -0,0 +1,25 @@ + env('AKENEO_URL'), + + 'client_id' => env('AKENEO_CLIENT_ID'), + + 'secret' => env('AKENEO_SECRET'), + + 'username' => env('AKENEO_USERNAME'), + + 'password' => env('AKENEO_PASSWORD'), + + 'event_secret' => env('AKENEO_EVENT_SECRET'), + + 'queue' => 'default', + + 'prefix' => 'akeneo', + + 'middleware' => [ + \JustBetter\AkeneoClient\Http\Middleware\HMacMiddleware::class, + ], + +]; diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..2c40061 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,10 @@ +includes: + - ./vendor/nunomaduro/larastan/extension.neon + - ./vendor/phpstan/phpstan-mockery/extension.neon + +parameters: + paths: + - src + - tests + level: 8 + checkMissingIterableValueType: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..6dcf683 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + ./tests/* + + + + + ./src + + + diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..7678041 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,7 @@ +name('akeneo.event'); diff --git a/src/Actions/DispatchEvent.php b/src/Actions/DispatchEvent.php new file mode 100644 index 0000000..e5d85e5 --- /dev/null +++ b/src/Actions/DispatchEvent.php @@ -0,0 +1,31 @@ +resolvesEvents->resolve($event['action']); + + Event::dispatch( + app($class, [ + 'event' => $event, + ]) + ); + } + + public static function bind(): void + { + app()->singleton(DispatchesEvents::class, static::class); + } +} diff --git a/src/Actions/ResolveEvent.php b/src/Actions/ResolveEvent.php new file mode 100644 index 0000000..80ea534 --- /dev/null +++ b/src/Actions/ResolveEvent.php @@ -0,0 +1,31 @@ +replace('.', '_') + ->studly(); + + $class = 'JustBetter\AkeneoClient\Events\\'.$eventName.'Event'; + + if (! class_exists($class)) { + throw new AkeneoException('Class "'.$class.'" does not exist.'); + } + + return $class; + } + + public static function bind(): void + { + app()->singleton(ResolvesEvents::class, static::class); + } +} diff --git a/src/Client/Akeneo.php b/src/Client/Akeneo.php new file mode 100644 index 0000000..bac013f --- /dev/null +++ b/src/Client/Akeneo.php @@ -0,0 +1,58 @@ +client = $clientBuilder->buildAuthenticatedByPassword( + clientId: $clientId, + secret: $secret, + username: $username, + password: $password + ); + } + + public function __call(string $method, array $args): mixed + { + $callable = [$this->client, $method]; + + if (! is_callable($callable)) { + throw new AkeneoException('Method "'.$method.'" is not callable'); + } + + return call_user_func_array($callable, $args); + } + + public static function fake(): void + { + config()->set('akeneo', [ + 'url' => 'akeneo', + 'client_id' => '::client-id::', + 'secret' => '::secret::', + 'username' => '::username::', + 'password' => '::password::', + 'webhook_secret' => '::webhook-secret::', + ]); + + Http::fake([ + 'akeneo/api/oauth/v1/token' => Http::response([ + 'access_token' => '::access-token::', + 'expires_in' => 3600, + 'token_type' => 'bearer', + 'scope' => null, + 'refresh_token' => '::refresh-token::', + ]), + ]); + } +} diff --git a/src/Client/Client.php b/src/Client/Client.php new file mode 100644 index 0000000..3db9961 --- /dev/null +++ b/src/Client/Client.php @@ -0,0 +1,28 @@ +getHeader('Content-Type'); + + $headers = $request->getHeaders(); + + unset($headers['Content-Type']); + + $contentType = Arr::first(Arr::wrap($contentTypes)) ?? 'application/json'; + + return Http::withHeaders($headers) + ->withBody($request->getBody()->getContents(), $contentType) + ->send($request->getMethod(), $request->getUri()) + ->toPsrResponse(); + } +} diff --git a/src/Client/ClientBuilder.php b/src/Client/ClientBuilder.php new file mode 100644 index 0000000..d0450aa --- /dev/null +++ b/src/Client/ClientBuilder.php @@ -0,0 +1,18 @@ +httpClient = new Client(); + } +} diff --git a/src/Contracts/DispatchesEvents.php b/src/Contracts/DispatchesEvents.php new file mode 100644 index 0000000..aa3ccbf --- /dev/null +++ b/src/Contracts/DispatchesEvents.php @@ -0,0 +1,8 @@ +collect('events') + ->each(fn (array $event) => EventJob::dispatch($event)); + + return response()->json([], 204); + } +} diff --git a/src/Http/Middleware/HMacMiddleware.php b/src/Http/Middleware/HMacMiddleware.php new file mode 100644 index 0000000..499bf59 --- /dev/null +++ b/src/Http/Middleware/HMacMiddleware.php @@ -0,0 +1,31 @@ +header('X-Akeneo-Request-Signature', ''); + + /** @var string $timestamp */ + $timestamp = $request->header('X-Akeneo-Request-Timestamp', ''); + + $requestBody = $request->getContent(); + $signedPayload = $timestamp.'.'.$requestBody; + + $generatedSignature = hash_hmac('sha256', $signedPayload, $secret); + + if (! hash_equals($signature, $generatedSignature)) { + return response()->json(['error' => 'Invalid signature'], 400); + } + + return $next($request); + } +} diff --git a/src/Jobs/EventJob.php b/src/Jobs/EventJob.php new file mode 100644 index 0000000..68e8ba7 --- /dev/null +++ b/src/Jobs/EventJob.php @@ -0,0 +1,29 @@ +onQueue(config('akeneo.queue')); + } + + public function handle(DispatchesEvents $dispatchesEvents): void + { + $dispatchesEvents->dispatch($this->event); + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php new file mode 100644 index 0000000..eb13beb --- /dev/null +++ b/src/ServiceProvider.php @@ -0,0 +1,75 @@ +registerConfig() + ->registerClient() + ->registerActions(); + } + + protected function registerConfig(): static + { + $this->mergeConfigFrom(__DIR__.'/../config/akeneo.php', 'akeneo'); + + return $this; + } + + protected function registerClient(): static + { + $this->app->singleton(Akeneo::class, fn () => new Akeneo( + url: config('akeneo.url'), + clientId: config('akeneo.client_id'), + secret: config('akeneo.secret'), + username: config('akeneo.username'), + password: config('akeneo.password') + )); + + return $this; + } + + protected function registerActions(): static + { + DispatchEvent::bind(); + ResolveEvent::bind(); + + return $this; + } + + public function boot(): void + { + $this + ->bootConfig() + ->bootRoutes(); + } + + protected function bootConfig(): static + { + $this->publishes([ + __DIR__.'/../config/akeneo.php' => config_path('akeneo.php'), + ], 'config'); + + return $this; + } + + protected function bootRoutes(): static + { + if (! $this->app->routesAreCached()) { + Route::prefix(config('akeneo.prefix')) + ->middleware(config('akeneo.middleware')) + ->group(fn () => $this->loadRoutesFrom(__DIR__.'/../routes/api.php')); + } + + return $this; + } +} diff --git a/tests/Actions/DispatchEventTest.php b/tests/Actions/DispatchEventTest.php new file mode 100644 index 0000000..0e0d4fb --- /dev/null +++ b/tests/Actions/DispatchEventTest.php @@ -0,0 +1,44 @@ + 'product.created', + 'data' => [ + 'resource' => [ + 'identifier' => '1000', + ], + ], + ]; + + $this->mock(ResolvesEvents::class, function (MockInterface $mock): void { + $mock + ->shouldReceive('resolve') + ->with('product.created') + ->once() + ->andReturn(ProductCreatedEvent::class); + }); + + /** @var DispatchEvent $action */ + $action = app(DispatchEvent::class); + $action->dispatch($payload); + + Event::assertDispatched(ProductCreatedEvent::class, function (ProductCreatedEvent $event) use ($payload): bool { + return $event->event === $payload; + }); + } +} diff --git a/tests/Actions/ResolveEventTest.php b/tests/Actions/ResolveEventTest.php new file mode 100644 index 0000000..89f19de --- /dev/null +++ b/tests/Actions/ResolveEventTest.php @@ -0,0 +1,70 @@ +resolve($event); + + $this->assertEquals($class, $resolvedClass); + } + + public function events(): array + { + return [ + [ + 'product.created', + ProductCreatedEvent::class, + ], + [ + 'product.updated', + ProductUpdatedEvent::class, + ], + [ + 'product.removed', + ProductRemovedEvent::class, + ], + [ + 'product_model.created', + ProductModelCreatedEvent::class, + ], + [ + 'product_model.updated', + ProductModelUpdatedEvent::class, + ], + [ + 'product_model.removed', + ProductModelRemovedEvent::class, + ], + ]; + } + + /** @test */ + public function it_can_throw_exceptions(): void + { + $this->expectException(AkeneoException::class); + + /** @var ResolveEvent $action */ + $action = app(ResolveEvent::class); + $action->resolve('::non-existent::'); + } +} diff --git a/tests/Client/AkeneoTest.php b/tests/Client/AkeneoTest.php new file mode 100644 index 0000000..765e88e --- /dev/null +++ b/tests/Client/AkeneoTest.php @@ -0,0 +1,63 @@ + '1000', + 'enabled' => true, + 'family' => 'hydras', + 'categories' => [], + 'groups' => [], + 'parent' => null, + 'values' => [ + 'name' => [ + [ + 'locale' => 'nl_NL', + 'scope' => 'ecommerce', + 'data' => 'Ziggy', + ], + ], + ], + ]; + + Http::fake([ + 'akeneo/api/rest/v1/products/1000' => Http::response($product), + ]); + + /** @var Akeneo $akeneo */ + $akeneo = app(Akeneo::class); + + $response = $akeneo->getProductApi()->get('1000'); + + $this->assertEquals($product, $response); + } + + /** @test */ + public function it_throws_exceptions_when_method_is_not_callable(): void + { + /** @var Akeneo $akeneo */ + $akeneo = app(Akeneo::class); + + $this->expectException(AkeneoException::class); + + // @phpstan-ignore-next-line + $akeneo->invalidMethod(); + } +} diff --git a/tests/Http/Controllers/EventControllerTest.php b/tests/Http/Controllers/EventControllerTest.php new file mode 100644 index 0000000..8d88db8 --- /dev/null +++ b/tests/Http/Controllers/EventControllerTest.php @@ -0,0 +1,41 @@ +withoutMiddleware() + ->post('akeneo/event', [ + 'events' => [ + [ + 'action' => 'product.created', + 'data' => [ + 'resource' => [ + 'identifier' => '1000', + ], + ], + ], + [ + 'action' => 'product.updated', + 'data' => [ + 'resource' => [ + 'identifier' => '2000', + ], + ], + ], + ], + ]); + + Bus::assertDispatched(EventJob::class, 2); + } +} diff --git a/tests/Http/Middleware/HMacMiddlewareTest.php b/tests/Http/Middleware/HMacMiddlewareTest.php new file mode 100644 index 0000000..3016a49 --- /dev/null +++ b/tests/Http/Middleware/HMacMiddlewareTest.php @@ -0,0 +1,64 @@ +set('akeneo.event_secret', '::event-secret::'); + } + + /** @test */ + public function it_can_pass(): void + { + $request = $this->fabricateRequest('::event-secret::'); + + /** @var HMacMiddleware $middleware */ + $middleware = app(HMacMiddleware::class); + + $passed = false; + + $middleware->handle($request, function () use (&$passed): void { + $passed = true; + }); + + $this->assertTrue($passed); + } + + /** @test */ + public function it_can_refuse(): void + { + $request = $this->fabricateRequest('::invalid-event-secret::'); + + /** @var HMacMiddleware $middleware */ + $middleware = app(HMacMiddleware::class); + + /** @var JsonResponse $response */ + $response = $middleware->handle($request, fn () => $this->fail('Passed middleware')); + + $this->assertEquals(400, $response->getStatusCode()); + } + + protected function fabricateRequest(string $secret): Request + { + $content = '::content::'; + $timestamp = (string) time(); + $hmac = $timestamp.'.'.$content; + + $signature = hash_hmac('sha256', $hmac, $secret); + + $request = new Request(content: $content); + $request->headers->set('X-Akeneo-Request-Signature', $signature); + $request->headers->set('X-Akeneo-Request-Timestamp', $timestamp); + + return $request; + } +} diff --git a/tests/Jobs/EventJobTest.php b/tests/Jobs/EventJobTest.php new file mode 100644 index 0000000..599aed2 --- /dev/null +++ b/tests/Jobs/EventJobTest.php @@ -0,0 +1,33 @@ + 'product.updated', + 'data' => [ + 'resource' => [ + 'identifier' => '1000', + ], + ], + ]; + + $this->mock(DispatchesEvents::class, function (MockInterface $mock) use ($payload): void { + $mock + ->shouldReceive('dispatch') + ->with($payload) + ->once(); + }); + + EventJob::dispatch($payload); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..b778738 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,16 @@ +