diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2c11a78 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +WORKSOME_API_TOKEN=worksome-test-token +WORKSOME_BASE_URI=https://gateway.test diff --git a/.gitattributes b/.gitattributes index 82c80c7..0a37a77 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ * text=auto # Ignore all test and documentation with "export-ignore". +/.env.example export-ignore /.github export-ignore /.gitattributes export-ignore /.gitignore export-ignore diff --git a/.gitignore b/.gitignore index 72bb0c1..4acd315 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /composer.lock /phpunit.xml /phpstan.neon +.env .php_cs .php_cs.cache .phpunit.cache diff --git a/README.md b/README.md index 8be9c9a..b6e41fa 100644 --- a/README.md +++ b/README.md @@ -11,19 +11,15 @@ An object-oriented PHP wrapper for the Worksome API ## Requirements - PHP >= 8.2 -- A [PSR-17 implementation](https://packagist.org/providers/psr/http-factory-implementation) -- A [PSR-18 implementation](https://packagist.org/providers/psr/http-client-implementation) ## Install Via Composer ```shell -composer require worksome/sdk guzzlehttp/guzzle:^7.5 http-interop/http-factory-guzzle:^1.2 +composer require worksome/sdk ``` -We are decoupled from any HTTP messaging client with help by [HTTPlug](https://httplug.io). - ## Usage #### Basic usage @@ -32,8 +28,8 @@ We are decoupled from any HTTP messaging client with help by [HTTPlug](https://h // Include the Composer autoloader require_once __DIR__ . '/vendor/autoload.php'; -$client = new \Worksome\Sdk\Client(); -$repositories = $client->graph()->execute(<<graph()->graphql(<<graph()->execute(<<authenticate($apiToken); +$client = new \Worksome\Sdk\Worksome($apiToken); ``` #### Using a different base URI @@ -57,28 +52,9 @@ $client->authenticate($apiToken); The Worksome SDK defaults to using the `https://api.worksome.com` URI, however if a custom URI is required, this can be passed to the constructor: ```php -$client = new \Worksome\Sdk\Client(baseUri: 'https://api.local'); -``` - -#### Using a different HTTP client - -Thanks to [HTTPlug](https://httplug.io), we support the use of many HTTP clients. For example, to use the Symfony HTTP -Client, first install the client and PSR-7 implementation. - -```shell -composer require worksome/sdk symfony/http-client nyholm/psr7 +$client = new \Worksome\Sdk\Worksome(baseUri: 'https://api.local'); ``` -Next, set up the Worksome client with this HTTP client: - -```php -$client = \Worksome\SDK\Client::createWithHttpClient( - new \Symfony\Component\HttpClient\HttplugClient() -); -``` - -Alternatively, you can inject an HTTP client through the `Client` constructor. - ## Change log Please see [GitHub Releases](https://github.com/worksome/sdk-php/releases) for more information on what has changed recently. diff --git a/composer.json b/composer.json index 5c53300..098ee24 100644 --- a/composer.json +++ b/composer.json @@ -5,23 +5,14 @@ "license": "MIT", "require": { "php": "^8.3", - "php-http/client-common": "^2.7", - "php-http/discovery": "^1.20", - "php-http/httplug": "^2.4", - "php-http/multipart-stream-builder": "^1.4", - "psr/http-client-implementation": "^1.0", - "psr/http-factory-implementation": "^1.0", - "psr/http-message": "^1.1 || ^2.0" + "illuminate/collections": "^11.45 || ^12.22", + "saloonphp/saloon": "^3.0" }, "require-dev": { - "guzzlehttp/guzzle": "^7.9", - "guzzlehttp/psr7": "^2.7", - "http-interop/http-factory-guzzle": "^1.2", "pestphp/pest": "^3.7", - "php-http/mock-client": "^1.6", - "php-http/vcr-plugin": "^1.2", "phpstan/phpstan": "^2.1", "symfony/var-dumper": "^7.2", + "vlucas/phpdotenv": "^5.6", "worksome/coding-style": "^3.2" }, "autoload": { @@ -53,8 +44,7 @@ "allow-plugins": { "pestphp/pest-plugin": true, "dealerdirect/phpcodesniffer-composer-installer": true, - "worksome/coding-style": true, - "php-http/discovery": true + "worksome/coding-style": true } }, "minimum-stability": "dev", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ae37457..53d4816 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,73 +1,7 @@ parameters: ignoreErrors: - - message: '#^PHP files should declare strict types\.$#' - identifier: worksome.declareStrictTypes + message: '#^Method Worksome\\Sdk\\Exceptions\\GraphQL\\InvalidResponseException\:\:__construct\(\) has parameter \$response with generic class Worksome\\Sdk\\DTOs\\GraphQL but does not specify its types\: TData$#' + identifier: missingType.generics count: 1 - path: src/Exception/BadMethodCallException.php - - - - message: '#^PHP files should declare strict types\.$#' - identifier: worksome.declareStrictTypes - count: 1 - path: src/Exception/ExceptionInterface.php - - - - message: '#^PHP files should declare strict types\.$#' - identifier: worksome.declareStrictTypes - count: 1 - path: src/Exception/InvalidArgumentException.php - - - - message: '#^PHP files should declare strict types\.$#' - identifier: worksome.declareStrictTypes - count: 1 - path: src/Exception/InvalidResponseException.php - - - - message: '#^PHP files should declare strict types\.$#' - identifier: worksome.declareStrictTypes - count: 1 - path: src/Exception/MissingArgumentException.php - - - - message: '#^PHP files should declare strict types\.$#' - identifier: worksome.declareStrictTypes - count: 1 - path: src/Exception/RuntimeException.php - - - - message: '#^PHP files should declare strict types\.$#' - identifier: worksome.declareStrictTypes - count: 1 - path: src/HttpClient/Builder.php - - - - message: '#^PHP files should declare strict types\.$#' - identifier: worksome.declareStrictTypes - count: 1 - path: src/HttpClient/Message/ResponseMediator.php - - - - message: '#^Method Worksome\\Sdk\\HttpClient\\Plugin\\Authentication\:\:doHandleRequest\(\) should return Http\\Promise\\Promise but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/HttpClient/Plugin/Authentication.php - - - - message: '#^PHP files should declare strict types\.$#' - identifier: worksome.declareStrictTypes - count: 1 - path: src/HttpClient/Plugin/Authentication.php - - - - message: '#^Method Worksome\\Sdk\\HttpClient\\Plugin\\PathPrepend\:\:doHandleRequest\(\) should return Http\\Promise\\Promise but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/HttpClient/Plugin/PathPrepend.php - - - - message: '#^PHP files should declare strict types\.$#' - identifier: worksome.declareStrictTypes - count: 1 - path: src/HttpClient/Plugin/PathPrepend.php + path: src/Exceptions/GraphQL/InvalidResponseException.php diff --git a/src/Api/AbstractApi.php b/src/Api/AbstractApi.php deleted file mode 100644 index be4eac1..0000000 --- a/src/Api/AbstractApi.php +++ /dev/null @@ -1,173 +0,0 @@ -client; - } - - public function configure(): self - { - return $this; - } - - /** - * Send a GET request with query parameters. - * - * @param string $path Request path. - * @param array $parameters GET parameters. - * @param array $requestHeaders Request Headers. - * - * @return array|string - */ - protected function get(string $path, array $parameters = [], array $requestHeaders = []): array|string - { - if (count($parameters) > 0) { - $path .= '?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986); - } - - $response = $this->client->getHttpClient()->get($path, $requestHeaders); - - return ResponseMediator::getContent($response); - } - - /** - * Send a HEAD request with query parameters. - * - * @param string $path Request path. - * @param array $parameters HEAD parameters. - * @param array $requestHeaders Request headers. - * - * @return ResponseInterface - */ - protected function head(string $path, array $parameters = [], array $requestHeaders = []): ResponseInterface - { - return $this->client->getHttpClient()->head( - $path . '?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986), - $requestHeaders - ); - } - - /** - * Send a POST request with JSON-encoded parameters. - * - * @param string $path Request path. - * @param array $parameters POST parameters to be JSON encoded. - * @param array $requestHeaders Request headers. - * - * @return array|string - */ - protected function post(string $path, array $parameters = [], array $requestHeaders = []): array|string - { - return $this->postRaw( - $path, - $this->createJsonBody($parameters), - $requestHeaders - ); - } - - /** - * Send a POST request with raw data. - * - * @param string $path Request path. - * @param string|null $body Request body. - * @param array $requestHeaders Request headers. - * - * @return array|string - */ - protected function postRaw(string $path, string|null $body, array $requestHeaders = []): array|string - { - $response = $this->client->getHttpClient()->post( - $path, - $requestHeaders, - $body - ); - - return ResponseMediator::getContent($response); - } - - /** - * Send a PATCH request with JSON-encoded parameters. - * - * @param string $path Request path. - * @param array $parameters POST parameters to be JSON encoded. - * @param array $requestHeaders Request headers. - * - * @return array|string - */ - protected function patch(string $path, array $parameters = [], array $requestHeaders = []): array|string - { - $response = $this->client->getHttpClient()->patch( - $path, - $requestHeaders, - $this->createJsonBody($parameters) - ); - - return ResponseMediator::getContent($response); - } - - /** - * Send a PUT request with JSON-encoded parameters. - * - * @param string $path Request path. - * @param array $parameters POST parameters to be JSON encoded. - * @param array $requestHeaders Request headers. - * - * @return array|string - */ - protected function put(string $path, array $parameters = [], array $requestHeaders = []): array|string - { - $response = $this->client->getHttpClient()->put( - $path, - $requestHeaders, - $this->createJsonBody($parameters) - ); - - return ResponseMediator::getContent($response); - } - - /** - * Send a DELETE request with JSON-encoded parameters. - * - * @param string $path Request path. - * @param array $parameters POST parameters to be JSON encoded. - * @param array $requestHeaders Request headers. - * - * @return array|string - */ - protected function delete(string $path, array $parameters = [], array $requestHeaders = []): array|string - { - $response = $this->client->getHttpClient()->delete( - $path, - $requestHeaders, - $this->createJsonBody($parameters) - ); - - return ResponseMediator::getContent($response); - } - - /** - * Create a JSON encoded version of an array of parameters. - * - * @param array $parameters Request parameters - * - * @return string|null - */ - protected function createJsonBody(array $parameters): string|null - { - return (count($parameters) === 0) ? null : (json_encode($parameters) ?: null); - } -} diff --git a/src/Api/Viewer.php b/src/Api/Viewer.php deleted file mode 100644 index 6b0feed..0000000 --- a/src/Api/Viewer.php +++ /dev/null @@ -1,83 +0,0 @@ -getClient()))->execute( - <<<'GQL' - { - viewer { - id - } - } - GQL - ); - - return $response['data']['viewer']['id'] ?? throw new InvalidResponseException($response); - } - - /** @return array */ - public function accounts(): array - { - /** @var array{data?: array{accounts: array}} $response */ - $response = (new GraphQL($this->getClient()))->execute( - <<<'GQL' - { - accounts { - id - name - } - } - GQL - ); - - return $response['data']['accounts'] ?? throw new InvalidResponseException($response); - } - - /** @return array{id: string, email: string} */ - public function changeEmail(string $email): array - { - /** @var array{data?: array{changeEmail: array{id: string, email: string}}} $response */ - $response = (new GraphQL($this->getClient()))->execute( - <<<'GQL' - mutation changeEmail($email: String!) { - changeEmail(input: {email: $email}) { - id - email - } - } - GQL, - [ - 'email' => $email, - ] - ); - - return $response['data']['changeEmail'] ?? throw new InvalidResponseException($response); - } - - /** @return array{id: string, email: string} */ - public function sendVerificationEmail(): array - { - /** @var array{data?: array{sendVerificationEmail: array{id: string, email: string}}} $response */ - $response = (new GraphQL($this->getClient()))->execute( - <<<'GQL' - mutation sendVerificationEmail { - sendVerificationEmail { - id - email - } - } - GQL - ); - - return $response['data']['sendVerificationEmail'] ?? throw new InvalidResponseException($response); - } -} diff --git a/src/Client.php b/src/Client.php deleted file mode 100644 index 5f9320b..0000000 --- a/src/Client.php +++ /dev/null @@ -1,99 +0,0 @@ -httpClientBuilder = $builder = $httpClientBuilder ?? new Builder(); - - $builder->addPlugin(new RedirectPlugin()); - $builder->addPlugin( - new AddHostPlugin( - Psr17FactoryDiscovery::findUriFactory()->createUri($baseUri) - ) - ); - $builder->addPlugin(new HeaderDefaultsPlugin([ - 'User-Agent' => 'worksome-sdk (https://github.com/worksome/sdk-php)', - ])); - - $builder->addHeaderValue('Accept', 'application/json'); - $builder->addHeaderValue('Content-Type', 'application/json'); - } - - public static function createWithHttpClient(ClientInterface $httpClient): self - { - $builder = new Builder($httpClient); - - return new self($builder); - } - - /** @throws InvalidArgumentException */ - public function api(string $name): AbstractApi - { - return match ($name) { - 'graph', 'graphql' => new GraphQL($this), - 'viewer' => new Viewer($this), - default => throw new InvalidArgumentException( - sprintf('Undefined api instance called: "%s"', $name) - ), - }; - } - - public function authenticate(string $tokenOrLogin, AuthMethod $authMethod = AuthMethod::AccessToken): void - { - $this->getHttpClientBuilder()->removePlugin(Authentication::class); - $this->getHttpClientBuilder()->addPlugin(new Authentication($tokenOrLogin, $authMethod)); - } - - /** @param array $args */ - public function __call(string $name, array $args): AbstractApi - { - try { - return $this->api($name); - } catch (InvalidArgumentException $e) { - throw new BadMethodCallException( - sprintf('Undefined method called: "%s"', $name), - $e->getCode(), - $e - ); - } - } - - public function getHttpClient(): HttpMethodsClientInterface - { - return $this->getHttpClientBuilder()->getHttpClient(); - } - - protected function getHttpClientBuilder(): Builder - { - return $this->httpClientBuilder; - } -} diff --git a/src/DTOs/GraphQL.php b/src/DTOs/GraphQL.php new file mode 100644 index 0000000..fc03cfd --- /dev/null +++ b/src/DTOs/GraphQL.php @@ -0,0 +1,37 @@ + + * + * @phpstan-type GraphQLError array{message: string, path?: list, extensions: mixed} + */ +readonly class GraphQL +{ + /** + * @param TData|null $data + * @param list|null $errors + */ + public function __construct( + public array|null $data, + public array|null $errors, + ) { + } + + /** @return self> */ + public static function fromResponse(Response $response): self + { + /** @var array{data?: TData, errors?: list} $json */ + $json = $response->json(); + + return new self( + data: $json['data'] ?? null, + errors: $json['errors'] ?? null, + ); + } +} diff --git a/src/Enums/AuthMethod.php b/src/Enums/AuthMethod.php deleted file mode 100644 index 2c2bb6f..0000000 --- a/src/Enums/AuthMethod.php +++ /dev/null @@ -1,10 +0,0 @@ -|string $response */ - public function __construct(public readonly array|string $response) - { - parent::__construct('Invalid response from API'); - } -} diff --git a/src/Exception/MissingArgumentException.php b/src/Exception/MissingArgumentException.php deleted file mode 100644 index 6b33dd2..0000000 --- a/src/Exception/MissingArgumentException.php +++ /dev/null @@ -1,26 +0,0 @@ - $required */ - public function __construct($required, int $code = 0, Throwable|null $previous = null) - { - if (is_string($required)) { - $required = [$required]; - } - - parent::__construct( - sprintf('One or more of required ("%s") parameters is missing!', implode('", "', $required)), - $code, - 1, - __FILE__, - __LINE__, - $previous - ); - } -} diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php deleted file mode 100644 index d2f55a3..0000000 --- a/src/Exception/RuntimeException.php +++ /dev/null @@ -1,7 +0,0 @@ - */ - private array $plugins = []; - - /** @var array|int|string> */ - private array $headers = []; - - public function __construct( - ClientInterface|null $httpClient = null, - RequestFactoryInterface|null $requestFactory = null, - StreamFactoryInterface|null $streamFactory = null, - ) { - $this->httpClient = $httpClient ?? Psr18ClientDiscovery::find(); - $this->requestFactory = $requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); - $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); - } - - public function getHttpClient(): HttpMethodsClientInterface - { - if ($this->httpClientModified) { - $this->httpClientModified = false; - - $plugins = $this->plugins; - - $this->pluginClient = new HttpMethodsClient( - (new PluginClientFactory())->createClient($this->httpClient, $plugins), - $this->requestFactory, - $this->streamFactory - ); - } - - return $this->pluginClient; - } - - public function addPlugin(Plugin $plugin): void - { - $this->plugins[] = $plugin; - $this->httpClientModified = true; - } - - /** @param class-string $className */ - public function removePlugin(string $className): void - { - foreach ($this->plugins as $idx => $plugin) { - if ($plugin instanceof $className) { - unset($this->plugins[$idx]); - $this->httpClientModified = true; - } - } - } - - /** - * Clears used headers. - */ - public function clearHeaders(): void - { - $this->headers = []; - - $this->removePlugin(HeaderAppendPlugin::class); - $this->addPlugin(new HeaderAppendPlugin($this->headers)); - } - - /** - * @param array $headers - */ - public function addHeaders(array $headers): void - { - $this->headers = array_merge($this->headers, $headers); - - $this->removePlugin(HeaderAppendPlugin::class); - $this->addPlugin(new HeaderAppendPlugin($this->headers)); - } - - public function addHeaderValue(string $header, string $headerValue): void - { - if (! isset($this->headers[$header])) { - $this->headers[$header] = $headerValue; - } else { - $this->headers[$header] = array_merge((array) $this->headers[$header], [$headerValue]); - } - - $this->removePlugin(HeaderAppendPlugin::class); - $this->addPlugin(new HeaderAppendPlugin($this->headers)); - } -} diff --git a/src/HttpClient/Message/ResponseMediator.php b/src/HttpClient/Message/ResponseMediator.php deleted file mode 100644 index 2e533a7..0000000 --- a/src/HttpClient/Message/ResponseMediator.php +++ /dev/null @@ -1,32 +0,0 @@ -|string */ - public static function getContent(ResponseInterface $response): array|string - { - $body = $response->getBody()->__toString(); - - if (str_starts_with($response->getHeaderLine('Content-Type'), 'application/json')) { - /** @var array $content */ - $content = json_decode($body, true, 512, JSON_THROW_ON_ERROR); - - if (json_last_error() === JSON_ERROR_NONE) { - return $content; - } - } - - return $body; - } - - public static function getHeader(ResponseInterface $response, string $name): string|null - { - $headers = $response->getHeader($name); - - return array_shift($headers); - } -} diff --git a/src/HttpClient/Plugin/Authentication.php b/src/HttpClient/Plugin/Authentication.php deleted file mode 100644 index af067ba..0000000 --- a/src/HttpClient/Plugin/Authentication.php +++ /dev/null @@ -1,35 +0,0 @@ -withHeader( - 'Authorization', - $this->getAuthorizationHeader() - ); - - return $next($request); - } - - private function getAuthorizationHeader(): string - { - return match ($this->method) { - AuthMethod::AccessToken => "Bearer {$this->tokenOrLogin}", - }; - } -} diff --git a/src/HttpClient/Plugin/PathPrepend.php b/src/HttpClient/Plugin/PathPrepend.php deleted file mode 100644 index edf7fd0..0000000 --- a/src/HttpClient/Plugin/PathPrepend.php +++ /dev/null @@ -1,31 +0,0 @@ -path = $path; - } - - public function doHandleRequest(RequestInterface $request, callable $next, callable $first): Promise - { - $currentPath = $request->getUri()->getPath(); - if (! str_starts_with($currentPath, $this->path)) { - $uri = $request->getUri()->withPath($this->path . $currentPath); - $request = $request->withUri($uri); - } - - return $next($request); - } -} diff --git a/src/Requests/GraphQLRequest.php b/src/Requests/GraphQLRequest.php new file mode 100644 index 0000000..eceb92a --- /dev/null +++ b/src/Requests/GraphQLRequest.php @@ -0,0 +1,54 @@ + $variables */ + public function __construct( + protected string $graphQuery, + protected array $variables = [], + ) { + } + + /** @return array */ + protected function defaultBody(): array + { + return [ + 'query' => $this->graphQuery, + ...($this->variables !== [] ? ['variables' => $this->variables] : []), + ]; + } + + public function resolveEndpoint(): string + { + return '/graphql'; + } + + /** {@inheritdoc} */ + protected function defaultHeaders(): array + { + return [ + 'Content-Type' => 'application/json', + ]; + } + + /** @return GraphQL> */ + public function createDtoFromResponse(Response $response): GraphQL + { + return GraphQL::fromResponse($response); + } +} diff --git a/src/Api/GraphQL.php b/src/Resources/GraphQLResource.php similarity index 54% rename from src/Api/GraphQL.php rename to src/Resources/GraphQLResource.php index bceb9f4..06db8a8 100644 --- a/src/Api/GraphQL.php +++ b/src/Resources/GraphQLResource.php @@ -2,31 +2,33 @@ declare(strict_types=1); -namespace Worksome\Sdk\Api; +namespace Worksome\Sdk\Resources; -use Worksome\Sdk\Exception\InvalidArgumentException; +use Saloon\Http\BaseResource; +use Worksome\Sdk\DTOs\GraphQL; +use Worksome\Sdk\Exceptions\InvalidArgumentException; +use Worksome\Sdk\Requests\GraphQLRequest; -class GraphQL extends AbstractApi +class GraphQLResource extends BaseResource { /** * @param array $variables * - * @return array|string + * @return GraphQL> */ - public function execute(string $query, array $variables = []): array|string + public function execute(string $query, array $variables = []): GraphQL { - return $this->post('/graphql', [ - 'query' => $query, - 'variables' => $variables, - ]); + $request = new GraphQLRequest($query, $variables); + + return $this->connector->send($request)->dto(); // @phpstan-ignore return.type } /** * @param array $variables * - * @return array|string + * @return GraphQL> */ - public function fromFile(string $file, array $variables = []): array|string + public function fromFile(string $file, array $variables = []): GraphQL { if (! file_exists($file) || ! is_readable($file)) { throw new InvalidArgumentException('The provided file does not exist or is unreadable.'); diff --git a/src/Resources/ViewerResource.php b/src/Resources/ViewerResource.php new file mode 100644 index 0000000..d2c1366 --- /dev/null +++ b/src/Resources/ViewerResource.php @@ -0,0 +1,87 @@ + $response */ + $response = $this->connector->graph()->execute( + <<<'GQL' + { + viewer { + id + } + } + GQL + ); + + return $response->data['viewer']['id'] ?? throw new InvalidResponseException($response); + } + + /** @return list */ + public function accounts(): array + { + /** @var GraphQL}> $response */ + $response = $this->connector->graph()->execute( + <<<'GQL' + { + accounts { + id + name + } + } + GQL + ); + + return $response->data['accounts'] ?? throw new InvalidResponseException($response); + } + + /** @return array{id: string, email: string} */ + public function changeEmail(string $email): array + { + /** @var GraphQL $response */ + $response = $this->connector->graph()->execute( + <<<'GQL' + mutation changeEmail($email: String!) { + changeEmail(input: {email: $email}) { + id + email + } + } + GQL, + [ + 'email' => $email, + ] + ); + + return $response->data['changeEmail'] ?? throw new InvalidResponseException($response); + } + + /** @return array{id: string, email: string} */ + public function sendVerificationEmail(): array + { + /** @var GraphQL $response */ + $response = $this->connector->graph()->execute( + <<<'GQL' + mutation sendVerificationEmail { + sendVerificationEmail { + id + email + } + } + GQL, + ); + + return $response->data['sendVerificationEmail'] ?? throw new InvalidResponseException($response); + } +} diff --git a/src/Worksome.php b/src/Worksome.php new file mode 100644 index 0000000..45203dc --- /dev/null +++ b/src/Worksome.php @@ -0,0 +1,66 @@ +graph(); + } + + public function viewer(): ViewerResource + { + return new ViewerResource($this); + } + + public function resolveBaseUrl(): string + { + return $this->baseUri; + } + + protected function defaultAuth(): TokenAuthenticator + { + return new TokenAuthenticator($this->apiToken); + } + + protected function defaultHeaders(): array + { + return [ + 'User-Agent' => 'worksome-sdk (https://github.com/worksome/sdk-php)', + ]; + } + + protected function defaultConfig(): array + { + return [ + 'timeout' => $this->timeoutInSeconds, + ]; + } +} diff --git a/tests/Api/GraphQLTest.php b/tests/Api/GraphQLTest.php deleted file mode 100644 index f88939a..0000000 --- a/tests/Api/GraphQLTest.php +++ /dev/null @@ -1,26 +0,0 @@ - $this->apiClass = GraphQL::class); - -it('can query via the Graph API', function () { - $query = <<<'GQL' -query { - profile { - id - email - } -} -GQL; - /** @var GraphQL $api */ - $api = $this->getApi(); - - expect($api->execute($query)) - ->data->toBeArray() - ->data->profile->toBeArray() - ->data->profile->id->toBe('VXNlcjox') - ->data->profile->email->toBe('admin@worksome.test'); -}); diff --git a/tests/Api/ViewerTest.php b/tests/Api/ViewerTest.php deleted file mode 100644 index 2261009..0000000 --- a/tests/Api/ViewerTest.php +++ /dev/null @@ -1,46 +0,0 @@ - $this->apiClass = Viewer::class); - -it('can query the id for a viewer', function () { - /** @var Viewer $api */ - $api = $this->getApi(); - - expect($api->id())->toBe('VXNlcjox'); -}); - -it('can query the accounts for a viewer', function () { - /** @var Viewer $api */ - $api = $this->getApi(); - - expect($api->accounts())->toBe([ - [ - 'id' => 'Q29tcGFueTox', - 'name' => 'Test Company', - ], - ]); -}); - -it('can change email for the viewer', function () { - /** @var Viewer $api */ - $api = $this->getApi(); - - expect($api->changeEmail('test@test.com'))->toBe([ - 'id' => 'VXNlcjox', - 'email' => 'test@test.com', - ]); -}); - -it('can send a verification email for the viewer', function () { - /** @var Viewer $api */ - $api = $this->getApi(); - - expect($api->sendVerificationEmail())->toBe([ - 'id' => 'VXNlcjox', - 'email' => 'test@test.com', - ]); -}); diff --git a/tests/ArchTest.php b/tests/ArchTest.php new file mode 100644 index 0000000..5ee48a0 --- /dev/null +++ b/tests/ArchTest.php @@ -0,0 +1,28 @@ +expect(['dd', 'dump', 'var_dump', 'print_r', 'var_export', 'die', 'exit']) + ->not->toBeUsed(); + +arch('Enums') + ->expect('Worksome\Sdk\Enums') + ->toBeEnums(); + +arch('Exceptions') + ->expect('Worksome\Sdk\Exceptions') + ->classes + ->toExtend(Exception::class) + ->toImplement(WorksomeException::class) + ->toHaveSuffix('Exception'); + +arch('Requests') + ->expect('Worksome\Sdk\Requests') + ->toExtend('Saloon\Http\Request'); + +arch('Resources') + ->expect('Worksome\Sdk\Resources') + ->toExtend('Saloon\Http\BaseResource'); diff --git a/tests/ClientTest.php b/tests/ClientTest.php deleted file mode 100644 index 0c1a771..0000000 --- a/tests/ClientTest.php +++ /dev/null @@ -1,16 +0,0 @@ -graph())->toBeInstanceOf(GraphQL::class) - ->and($client->graphql())->toBeInstanceOf(GraphQL::class) - ->and($client->viewer())->toBeInstanceOf(Viewer::class); -}); diff --git a/tests/Feature/Resources/GraphQLResourceTest.php b/tests/Feature/Resources/GraphQLResourceTest.php new file mode 100644 index 0000000..6a10607 --- /dev/null +++ b/tests/Feature/Resources/GraphQLResourceTest.php @@ -0,0 +1,43 @@ +worksome = worksomeMock(); +}); + +it('can query via the Graph API', function () { + MockClient::global([ + GraphQLRequest::class => MockResponse::fixture('graphql-profile'), + ]); + + $query = <<<'GQL' +query { + profile { + id + email + } +} +GQL; + expect($this->worksome->graph()->execute($query)) + ->data->toBeArray() + ->data->profile->toBeArray() + ->data->profile->id->toBe('VXNlcjox') + ->data->profile->email->toBe('admin@worksome.test'); +}); + +it('can handle GraphQL error from invalid query', function () { + MockClient::global([ + GraphQLRequest::class => MockResponse::fixture('graphql-error'), + ]); + + $query = <<<'GQL' +query {} +GQL; + $this->worksome->graph()->execute($query); +})->throws(ClientException::class); diff --git a/tests/Feature/Resources/ViewerResourceTest.php b/tests/Feature/Resources/ViewerResourceTest.php new file mode 100644 index 0000000..0e77805 --- /dev/null +++ b/tests/Feature/Resources/ViewerResourceTest.php @@ -0,0 +1,55 @@ +worksome = worksomeMock(); +}); + +it('can query the id for a viewer', function () { + MockClient::global([ + GraphQLRequest::class => MockResponse::fixture('viewer-id'), + ]); + + expect($this->worksome->viewer()->id()) + ->toBe('VXNlcjox'); +}); + +it('can query the accounts for a viewer', function () { + MockClient::global([ + GraphQLRequest::class => MockResponse::fixture('viewer-accounts'), + ]); + + expect($this->worksome->viewer()->accounts())->toBe([ + [ + 'id' => 'Q29tcGFueTox', + 'name' => 'Test Company', + ], + ]); +}); + +it('can change email for the viewer', function () { + MockClient::global([ + GraphQLRequest::class => MockResponse::fixture('viewer-change-email'), + ]); + + expect($this->worksome->viewer()->changeEmail('test@test.com'))->toBe([ + 'id' => 'VXNlcjox', + 'email' => 'test@test.com', + ]); +}); + +it('can send a verification email for the viewer', function () { + MockClient::global([ + GraphQLRequest::class => MockResponse::fixture('viewer-send-verification-email'), + ]); + + expect($this->worksome->viewer()->sendVerificationEmail())->toBe([ + 'id' => 'VXNlcjox', + 'email' => 'test@test.com', + ]); +}); diff --git a/tests/Feature/WorksomeTest.php b/tests/Feature/WorksomeTest.php new file mode 100644 index 0000000..9349a93 --- /dev/null +++ b/tests/Feature/WorksomeTest.php @@ -0,0 +1,16 @@ +graph())->toBeInstanceOf(GraphQLResource::class) + ->and($client->graphql())->toBeInstanceOf(GraphQLResource::class) + ->and($client->viewer())->toBeInstanceOf(ViewerResource::class); +}); diff --git a/tests/Fixtures/Saloon/graphql-error.json b/tests/Fixtures/Saloon/graphql-error.json new file mode 100644 index 0000000..4fc9233 --- /dev/null +++ b/tests/Fixtures/Saloon/graphql-error.json @@ -0,0 +1,22 @@ +{ + "statusCode": 400, + "headers": { + "Date": "Mon, 11 Aug 2025 12:53:23 GMT", + "Content-Type": "application\/json; charset=utf-8", + "Content-Length": "150", + "Connection": "keep-alive", + "CF-RAY": "96d7e024ce23be35-CPH", + "x-powered-by": "Express", + "access-control-allow-origin": "*", + "Cache-Control": "no-store", + "etag": "W\/\"96-jdC342QGOtYpiulyP9jjHSGcDlA\"", + "strict-transport-security": "max-age=31536000; includeSubDomains", + "cf-cache-status": "DYNAMIC", + "Report-To": "{\"endpoints\":[{\"url\":\"https:\\\/\\\/a.nel.cloudflare.com\\\/report\\\/v4?s=P2xHPfufJ4776PtHpfnr71OK9nViVXUKXmarhUux%2B5hndgNU%2Bo22PAnupC3zpeuTe28tAkKSSCEsm8iOMBnSkVWF7vsTSDMpClZ9vOiCAGQ6%2F4TEOe5In1GFCiHKBF9HrbA%3D\"}],\"group\":\"cf-nel\",\"max_age\":604800}", + "NEL": "{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}", + "Server": "cloudflare", + "server-timing": "cfL4;desc=\"?proto=TCP&rtt=4853&min_rtt=4684&rtt_var=1877&sent=5&recv=7&lost=0&retrans=0&sent_bytes=3531&recv_bytes=2951&delivery_rate=927412&cwnd=252&unsent_bytes=0&cid=a0e4a49c3d189dc7&ts=144&x=0\"" + }, + "data": "{\"errors\":[{\"message\":\"Syntax Error: Expected Name, found \\\"}\\\".\",\"locations\":[{\"line\":1,\"column\":8}],\"extensions\":{\"code\":\"GRAPHQL_PARSE_FAILED\"}}]}\n", + "context": [] +} \ No newline at end of file diff --git a/tests/Fixtures/Saloon/graphql-profile.json b/tests/Fixtures/Saloon/graphql-profile.json new file mode 100644 index 0000000..f0b02fc --- /dev/null +++ b/tests/Fixtures/Saloon/graphql-profile.json @@ -0,0 +1,16 @@ +{ + "statusCode": 200, + "headers": { + "Server": "nginx\/1.29.0", + "Date": "Mon, 11 Aug 2025 12:17:04 GMT", + "Content-Type": "application\/json; charset=utf-8", + "Content-Length": "68", + "Connection": "keep-alive", + "X-Powered-By": "Express", + "Access-Control-Allow-Origin": "*", + "cache-control": "no-store", + "ETag": "W\/\"44-X6YNyDz7BwZ5hZ9BnAdh62VjDdQ\"" + }, + "data": "{\"data\":{\"profile\":{\"id\":\"VXNlcjox\",\"email\":\"admin@worksome.test\"}}}\n", + "context": [] +} diff --git a/tests/Fixtures/Saloon/viewer-accounts.json b/tests/Fixtures/Saloon/viewer-accounts.json new file mode 100644 index 0000000..e8b8777 --- /dev/null +++ b/tests/Fixtures/Saloon/viewer-accounts.json @@ -0,0 +1,16 @@ +{ + "statusCode": 200, + "headers": { + "Server": "nginx\/1.29.0", + "Date": "Mon, 11 Aug 2025 12:17:08 GMT", + "Content-Type": "application\/json; charset=utf-8", + "Content-Length": "66", + "Connection": "keep-alive", + "X-Powered-By": "Express", + "Access-Control-Allow-Origin": "*", + "cache-control": "no-store", + "ETag": "W\/\"42-d0juAqL\/pQyIVVSKTUbgEuG8KGM\"" + }, + "data": "{\"data\":{\"accounts\":[{\"id\":\"Q29tcGFueTox\",\"name\":\"Test Company\"}]}}\n", + "context": [] +} diff --git a/tests/Fixtures/Saloon/viewer-change-email.json b/tests/Fixtures/Saloon/viewer-change-email.json new file mode 100644 index 0000000..b0471d7 --- /dev/null +++ b/tests/Fixtures/Saloon/viewer-change-email.json @@ -0,0 +1,16 @@ +{ + "statusCode": 200, + "headers": { + "Server": "nginx\/1.29.0", + "Date": "Mon, 11 Aug 2025 12:18:00 GMT", + "Content-Type": "application\/json; charset=utf-8", + "Content-Length": "67", + "Connection": "keep-alive", + "X-Powered-By": "Express", + "Access-Control-Allow-Origin": "*", + "cache-control": "no-store", + "ETag": "W\/\"43-ua1czynL0MKbHDQYKnET68x0st8\"" + }, + "data": "{\"data\":{\"changeEmail\":{\"id\":\"VXNlcjox\",\"email\":\"test@test.com\"}}}\n", + "context": [] +} \ No newline at end of file diff --git a/tests/Fixtures/Saloon/viewer-id.json b/tests/Fixtures/Saloon/viewer-id.json new file mode 100644 index 0000000..8086eec --- /dev/null +++ b/tests/Fixtures/Saloon/viewer-id.json @@ -0,0 +1,16 @@ +{ + "statusCode": 200, + "headers": { + "Server": "nginx\/1.29.0", + "Date": "Mon, 11 Aug 2025 12:17:06 GMT", + "Content-Type": "application\/json; charset=utf-8", + "Content-Length": "38", + "Connection": "keep-alive", + "X-Powered-By": "Express", + "Access-Control-Allow-Origin": "*", + "cache-control": "no-store", + "ETag": "W\/\"26-8oL\/XpG\/QVlBX8URydjfpAtsOMU\"" + }, + "data": "{\"data\":{\"viewer\":{\"id\":\"VXNlcjox\"}}}\n", + "context": [] +} \ No newline at end of file diff --git a/tests/Fixtures/Saloon/viewer-send-verification-email.json b/tests/Fixtures/Saloon/viewer-send-verification-email.json new file mode 100644 index 0000000..973276d --- /dev/null +++ b/tests/Fixtures/Saloon/viewer-send-verification-email.json @@ -0,0 +1,16 @@ +{ + "statusCode": 200, + "headers": { + "Server": "nginx\/1.29.0", + "Date": "Mon, 11 Aug 2025 12:18:00 GMT", + "Content-Type": "application\/json; charset=utf-8", + "Content-Length": "77", + "Connection": "keep-alive", + "X-Powered-By": "Express", + "Access-Control-Allow-Origin": "*", + "cache-control": "no-store", + "ETag": "W\/\"4d-WSIQjlQOPROlkpJH23+qj9s+B90\"" + }, + "data": "{\"data\":{\"sendVerificationEmail\":{\"id\":\"VXNlcjox\",\"email\":\"test@test.com\"}}}\n", + "context": [] +} \ No newline at end of file diff --git a/tests/Pest.php b/tests/Pest.php index e6fff24..0edeb8c 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,6 +2,15 @@ declare(strict_types=1); -use Worksome\Sdk\Tests\TestCase; +use Saloon\Http\Faking\MockClient; +use Worksome\Sdk\Worksome; -uses(TestCase::class)->in('Api'); +function worksomeMock(): Worksome +{ + MockClient::destroyGlobal(); + + $token = $_SERVER['WORKSOME_API_TOKEN'] ?? 'fake-token'; + $baseUri = $_SERVER['WORKSOME_BASE_URI'] ?? 'https://api.worksome.com'; + + return new Worksome($token, $baseUri); +} diff --git a/tests/TestCase.php b/tests/TestCase.php deleted file mode 100644 index 49c9be1..0000000 --- a/tests/TestCase.php +++ /dev/null @@ -1,45 +0,0 @@ - */ - protected string $apiClass; - - protected function getApi(): AbstractApi - { - $namingStrategy = new PathNamingStrategy(); - $recorder = new FilesystemRecorder(__DIR__ . '/__SNAPSHOTS__'); - - $httpBuilder = new Builder(); - $httpBuilder->addPlugin( - in_array('--update-snapshots', $_SERVER['argv']) || getenv('UPDATE_SNAPSHOTS') === 'true' ? - new RecordPlugin($namingStrategy, $recorder) : - new ReplayPlugin($namingStrategy, $recorder) - ); - - $client = new Client($httpBuilder); - - $httpBuilder->removePlugin(AddHostPlugin::class); - $httpBuilder->addPlugin( - new AddHostPlugin(Psr17FactoryDiscovery::findUriFactory()->createUri( - (string) (getenv('WORKSOME_LOCAL_API_URL') ?: 'http://localhost:3000') - )) - ); - - $client->authenticate((string) getenv('WORKSOME_LOCAL_API_TOKEN')); - - return new ($this->apiClass)($client); - } -} diff --git a/tests/__SNAPSHOTS__/POST_graphql_12d55.txt b/tests/__SNAPSHOTS__/POST_graphql_12d55.txt deleted file mode 100644 index 88aae13..0000000 --- a/tests/__SNAPSHOTS__/POST_graphql_12d55.txt +++ /dev/null @@ -1,11 +0,0 @@ -HTTP/1.1 200 OK -X-Powered-By: Express -Access-Control-Allow-Origin: * -Content-Type: application/json; charset=utf-8 -Content-Length: 69 -ETag: W/"45-jtmvHVUaBHcMBAAbduL9fRj6mpk" -Date: Fri, 25 Nov 2022 10:43:51 GMT -Connection: keep-alive -Keep-Alive: timeout=5 - -{"data":{"accounts":[{"id":"Q29tcGFueTox","name":"Test Company"}]}} diff --git a/tests/__SNAPSHOTS__/POST_graphql_732d7.txt b/tests/__SNAPSHOTS__/POST_graphql_732d7.txt deleted file mode 100644 index 07c1a94..0000000 --- a/tests/__SNAPSHOTS__/POST_graphql_732d7.txt +++ /dev/null @@ -1,11 +0,0 @@ -HTTP/1.1 200 OK -X-Powered-By: Express -Access-Control-Allow-Origin: * -Content-Type: application/json; charset=utf-8 -Content-Length: 68 -ETag: W/"44-831/hEioQpMr08IyonvlvfKwe8w" -Date: Thu, 15 Dec 2022 12:29:23 GMT -Connection: keep-alive -Keep-Alive: timeout=5 - -{"data":{"changeEmail":{"id":"VXNlcjox","email":"test@test.com"}}} diff --git a/tests/__SNAPSHOTS__/POST_graphql_8e5ab.txt b/tests/__SNAPSHOTS__/POST_graphql_8e5ab.txt deleted file mode 100644 index 678fede..0000000 --- a/tests/__SNAPSHOTS__/POST_graphql_8e5ab.txt +++ /dev/null @@ -1,11 +0,0 @@ -HTTP/1.1 200 OK -X-Powered-By: Express -Access-Control-Allow-Origin: * -Content-Type: application/json; charset=utf-8 -Content-Length: 69 -ETag: W/"45-ugKVV8pFBJmQZ4qi+q7Bq0TmxS0" -Date: Fri, 14 Oct 2022 10:06:03 GMT -Connection: keep-alive -Keep-Alive: timeout=5 - -{"data":{"profile":{"id":"VXNlcjox","email":"admin@worksome.test"}}} diff --git a/tests/__SNAPSHOTS__/POST_graphql_b849d.txt b/tests/__SNAPSHOTS__/POST_graphql_b849d.txt deleted file mode 100644 index 8c12c69..0000000 --- a/tests/__SNAPSHOTS__/POST_graphql_b849d.txt +++ /dev/null @@ -1,11 +0,0 @@ -HTTP/1.1 200 OK -X-Powered-By: Express -Access-Control-Allow-Origin: * -Content-Type: application/json; charset=utf-8 -Content-Length: 38 -ETag: W/"26-8oL/XpG/QVlBX8URydjfpAtsOMU" -Date: Fri, 25 Nov 2022 10:43:51 GMT -Connection: keep-alive -Keep-Alive: timeout=5 - -{"data":{"viewer":{"id":"VXNlcjox"}}} diff --git a/tests/__SNAPSHOTS__/POST_graphql_e5f7a.txt b/tests/__SNAPSHOTS__/POST_graphql_e5f7a.txt deleted file mode 100644 index 82acb4c..0000000 --- a/tests/__SNAPSHOTS__/POST_graphql_e5f7a.txt +++ /dev/null @@ -1,11 +0,0 @@ -HTTP/1.1 200 OK -X-Powered-By: Express -Access-Control-Allow-Origin: * -Content-Type: application/json; charset=utf-8 -Content-Length: 78 -ETag: W/"4e-0z56ImZjKVO/v83vAij9NDZFuhY" -Date: Thu, 15 Dec 2022 12:30:26 GMT -Connection: keep-alive -Keep-Alive: timeout=5 - -{"data":{"sendVerificationEmail":{"id":"VXNlcjox","email":"test@test.com"}}}