diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6f313c6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.yml] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0721558 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.gitattributes export-ignore +/.gitignore export-ignore +/.travis.yml export-ignore +/.php_cs export-ignore +/phpunit.xml.dist export-ignore +/.scrutinizer.yml export-ignore +/tests export-ignore +/.editorconfig export-ignore diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..e949aa0 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,15 @@ +# Lines starting with '#' are comments. +# Each line is a file pattern followed by one or more owners. + +# More details are here: https://help.github.com/articles/about-codeowners/ + +# The '*' pattern is global owners. + +# Order is important. The last matching pattern has the most precedence. +# The folders are ordered as follows: + +# In each subsection folders are ordered first by depth, then alphabetically. +# This should make it easy to add new rules without breaking existing ones. + +# Global rule: +* @Razorsheep \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a67e023 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/vendor +composer.lock +phpunit.xml +.phpunit.result.cache +.php_cs.cache +.php-cs-fixer.cache +.idea diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..01d0208 --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,16 @@ +notPath('bootstrap') + ->notPath('storage') + ->notPath('vendor') + ->notPath('docker') + ->in(getcwd()) + ->name('*.php') + ->notName('*.blade.php') + ->notName('index.php') + ->notName('server.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true); + +return Signifly\styles($finder); diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..1340d7e --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,25 @@ +filter: + excluded_paths: [ tests/* ] + +checks: + php: + remove_extra_empty_lines: true + remove_php_closing_tag: true + remove_trailing_whitespace: true + fix_use_statements: + remove_unused: true + preserve_multiple: false + preserve_blanklines: true + order_alphabetically: false + fix_php_opening_tag: true + fix_linefeed: true + fix_line_ending: true + fix_identation_4spaces: true + fix_doc_comments: true + +build: + nodes: + analysis: + tests: + override: + - php-scrutinizer-run diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..52119d6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to `laravel-struct` will be documented in this file + +## 0.0.1 - 2022-10-30 + +- Experimental release! diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..8696698 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# The MIT License (MIT) + +Copyright (c) Signifly + +> 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..289a832 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +

+ ![Struct Logo](struct.svg?raw=true "Struct Logo") +

+ +# Make requests to the Struct API from your Laravel app + +The `signifly/laravel-struct` package allows you to easily make requests to the Struct API. + +## Installation + +You can install the package via composer: + +```bash +composer require signifly/laravel-struct +``` + +The package will automatically register itself. + + +## Documentation + +You can find the full documentation [here](https://www.notion.so/signifly/Laravel-Struct). +Struct API Documentation [here](https://docs.struct.com/documentation/v3/integration/web-api/). + + +## Testing +```bash +composer test +``` + +## Security + +If you discover any security issues, please email dev@signifly.com instead of using the issue tracker. + +## Credits + +- [Ro Kleine Sonne](https://github.com/Razorsheep) +- [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..246e8c4 --- /dev/null +++ b/composer.json @@ -0,0 +1,54 @@ +{ + "name": "signifly/laravel-struct", + "description": "A simple package to handle communication with Struct API", + "homepage": "https://github.com/signifly/laravel-struct", + "license": "MIT", + "authors": [ + { + "name": "Ro Kleine Sonne", + "email": "ro@signifly.com", + "role": "Developer" + } + ], + "require": { + "php": "^7.4|^8.0", + "guzzlehttp/guzzle": "^7.2", + "illuminate/contracts": "^8.0|^9.0", + "illuminate/http": "^8.0|^9.0", + "illuminate/routing": "^8.0|^9.0", + "illuminate/support": "^8.0|^9.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "orchestra/testbench": "^6.0|^7.0", + "phpunit/phpunit": "^9.0", + "signifly/php-config": "^1.0" + }, + "autoload": { + "psr-4": { + "Signifly\\Struct\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Signifly\\Struct\\Tests\\": "tests" + } + }, + "scripts": { + "test": "vendor/bin/phpunit", + "fix": "vendor/bin/php-cs-fixer fix" + }, + "config": { + "sort-packages": true + }, + "extra": { + "laravel": { + "providers": [ + "Signifly\\Struct\\StructServiceProvider" + ], + "aliases": { + "Struct": "Signifly\\Struct\\Support\\Facades\\Struct" + } + } + } +} diff --git a/config/struct.php b/config/struct.php new file mode 100644 index 0000000..f5aa9a5 --- /dev/null +++ b/config/struct.php @@ -0,0 +1,40 @@ + [ + + /* + * The API key from private app credentials. + */ + 'api_key' => env('STRUCT_API_KEY', ''), + + /* + * The password from private app credentials. + */ + 'base_uri' => env('STRUCT_PASSWORD', ''), + ], + + 'webhooks' => [ + + /* + * The webhook secret provider to use. + */ + 'secret_provider' => \Signifly\Struct\Webhooks\ConfigSecretProvider::class, + + /* + * The shopify webhook secret. + */ + 'secret' => env('STRUCT_WEBHOOK_SECRET'), + + ], + + 'exceptions' => [ + + /* + * Whether to include the validation errors in the exception message. + */ + 'include_validation_errors' => false, + + ], +]; diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..ca15ef3 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,29 @@ + + + + + tests + + + + + src/ + + + + + + + + + + diff --git a/src/Exceptions/ErrorHandlerInterface.php b/src/Exceptions/ErrorHandlerInterface.php new file mode 100644 index 0000000..99e2adb --- /dev/null +++ b/src/Exceptions/ErrorHandlerInterface.php @@ -0,0 +1,10 @@ +successful()) { + return; + } + + if ($response->status() === 429) { + throw new TooManyRequestsException($response); + } + + if ($response->status() === 422) { + throw new ValidationException($response->json('errors', [])); + } + + if ($response->status() === 404) { + throw new NotFoundException(); + } + + $response->throw(); + } +} diff --git a/src/Exceptions/InvalidFormatException.php b/src/Exceptions/InvalidFormatException.php new file mode 100644 index 0000000..2089d32 --- /dev/null +++ b/src/Exceptions/InvalidFormatException.php @@ -0,0 +1,11 @@ +response = $response; + + parent::__construct($message ?? 'Too many requests.'); + } +} diff --git a/src/Exceptions/ValidationException.php b/src/Exceptions/ValidationException.php new file mode 100644 index 0000000..4490e25 --- /dev/null +++ b/src/Exceptions/ValidationException.php @@ -0,0 +1,19 @@ +errors = $errors; + + parent::__construct( + config('struct.exceptions.include_validation_errors', false) + ? 'Validation failed due to: '.json_encode($this->errors) + : 'The given data failed to pass validation.' + ); + } +} diff --git a/src/Exceptions/WebhookFailed.php b/src/Exceptions/WebhookFailed.php new file mode 100644 index 0000000..64846dc --- /dev/null +++ b/src/Exceptions/WebhookFailed.php @@ -0,0 +1,41 @@ + $this->getMessage()], 400); + } +} diff --git a/src/Factory.php b/src/Factory.php new file mode 100644 index 0000000..188da7f --- /dev/null +++ b/src/Factory.php @@ -0,0 +1,22 @@ +middleware(ValidateWebhook::class); + } + + public function handle(Request $request) + { + try { + $webhook = Webhook::fromRequest($request); + + Event::dispatch($webhook->eventName(), $webhook); + + return new JsonResponse(); + } catch (Exception $e) { + return new Response('Error handling webhook', 500); + } + } +} diff --git a/src/Http/Middleware/ValidateWebhook.php b/src/Http/Middleware/ValidateWebhook.php new file mode 100644 index 0000000..9c8a02d --- /dev/null +++ b/src/Http/Middleware/ValidateWebhook.php @@ -0,0 +1,24 @@ +webhookValidator = $webhookValidator; + } + + public function handle(Request $request, Closure $next) + { + $this->webhookValidator->validateFromRequest($request); + + return $next($request); + } +} diff --git a/src/REST/Actions/ManagesProducts.php b/src/REST/Actions/ManagesProducts.php new file mode 100644 index 0000000..473ad68 --- /dev/null +++ b/src/REST/Actions/ManagesProducts.php @@ -0,0 +1,50 @@ +createResource('products', $data); + } + + public function getProducts(array $params = []): Collection + { + return $this->getResources('products', $params); + } + + public function getProduct($productId): ProductResource + { + return $this->getResource('products', $productId); + } + + public function updateProduct($productId, $data) + { + return $this->updateResource('products', $productId, $data); + } + + public function updateProducts($data) + { + return $this->updateResources('products', $data); + } + + public function deleteProduct($productId): void + { + $this->deleteResource('products', $productId); + } + + public function deleteProducts($productIds): void + { + $this->deleteResource('products', $productIds); + } + +} diff --git a/src/REST/Cursor.php b/src/REST/Cursor.php new file mode 100644 index 0000000..1e315df --- /dev/null +++ b/src/REST/Cursor.php @@ -0,0 +1,126 @@ +; rel="?{type}"?/i'; + + protected Struct $struct; + protected int $position = 0; + protected array $links = []; + protected array $results = []; + protected string $resourceClass; + + public function __construct(Struct $struct, Collection $results) + { + $this->struct = $struct; + $this->results[$this->position] = $results; + + $this->detectResourceClass(); + $this->extractLinks(); + } + + public function current(): Collection + { + return $this->results[$this->position]; + } + + public function hasNext(): bool + { + return ! empty($this->links['next']); + } + + public function hasPrev(): bool + { + return $this->position > 0; + } + + public function key(): int + { + return $this->position; + } + + public function next(): void + { + $this->position++; + + if (! $this->valid() && $this->hasNext()) { + $this->results[$this->position] = $this->fetchNextResults(); + $this->extractLinks(); + } + } + + public function prev(): void + { + if (! $this->hasPrev()) { + throw new RuntimeException('No previous results available.'); + } + + $this->position--; + } + + public function rewind(): void + { + $this->position = 0; + } + + public function valid(): bool + { + return isset($this->results[$this->position]); + } + + protected function extractLinks(): void + { + $response = $this->struct->getLastResponse(); + + if (! $response->header('Link')) { + $this->links = []; + + return; + } + + $links = [ + 'next' => null, + 'previous' => null, + ]; + + foreach (array_keys($links) as $type) { + $matched = preg_match( + str_replace('{type}', $type, static::LINK_REGEX), + $response->header('Link'), + $matches + ); + + if ($matched) { + $links[$type] = $matches[1]; + } + } + + $this->links = $links; + } + + protected function fetchNextResults(): Collection + { + $response = $this->struct->get( + Str::after($this->links['next'], $this->struct->getBaseUrl()) + ); + + return Collection::make(Arr::first($response->json())) + ->map(fn ($attr) => new $this->resourceClass($attr, $this->struct)); + } + + private function detectResourceClass() + { + if ($resource = optional($this->results[0])->first()) { + $this->resourceClass = get_class($resource); + } + } +} diff --git a/src/REST/Resources/ApiResource.php b/src/REST/Resources/ApiResource.php new file mode 100644 index 0000000..2283707 --- /dev/null +++ b/src/REST/Resources/ApiResource.php @@ -0,0 +1,157 @@ +attributes = $attributes; + $this->struct = $struct; + } + + /** + * Get all of the attributes except for a specified array of keys. + * + * @param array|string $keys + * @return array + */ + public function except($keys): array + { + return Arr::except($this->getAttributes(), is_array($keys) ? $keys : func_get_args()); + } + + /** + * Get a subset of the attributes. + * + * @param array|string $keys + * @return array + */ + public function only($keys): array + { + return Arr::only($this->getAttributes(), is_array($keys) ? $keys : func_get_args()); + } + + /** + * @param string $key + * @return mixed + */ + public function __get($key) + { + if (array_key_exists($key, $this->attributes)) { + return $this->getAttribute($key); + } + + throw new Exception('Property '.$key.' does not exist on '.get_called_class()); + } + + /** + * @param string $key + * @return bool + */ + public function __isset($key): bool + { + return array_key_exists($key, $this->attributes); + } + + /** + * Determine if the given attribute exists. + * + * @param mixed $offset + * @return bool + */ + #[\ReturnTypeWillChange] + public function offsetExists($offset) + { + return array_key_exists($offset, $this->attributes); + } + + /** + * Get the value for a given offset. + * + * @param mixed $offset + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($offset) + { + return $this->getAttribute($offset); + } + + /** + * Set the value for a given offset. + * + * @param mixed $offset + * @param mixed $value + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) + { + return $this->setAttribute($offset, $value); + } + + /** + * Unset the value for a given offset. + * + * @param mixed $offset + * @return void + */ + #[\ReturnTypeWillChange] + public function offsetUnset($offset) + { + unset($this->attributes[$offset]); + } + + /** + * Get an attribute. + * + * @param string $key + * @return mixed + */ + protected function getAttribute($key) + { + return $this->attributes[$key]; + } + + /** + * Set an attribute. + * + * @param string $key + * @param mixed $value + */ + protected function setAttribute($key, $value) + { + $this->attributes[$key] = $value; + + return $this; + } + + /** + * Get attributes for the resource. + * + * @return array + */ + public function getAttributes() + { + return $this->attributes; + } + + public function toArray() + { + return $this->getAttributes(); + } +} diff --git a/src/REST/Resources/ProductResource.php b/src/REST/Resources/ProductResource.php new file mode 100644 index 0000000..0d3a31a --- /dev/null +++ b/src/REST/Resources/ProductResource.php @@ -0,0 +1,18 @@ +struct->updateProduct($this->id, $data); + } + + public function delete(): void + { + $this->struct->deleteProduct($this->id); + } +} diff --git a/src/REST/Resources/WebhookResource.php b/src/REST/Resources/WebhookResource.php new file mode 100644 index 0000000..2a062af --- /dev/null +++ b/src/REST/Resources/WebhookResource.php @@ -0,0 +1,16 @@ +struct->updateWebhook($this->id, $data); + } + + public function delete(): void + { + $this->struct->deleteWebhook($this->id); + } +} diff --git a/src/Struct.php b/src/Struct.php new file mode 100644 index 0000000..5a43db0 --- /dev/null +++ b/src/Struct.php @@ -0,0 +1,68 @@ +withCredentials($apiKey, $baseUri); + } + + public function cursor(Collection $results): Cursor + { + return new Cursor($this, $results); + } + + public function getHttpClient(): PendingRequest + { + return $this->httpClient ??= Http::baseUrl($this->getBaseUrl()) + ->withToken($this->apiKey, '') + ->acceptJson(); + } + + public function graphQl(): PendingRequest + { + return Http::baseUrl("https://{$this->domain}/admin/api/graphql.json") + ->withHeaders(['X-Shopify-Access-Token' => $this->password]); + } + + public function getBaseUrl(): string + { + return "https://{$this->baseUri}"; + } + + public function tap(callable $callback): self + { + $callback($this->getHttpClient()); + + return $this; + } + + public function withCredentials(string $apiKey, string $baseUri): self + { + $this->apiKey = $apiKey; + $this->baseUri = $baseUri; + + $this->httpClient = null; + + return $this; + } +} diff --git a/src/StructServiceProvider.php b/src/StructServiceProvider.php new file mode 100644 index 0000000..6f4404f --- /dev/null +++ b/src/StructServiceProvider.php @@ -0,0 +1,58 @@ +publishConfig(); + $this->registerMacros(); + } + + public function register() + { + $this->app->singleton(Struct::class, fn () => Factory::fromConfig()); + + $this->app->alias(Struct::class, 'struct'); + + $this->app->bind(ErrorHandlerInterface::class, Handler::class); + + $this->app->singleton(SecretProvider::class, function (Application $app) { + $secretProvider = config('struct.webhooks.secret_provider'); + + return $app->make($secretProvider); + }); + } + + protected function publishConfig() + { + if ($this->app->runningInConsole()) { + $this->publishes([ + __DIR__.'/../config/struct.php' => config_path('struct.php'), + ], 'laravel-struct'); + } + + $this->mergeConfigFrom(__DIR__.'/../config/struct.php', 'struct'); + } + + protected function registerMacros(): void + { + Route::macro('structWebhooks', function (string $uri = 'struct/webhooks') { + return $this->post($uri, [WebhookController::class, 'handle'])->name('struct.webhooks'); + }); + + Request::macro('structEventKey', fn() => $this->header(Webhook::HEADER_EVENT_KEY)); + + } +} diff --git a/src/Support/Facades/Struct.php b/src/Support/Facades/Struct.php new file mode 100644 index 0000000..7b1cc19 --- /dev/null +++ b/src/Support/Facades/Struct.php @@ -0,0 +1,18 @@ +getHttpClient()->get($url, $query); + + $this->handleErrorResponse($response); + + return $response; + } + + public function post(string $url, array $data = []): Response + { + $response = $this->getHttpClient()->post($url, $data); + + $this->handleErrorResponse($response); + + return $response; + } + + public function put(string $url, array $data = []): Response + { + $response = $this->getHttpClient()->put($url, $data); + + $this->handleErrorResponse($response); + + return $response; + } + + public function patch(string $url, array $data = []): Response + { + $response = $this->getHttpClient()->patch($url, $data); + + $this->handleErrorResponse($response); + + return $response; + } + + public function delete(string $url, array $data = []): Response + { + $response = $this->getHttpClient()->delete($url, $data); + + $this->handleErrorResponse($response); + + return $response; + } + + protected function resourceClassFor(string $resource): string + { + $resourceClass = Str::of($resource) + ->studly() + ->singular() + ->prepend('Signifly\\Struct\\REST\\Resources\\') + ->append('Resource'); + + return class_exists($resourceClass) ? $resourceClass : ApiResource::class; + } + + protected function createResource(string $resource, array $data, array $uriPrefix = []): array + { + $response = $this->post(implode('/', [...$uriPrefix, $resource]), $data); + + return $response->json(); + } + + protected function createResources(string $resource, array $data, array $uriPrefix = []): array + { + $response = $this->post(implode('/', [...$uriPrefix, $resource]), $data); + + return $response->json(); + } + + protected function getResources(string $resource, array $params, array $uriPrefix = []): Collection + { + $resourceClass = $this->resourceClassFor($resource); + $response = $this->get(implode('/', [...$uriPrefix, "{$resource}.json"]), $params); + + return $this->transformCollection($response[Str::ucfirst($resource)], $resourceClass); + } + + protected function getResource(string $resource, $resourceId, array $uriPrefix = []): ApiResource + { + // $key = Str::singular($resource); + $resourceClass = $this->resourceClassFor($resource); + + $response = $this->get(implode('/', [...$uriPrefix, "{$resource}/{$resourceId}"])); + + return new $resourceClass($response->json(), $this); + } + + protected function updateResource(string $resource, $resourceId, array $data, array $uriPrefix = []): ApiResource + { + $key = Str::singular($resource); + $resourceClass = $this->resourceClassFor($resource); + + $response = $this->put(implode('/', [...$uriPrefix, "{$resource}/{$resourceId}.json"]), [$key => $data]); + + return new $resourceClass($response[$key], $this); + } + + protected function updateResources(string $resource, $resourceId, array $data, array $uriPrefix = []): ApiResource + { + $key = Str::singular($resource); + $resourceClass = $this->resourceClassFor($resource); + + $response = $this->put(implode('/', [...$uriPrefix, "{$resource}/{$resourceId}.json"]), [$key => $data]); + + return new $resourceClass($response[$key], $this); + } + + protected function deleteResource(string $resource, $resourceId, array $uriPrefix = []): void + { + $this->delete(implode('/', [...$uriPrefix, "{$resource}/{$resourceId}.json"])); + } + + public function getLastResponse(): Response + { + return $this->lastResponse; + } + + private function handleErrorResponse(Response $response): void + { + $this->lastResponse = $response; + + app(ErrorHandlerInterface::class)->handle($response); + } +} diff --git a/src/Support/TransformsResources.php b/src/Support/TransformsResources.php new file mode 100644 index 0000000..ab71533 --- /dev/null +++ b/src/Support/TransformsResources.php @@ -0,0 +1,21 @@ +map(function ($attributes) use ($class) { + return $this->transformItem($attributes, $class); + }); + } + + protected function transformItem(array $attributes, string $class): ApiResource + { + return new $class($attributes, $this); + } +} diff --git a/src/Support/VerifiesWebhooks.php b/src/Support/VerifiesWebhooks.php new file mode 100644 index 0000000..ed37bdf --- /dev/null +++ b/src/Support/VerifiesWebhooks.php @@ -0,0 +1,16 @@ +calculateSignature($data, $secret)); + } + + public function calculateSignature(string $data, string $secret): string + { + return base64_encode(hash_hmac('sha256', $data, $secret, true)); + } +} diff --git a/src/Webhooks/ConfigSecretProvider.php b/src/Webhooks/ConfigSecretProvider.php new file mode 100644 index 0000000..3085538 --- /dev/null +++ b/src/Webhooks/ConfigSecretProvider.php @@ -0,0 +1,11 @@ +eventKey = $eventKey; + $this->payload = $payload; + } + + public function payload(): array + { + return $this->payload; + } + + public function eventKey(): string + { + return $this->eventKey; + } + + public function eventName(): string + { + return 'struct-webhooks.'. $this->eventKey(); + } + + public static function fromRequest(Request $request): self + { + return new self( + $request->structEventKey(), + json_decode($request->getContent(), true) + ); + } +} diff --git a/src/Webhooks/WebhookValidator.php b/src/Webhooks/WebhookValidator.php new file mode 100644 index 0000000..54bb5fd --- /dev/null +++ b/src/Webhooks/WebhookValidator.php @@ -0,0 +1,41 @@ +secretProvider = $secretProvider; + } + + public function validate(string $signature, string $domain, string $data): void + { + // Validate webhook secret presence + $secret = $this->secretProvider->getSecret($domain); + throw_if(empty($secret), WebhookFailed::missingSigningSecret()); + + // Validate webhook signature + throw_unless( + $this->isWebhookSignatureValid($signature, $data, $secret), + WebhookFailed::invalidSignature($signature) + ); + } + + public function validateFromRequest(Request $request): void + { + // Validate event key presence + $eventKey = $request->structEventKey(); + throw_unless($eventKey, WebhookFailed::missingEventKey()); + + // $this->validate($signature, $request->shopifyShopDomain(), $request->getContent()); + } +} diff --git a/struct.svg b/struct.svg new file mode 100644 index 0000000..2064cce --- /dev/null +++ b/struct.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Fixtures/products.all.json b/tests/Fixtures/products.all.json new file mode 100644 index 0000000..ae13aa5 --- /dev/null +++ b/tests/Fixtures/products.all.json @@ -0,0 +1,49 @@ +{ + "Products": [ + { + "Id": 18, + "Name": { + "en-GB": "Mascot work pants", + "da-DK": "Mascot arbejdsbukser" + }, + "Created": "2023-04-15T14:58:52.472463+02:00", + "CreatedBy": "Struct", + "LastModified": "2023-04-15T14:58:52.472463+02:00", + "LastModifiedBy": "Struct", + "VariationDefinitionUid": "4e847750-30c8-4d95-b0b7-ed0de5bbfec4", + "IsArchived": false, + "ProductStructureUid": "05758c14-959e-4b59-abe5-060182649b55" + }, + { + "Id": 19, + "Name": { + "en-GB": "Apple Iphone", + "da-DK": "Apple Iphone" + }, + "Created": "2023-01-05T13:58:52.472463+01:00", + "CreatedBy": "Struct", + "LastModified": "2022-11-16T13:58:52.472463+01:00", + "LastModifiedBy": "Struct", + "VariationDefinitionUid": "4e847750-30c8-4d95-b0b7-ed0de5bbfec4", + "IsArchived": false, + "ProductStructureUid": "05758c14-959e-4b59-abe5-060182649b55" + }, + { + "Id": 20, + "Name": { + "en-GB": "Samsung 8K series TV", + "da-DK": "Samsung 8K serie" + }, + "Created": "2022-10-22T14:58:52.472463+02:00", + "CreatedBy": "Struct", + "LastModified": "2022-10-22T14:58:52.472463+02:00", + "LastModifiedBy": "Struct", + "VariationDefinitionUid": "4e847750-30c8-4d95-b0b7-ed0de5bbfec4", + "IsArchived": false, + "ProductStructureUid": "05758c14-959e-4b59-abe5-060182649b55" + } + ], + "Total": 20102, + "Remaining": 20100, + "LastId": 20 + } \ No newline at end of file diff --git a/tests/Fixtures/products.create.json b/tests/Fixtures/products.create.json new file mode 100644 index 0000000..77cda64 --- /dev/null +++ b/tests/Fixtures/products.create.json @@ -0,0 +1,34 @@ +[ + { + "Values": { + "Name": [ + { + "CultureCode": "en-GB", + "Data": "Mascot work pants" + }, + { + "CultureCode": "da-DK", + "Data": "Mascot arbejdsbukser" + } + ] + }, + "VariationDefinitionUid": "4e847750-30c8-4d95-b0b7-ed0de5bbfec4", + "ProductStructureUid": "05758c14-959e-4b59-abe5-060182649b55" + }, + { + "Name": { + "en-GB": "Apple Iphone", + "da-DK": "Apple Iphone" + }, + "VariationDefinitionUid": "4e847750-30c8-4d95-b0b7-ed0de5bbfec4", + "ProductStructureUid": "05758c14-959e-4b59-abe5-060182649b55" + }, + { + "Name": { + "en-GB": "Samsung 8K series TV", + "da-DK": "Samsung 8K serie" + }, + "VariationDefinitionUid": "4e847750-30c8-4d95-b0b7-ed0de5bbfec4", + "ProductStructureUid": "05758c14-959e-4b59-abe5-060182649b55" + } +] \ No newline at end of file diff --git a/tests/Fixtures/products.show.json b/tests/Fixtures/products.show.json new file mode 100644 index 0000000..339c229 --- /dev/null +++ b/tests/Fixtures/products.show.json @@ -0,0 +1,14 @@ +{ + "Id": 2011, + "Name": { + "en-GB": "Mascot work pants", + "da-DK": "Mascot arbejdsbukser" + }, + "Created": "2023-04-15T14:58:52.447014+02:00", + "CreatedBy": "Struct", + "LastModified": "2023-04-15T14:58:52.447014+02:00", + "LastModifiedBy": "Struct", + "VariationDefinitionUid": "c3555e21-7728-4cda-a534-028449e61365", + "IsArchived": false, + "ProductStructureUid": "e56d09fa-1ec1-42f4-9487-a9a8fdb8e13e" +} \ No newline at end of file diff --git a/tests/Fixtures/products.update-multiple.json b/tests/Fixtures/products.update-multiple.json new file mode 100644 index 0000000..b93f2c1 --- /dev/null +++ b/tests/Fixtures/products.update-multiple.json @@ -0,0 +1,47 @@ +[ + { + "ProductId": 20111, + "UpdateModel": { + "Values": { + "Name": "Samsung QE65Q950T", + "PrimaryImage": "3013" + } + } + }, + { + "ProductId": 5635, + "UpdateModel": { + "Classifications": { + "CategoryIds": [ + 392, + 23 + ], + "Primary": 23 + } + } + }, + { + "ProductId": 18863, + "UpdateModel": { + "Classifications": { + "CategoryIds": [ + 392, + 23 + ], + "Primary": 23 + }, + "Values": { + "Description": [ + { + "CultureCode": "en-GB", + "Data": "Apples flagship phone" + }, + { + "CultureCode": "da-DK", + "Data": "Apples flagskib" + } + ] + } + } + } +] \ No newline at end of file diff --git a/tests/MacrosTest.php b/tests/MacrosTest.php new file mode 100644 index 0000000..a7a3af5 --- /dev/null +++ b/tests/MacrosTest.php @@ -0,0 +1,33 @@ +assertTrue(Route::hasMacro('structWebhooks')); + } + + /** @test **/ + public function it_register_struct_macros_on_request() + { + $this->assertTrue(Request::hasMacro('structShopDomain')); + $this->assertTrue(Request::hasMacro('structHmacSignature')); + $this->assertTrue(Request::hasMacro('structTopic')); + } + + /** @test **/ + public function it_registers_endpoint_when_using_struct_webhooks_macro() + { + Route::structWebhooks(); + + Route::getRoutes()->refreshNameLookups(); + + $this->assertTrue(Route::has('struct.webhooks')); + } +} diff --git a/tests/ManageProductsTest.php b/tests/ManageProductsTest.php new file mode 100644 index 0000000..46d6eb9 --- /dev/null +++ b/tests/ManageProductsTest.php @@ -0,0 +1,128 @@ +struct = Factory::fromConfig(); + } + + /** @test **/ + public function it_gets_products() + { + Http::fake([ + '*' => Http::response($this->fixture('products.all')), + ]); + + $resources = $this->struct->getProducts(); + + Http::assertSent(function (Request $request) { + $this->assertEquals($this->struct->getBaseUrl().'/products.json', $request->url()); + $this->assertEquals('GET', $request->method()); + + return true; + }); + $this->assertInstanceOf(Collection::class, $resources); + $this->assertInstanceOf(ProductResource::class, $resources->first()); + $this->assertCount(3, $resources); + } + + /** @test **/ + public function it_creates_a_product() + { + Http::fake([ + '*' => Http::response([1, 2]), + ]); + + $productIds = $this->struct->createProducts($payload = [ + [ + 'ProductStructureUid' => 'df5733d0-af60-4299-9449-1bed2f4f9ba1', + ], + [ + 'ProductStructureUid' => 'df5733d0-af60-4299-9449-1bed2f4f9ba1', + ], + ]); + + Http::assertSent(function (Request $request) use ($payload) { + $this->assertEquals($this->struct->getBaseUrl().'/products', $request->url()); + $this->assertEquals($payload, $request->data()); + $this->assertEquals('POST', $request->method()); + + return true; + }); + } + + /** @test **/ + public function it_finds_a_product() + { + Http::fake([ + '*' => Http::response($this->fixture('products.show')), + ]); + + $resource = $this->struct->getProduct($id = 1234); + + Http::assertSent(function (Request $request) use ($id) { + $this->assertEquals($this->struct->getBaseUrl().'/products/'.$id, $request->url()); + $this->assertEquals('GET', $request->method()); + + return true; + }); + $this->assertInstanceOf(ProductResource::class, $resource); + } + + /** @test **/ + public function it_updates_a_product() + { + $fixture = $this->fixture('products.update-multiple'); + + Http::fake([ + '*' => Http::response($this->fixture('products.update-multiple')), + ]); + + $id = 1234; + + $resource = $this->struct->updateProducts($id, $fixture); + + Http::assertSent(function (Request $request) use ($id) { + $this->assertEquals($this->struct->getBaseUrl().'/products/', $request->url()); + $this->assertEquals('PATCH', $request->method()); + + return true; + }); + $this->assertInstanceOf(ProductResource::class, $resource); + } + + /** @test **/ + public function it_deletes_a_product() + { + Http::fake([ + '*' => Http::response(), + ]); + + $id = 1234; + + $this->struct->deleteProduct($id); + + Http::assertSent(function (Request $request) use ($id) { + $this->assertEquals($this->struct->getBaseUrl().'/products/'.$id.'.json', $request->url()); + $this->assertEquals('DELETE', $request->method()); + + return true; + }); + } + +} diff --git a/tests/ManageWebhooksTest.php b/tests/ManageWebhooksTest.php new file mode 100644 index 0000000..6594b10 --- /dev/null +++ b/tests/ManageWebhooksTest.php @@ -0,0 +1,43 @@ +struct = Factory::fromConfig(); + } + + /** @test **/ + public function it_creates_a_webhook() + { + Http::fake([ + '*' => Http::response($this->fixture('webhooks.create')), + ]); + + $resource = $this->struct->createWebhook($payload = [ + 'topic' => 'orders/create', + 'address' => 'https://whatever.hostname.com/', + 'format' => 'json', + ]); + + Http::assertSent(function (Request $request) { + $this->assertEquals($this->struct->getBaseUrl().'/webhooks.json', $request->url()); + $this->assertEquals('POST', $request->method()); + + return true; + }); + $this->assertInstanceOf(ApiResource::class, $resource); + } +} diff --git a/tests/StructTest.php b/tests/StructTest.php new file mode 100644 index 0000000..fae6a0c --- /dev/null +++ b/tests/StructTest.php @@ -0,0 +1,50 @@ +app->make('struct'); + + $this->assertInstanceOf(Struct::class, $struct); + } + + /** @test **/ + public function it_returns_the_same_struct_instance_from_the_container() + { + $structA = $this->app->make('struct'); + $structB = $this->app->make('struct'); + + $this->assertSame($structA, $structB); + } + + /** @test **/ + public function it_memoizes_the_http_client() + { + $struct = $this->app->make('struct'); + + $clientA = $struct->getHttpClient(); + $clientB = $struct->getHttpClient(); + + $this->assertSame($clientA, $clientB); + } + + /** @test **/ + public function it_updates_credentials_and_resets_client() + { + $struct = $this->app->make('struct'); + + $clientA = $struct->getHttpClient(); + + $struct = $struct->withCredentials('1234', '1234'); + + $clientB = $struct->getHttpClient(); + + $this->assertNotSame($clientA, $clientB); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..f17ccbf --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,35 @@ +setApplicationKey(); + + parent::setUp(); + } + + protected function getPackageProviders($app) + { + return [ + StructServiceProvider::class, + ]; + } + + protected function setApplicationKey() + { + putenv('APP_KEY=mysecretkey'); + } + + protected function fixture(string $name): array + { + $json = file_get_contents(__DIR__.'/Fixtures/'.$name.'.json'); + + return json_decode($json, true); + } +} diff --git a/tests/WebhookControllerTest.php b/tests/WebhookControllerTest.php new file mode 100644 index 0000000..96d7963 --- /dev/null +++ b/tests/WebhookControllerTest.php @@ -0,0 +1,42 @@ +postJson($this->getUrl(), [], $this->getValidHeaders()); + + $response->assertOk(); + Event::assertDispatched('struct-webhooks.products.created'); + } + + private function getUrl(): string + { + return route('struct.webhooks'); + } + + private function getValidHeaders(array $overwrites = []): array + { + return array_merge([ + Webhook::HEADER_EVENT_KEY => 'products.created', + ], $overwrites); + } +} diff --git a/workflows/php-cs-fixer.yml b/workflows/php-cs-fixer.yml new file mode 100644 index 0000000..344b9cc --- /dev/null +++ b/workflows/php-cs-fixer.yml @@ -0,0 +1,30 @@ +name: Check & fix styling + +on: push + +jobs: + php-cs-fixer: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + ref: ${{ github.head_ref }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.0 + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_mysql, bcmath, soap, intl, gd, exif, iconv, imagick + coverage: none + + - name: Composer install + run: composer install + + - name: Run php-cs-fixer + run: ./vendor/bin/php-cs-fixer fix + + - uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Apply php-cs-fixer changes + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/workflows/run-tests.yml b/workflows/run-tests.yml new file mode 100644 index 0000000..5d2f045 --- /dev/null +++ b/workflows/run-tests.yml @@ -0,0 +1,49 @@ +name: Tests + +on: [ push, pull_request ] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + os: [ ubuntu-latest ] + php: [ 7.4, 8.0, 8.1 ] + laravel: [ 8.*, 9.* ] + dependency-version: [ prefer-lowest, prefer-stable ] + include: + - laravel: 8.* + testbench: ^6.24 + - laravel: 9.* + testbench: 7.* + exclude: + - laravel: 9.* + php: 7.4 + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} - ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ~/.composer/cache/files + key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + + - 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 + coverage: none + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest + + - name: Execute tests + run: vendor/bin/phpunit