diff --git a/.gitignore b/.gitignore index 81cea5e..64f647e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ .env /.vscode report_coverage.xml -/storage \ No newline at end of file +/storage +.idea \ No newline at end of file diff --git a/src/Dtos/GraphQLQueryBuilder.php b/src/Dtos/GraphQLQueryBuilder.php new file mode 100644 index 0000000..15a37cc --- /dev/null +++ b/src/Dtos/GraphQLQueryBuilder.php @@ -0,0 +1,111 @@ + + * @license https://mit-license.org/ MIT License + * @version GIT: 0.0.4 + * @link https://github.com/spotlibs + */ + +declare(strict_types=1); + +namespace Spotlibs\PhpLib\Dtos; + +use Spotlibs\PhpLib\Exceptions\ParameterException; + +/** + * GraphQLQueryBuilder + * + * @category Library + * @package Dtos + * @author Mufthi Ryanda + * @license https://mit-license.org/ MIT License + * @link https://github.com/spotlibs + */ + +trait GraphQLQueryBuilder +{ + private array $selectedFields = []; + + /** + * Get available query fields + * + * @return array Array of available field names + */ + abstract protected function getQueryFields(): array; + + /** + * Get the GraphQL query name + * + * @return string The query name + */ + abstract protected function getQueryName(): string; + + /** + * Get the GraphQL operation name + * + * @return string The operation name + */ + abstract protected function getOperationName(): string; + + /** + * Select specific fields for the GraphQL query + * + * @param string ...$fields The field names to select + * + * @return self Returns the current instance for method chaining + * + * @throws ParameterException When invalid fields are provided + */ + public function select(string ...$fields): self + { + $queryFields = $this->getQueryFields(); + $invalidFields = array_diff($fields, $queryFields); + + if (!empty($invalidFields)) { + throw new ParameterException( + 'Invalid field(s): ' . implode(', ', $invalidFields) . + '. Available fields: ' . implode(', ', $queryFields) + ); + } + + $this->selectedFields = array_merge($this->selectedFields, $fields); + return $this; + } + + /** + * Convert the query to a GraphQL query string + * + * @return string The formatted GraphQL query string + */ + public function toGraphQLQueryString(): string + { + $fields = empty($this->selectedFields) + ? $this->getSelectedPropertiesAsFields() + : $this->selectedFields; + + $indentedFields = array_map(fn($field) => " {$field}", $fields); + $fieldsList = implode("\n", $indentedFields); + + return sprintf( + "query %s {\n %s {\n%s\n }\n}", + $this->getQueryName(), + $this->getOperationName(), + $fieldsList + ); + } + + /** + * Get selected properties as GraphQL fields + * + * @return array Array of field names + */ + private function getSelectedPropertiesAsFields(): array + { + return $this->getQueryFields(); + } +} diff --git a/src/Libraries/GraphQLClient.php b/src/Libraries/GraphQLClient.php new file mode 100644 index 0000000..ce158c4 --- /dev/null +++ b/src/Libraries/GraphQLClient.php @@ -0,0 +1,208 @@ + + * @license https://mit-license.org/ MIT License + * @version GIT: 0.3.7 + * @link https://github.com/spotlibs + */ + +declare(strict_types=1); + +namespace Spotlibs\PhpLib\Libraries; + +use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\Psr7\Request; +use Psr\Http\Message\ResponseInterface; +use Spotlibs\PhpLib\Logs\Log; + +/** + * GraphQLClient + * + * Name for GraphQLClient + * + * @category HttpClient + * @package Client + * @author Mufthi Ryanda + * @license https://mit-license.org/ MIT License + * @link https://github.com/spotlibs + */ + +class GraphQLClient +{ + /** + * HTTP client for making requests + * + * @var GuzzleClient $httpClient + */ + private GuzzleClient $httpClient; + + /** + * GraphQL endpoint URL + * + * @var string $endpoint + */ + private string $endpoint; + + /** + * Request headers + * + * @var array $headers + */ + private array $headers = []; + + /** + * Basic auth credentials + * + * @var array $basicAuth + */ + private array $basicAuth = []; + + /** + * Create a new GraphQLClient instance. + * + * @param string $endpoint GraphQL endpoint URL + * @param array $config Additional GuzzleHttp client configuration + * + * @return void + */ + public function __construct(string $endpoint, array $config = []) + { + $this->endpoint = $endpoint; + + $defaultConfig = [ + 'timeout' => 10, + 'verify' => false, + ]; + + $this->httpClient = new GuzzleClient(array_merge($defaultConfig, $config)); + + // Set default headers for GraphQL + $this->headers = [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; + } + + /** + * Set basic authentication credentials + * + * @param string $username Username for basic auth + * @param string $password Password for basic auth + * + * @return self + */ + public function setBasicAuth(string $username, string $password): self + { + $this->basicAuth = [$username, $password]; + return $this; + } + + /** + * Set JWT bearer token authentication + * + * @param string $token JWT token + * + * @return self + */ + public function setBearerToken(string $token): self + { + $this->headers['Authorization'] = 'Bearer ' . $token; + return $this; + } + + /** + * Set additional request headers + * + * @param array $headers Associative array of headers + * + * @return self + */ + public function setHeaders(array $headers): self + { + $this->headers = array_merge($this->headers, $headers); + return $this; + } + + /** + * Execute GraphQL query + * + * @param string $query GraphQL query string + * @param array|null $variables Optional variables for the query + * @param string|null $operationName Optional operation name + * + * @return ResponseInterface GraphQL response + */ + public function query(string $query, ?array $variables = null, ?string $operationName = null): ResponseInterface + { + $startTime = microtime(true); + + $body = ['query' => $query]; + + if ($variables !== null) { + $body['variables'] = $variables; + } + + if ($operationName !== null) { + $body['operationName'] = $operationName; + } + + $jsonBody = json_encode($body, JSON_THROW_ON_ERROR); + + $request = new Request('POST', $this->endpoint, $this->headers, $jsonBody); + + $options = []; + + // Add basic auth if set + if (!empty($this->basicAuth)) { + $options['auth'] = $this->basicAuth; + } + + $response = $this->httpClient->send($request, $options); + + $elapsed = microtime(true) - $startTime; + + if (env('APP_DEBUG', false)) { + $request->getBody()->rewind(); + $reqBody = $request->getBody()->getContents(); + $respBody = $response->getBody()->getContents(); + + if (strlen($reqBody) > 5000) { + $reqBody = "more than 5000 characters"; + } + if (strlen($respBody) > 5000) { + $respBody = "more than 5000 characters"; + } + + $logData = [ + 'host' => $request->getUri()->getHost(), + 'url' => $request->getUri()->getPath(), + 'graphql' => [ + 'query' => $query, + 'variables' => $variables, + 'operationName' => $operationName, + ], + 'request' => [ + 'method' => $request->getMethod(), + 'headers' => $request->getHeaders(), + 'body' => json_decode($reqBody, true), + ], + 'response' => [ + 'headers' => $response->getHeaders(), + 'body' => json_decode($respBody, true), + ], + 'responseTime' => round($elapsed * 1000), + 'memoryUsage' => memory_get_usage() + ]; + + $response->getBody()->rewind(); + Log::activity()->info($logData); + } + + return $response; + } +} diff --git a/src/Libraries/Security.php b/src/Libraries/Security.php index 7fdc5df..313bb05 100644 --- a/src/Libraries/Security.php +++ b/src/Libraries/Security.php @@ -52,7 +52,8 @@ public static function encrypt(string $plaintext): string if (!$ecrypted) { throw new \Exception("failed to encrypt string"); } - return bin2hex($iv . $ecrypted); + + return strtoupper(bin2hex($iv . $ecrypted)); } /** @@ -65,7 +66,6 @@ public static function encrypt(string $plaintext): string */ public static function decrypt(string $encrypted): string { - $ivHex = substr($encrypted, 0, 32); $iv = hex2bin($ivHex); $encrypted = substr($encrypted, 32); diff --git a/src/Middlewares/ActivityMonitor.php b/src/Middlewares/ActivityMonitor.php index 067ac1b..d710221 100644 --- a/src/Middlewares/ActivityMonitor.php +++ b/src/Middlewares/ActivityMonitor.php @@ -58,6 +58,9 @@ public function __construct(Context $contextService) */ public function handle($request, Closure $next) { + if ($request->path() == "ping") { + return $next($request); + } $meta = new Metadata(); $meta->api_key = $request->header('X-Api-Key'); $meta->app = $request->header('X-App'); @@ -107,6 +110,9 @@ public function handle($request, Closure $next) */ public function terminate($request, $response) { + if ($request->path() == "ping") { + return; + } $fileData = (array) $this->contextService->get('fileData'); $log = new StdClass(); $log->app_name = getenv('APP_NAME'); diff --git a/tests/Dtos/GraphQLQueryBuilderTest.php b/tests/Dtos/GraphQLQueryBuilderTest.php new file mode 100644 index 0000000..6ea0f8d --- /dev/null +++ b/tests/Dtos/GraphQLQueryBuilderTest.php @@ -0,0 +1,96 @@ +select('name', 'email'); + + $this->assertEquals($query, $result); + $this->assertEquals(['name', 'email'], $query->getSelectedFields()); + } + + /** @test */ + /** @runInSeparateProcess */ + public function testSelectMultipleCallsAppendFields() + { + $query = new MockGraphQLQuery(); + $query->select('name') + ->select('email', 'age'); + + $this->assertEquals(['name', 'email', 'age'], $query->getSelectedFields()); + } + + /** @test */ + /** @runInSeparateProcess */ + public function testSelectInvalidFieldsThrowsException() + { + $this->expectException(ParameterException::class); + $this->expectExceptionMessage('Invalid field(s): invalid, unknown. Available fields: name, email, age, active'); + + $query = new MockGraphQLQuery(); + $query->select('name', 'invalid', 'unknown'); + } + + /** @test */ + /** @runInSeparateProcess */ + public function testToGraphQLQueryStringWithSelectedFields() + { + $query = new MockGraphQLQuery(); + $query->select('name', 'email'); + $result = $query->toGraphQLQueryString(); + + $expected = "query TestQuery {\n testOperation {\n name\n email\n }\n}"; + $this->assertEquals($expected, $result); + } + + /** @test */ + /** @runInSeparateProcess */ + public function testToGraphQLQueryStringWithoutSelectedFields() + { + $query = new MockGraphQLQuery(); + $result = $query->toGraphQLQueryString(); + + $expected = "query TestQuery {\n testOperation {\n name\n email\n age\n active\n }\n}"; + $this->assertEquals($expected, $result); + } + + /** @test */ + /** @runInSeparateProcess */ + public function testToGraphQLQueryStringFormat() + { + $query = new MockGraphQLQuery(); + $query->select('name'); + $result = $query->toGraphQLQueryString(); + + $this->assertStringContainsString('query TestQuery', $result); + $this->assertStringContainsString('testOperation', $result); + $this->assertStringContainsString(' name', $result); + } + + /** @test */ + /** @runInSeparateProcess */ + public function testSelectEmptyFieldsArray() + { + $query = new MockGraphQLQuery(); + $result = $query->select(); + + $this->assertEquals($query, $result); + $this->assertEquals([], $query->getSelectedFields()); + } +} \ No newline at end of file diff --git a/tests/Dtos/MockGraphQLQuery.php b/tests/Dtos/MockGraphQLQuery.php new file mode 100644 index 0000000..22c51a4 --- /dev/null +++ b/tests/Dtos/MockGraphQLQuery.php @@ -0,0 +1,32 @@ +selectedFields; + } +} \ No newline at end of file diff --git a/tests/Libraries/GraphQLClientTest.php b/tests/Libraries/GraphQLClientTest.php new file mode 100644 index 0000000..e627e2d --- /dev/null +++ b/tests/Libraries/GraphQLClientTest.php @@ -0,0 +1,294 @@ + 'application/json'], json_encode([ + 'data' => ['user' => ['id' => '1', 'name' => 'John Doe']] + ])), + ]); + $handlerStack = new HandlerStack($mock); + + $client = new GraphQLClient('https://api.example.com/graphql', ['handler' => $handlerStack]); + $response = $client->query('{ user(id: "1") { id name } }'); + + $contents = json_decode($response->getBody()->getContents(), true); + $this->assertEquals('John Doe', $contents['data']['user']['name']); + } + + public function testQueryWithVariables(): void + { + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'data' => ['user' => ['id' => '123', 'email' => 'test@example.com']] + ])), + ]); + $handlerStack = new HandlerStack($mock); + + $client = new GraphQLClient('https://api.example.com/graphql', ['handler' => $handlerStack]); + $response = $client->query( + 'query GetUser($id: ID!) { user(id: $id) { id email } }', + ['id' => '123'] + ); + + $contents = json_decode($response->getBody()->getContents(), true); + $this->assertEquals('test@example.com', $contents['data']['user']['email']); + } + + public function testQueryWithOperationName(): void + { + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'data' => ['posts' => [['id' => '1', 'title' => 'Test Post']]] + ])), + ]); + $handlerStack = new HandlerStack($mock); + + $client = new GraphQLClient('https://api.example.com/graphql', ['handler' => $handlerStack]); + $response = $client->query( + 'query GetPosts { posts { id title } } query GetUsers { users { id name } }', + null, + 'GetPosts' + ); + + $contents = json_decode($response->getBody()->getContents(), true); + $this->assertEquals('Test Post', $contents['data']['posts'][0]['title']); + } + + public function testQueryWithVariablesAndOperationName(): void + { + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'data' => ['createUser' => ['id' => '456', 'name' => 'Jane Doe']] + ])), + ]); + $handlerStack = new HandlerStack($mock); + + $client = new GraphQLClient('https://api.example.com/graphql', ['handler' => $handlerStack]); + $response = $client->query( + 'mutation CreateUser($input: UserInput!) { createUser(input: $input) { id name } }', + ['input' => ['name' => 'Jane Doe', 'email' => 'jane@example.com']], + 'CreateUser' + ); + + $contents = json_decode($response->getBody()->getContents(), true); + $this->assertEquals('Jane Doe', $contents['data']['createUser']['name']); + } + + public function testBasicAuth(): void + { + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'data' => ['authenticated' => true] + ])), + ]); + $handlerStack = new HandlerStack($mock); + + $client = new GraphQLClient('https://api.example.com/graphql', ['handler' => $handlerStack]); + $client->setBasicAuth('username', 'password'); + $response = $client->query('{ authenticated }'); + + $contents = json_decode($response->getBody()->getContents(), true); + $this->assertTrue($contents['data']['authenticated']); + } + + public function testBearerToken(): void + { + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'data' => ['user' => ['role' => 'admin']] + ])), + ]); + $handlerStack = new HandlerStack($mock); + + $client = new GraphQLClient('https://api.example.com/graphql', ['handler' => $handlerStack]); + $client->setBearerToken('jwt-token-here'); + $response = $client->query('{ user { role } }'); + + $contents = json_decode($response->getBody()->getContents(), true); + $this->assertEquals('admin', $contents['data']['user']['role']); + } + + public function testCustomHeaders(): void + { + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'data' => ['success' => true] + ])), + ]); + $handlerStack = new HandlerStack($mock); + + $client = new GraphQLClient('https://api.example.com/graphql', ['handler' => $handlerStack]); + $client->setHeaders(['X-Custom-Header' => 'custom-value', 'X-API-Version' => '2.0']); + $response = $client->query('{ success }'); + + $contents = json_decode($response->getBody()->getContents(), true); + $this->assertTrue($contents['data']['success']); + } + + public function testConnectionError(): void + { + $this->expectException(GuzzleException::class); + + $request = new Request('POST', 'https://api.example.com/graphql'); + $mock = new MockHandler([ + new ConnectException('Connection failed', $request) + ]); + $handlerStack = new HandlerStack($mock); + + $client = new GraphQLClient('https://api.example.com/graphql', ['handler' => $handlerStack]); + $client->query('{ user { id } }'); + } + + public function testHttpError(): void + { + $mock = new MockHandler([ + new Response(500, ['Content-Type' => 'application/json'], json_encode([ + 'errors' => [['message' => 'Internal server error']] + ])), + ]); + $handlerStack = new HandlerStack($mock); + + $client = new GraphQLClient('https://api.example.com/graphql', ['handler' => $handlerStack]); + $response = $client->query('{ user { id } }'); + + $this->assertEquals(500, $response->getStatusCode()); + $contents = json_decode($response->getBody()->getContents(), true); + $this->assertEquals('Internal server error', $contents['errors'][0]['message']); + } + + public function testGraphQLErrors(): void + { + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'data' => null, + 'errors' => [['message' => 'Field "nonexistent" not found']] + ])), + ]); + $handlerStack = new HandlerStack($mock); + + $client = new GraphQLClient('https://api.example.com/graphql', ['handler' => $handlerStack]); + $response = $client->query('{ nonexistent }'); + + $contents = json_decode($response->getBody()->getContents(), true); + $this->assertNull($contents['data']); + $this->assertEquals('Field "nonexistent" not found', $contents['errors'][0]['message']); + } + + public function testDebugLogging(): void + { + putenv('APP_DEBUG=true'); + + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'data' => ['user' => ['id' => '1', 'name' => 'Debug User']] + ])), + ]); + $handlerStack = new HandlerStack($mock); + + $client = new GraphQLClient('https://api.example.com/graphql', ['handler' => $handlerStack]); + $response = $client->query('{ user(id: "1") { id name } }', ['debug' => true]); + + $contents = json_decode($response->getBody()->getContents(), true); + $this->assertEquals('Debug User', $contents['data']['user']['name']); + + putenv('APP_DEBUG=false'); + } + + public function testLargeBodyTruncation(): void + { + putenv('APP_DEBUG=true'); + + // Create large response body (over 5000 characters) + $largeData = []; + for ($i = 0; $i < 20; $i++) { + $largeData["message$i"] = str_repeat('Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. ', 10); + } + + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'data' => $largeData + ])), + ]); + $handlerStack = new HandlerStack($mock); + + // Create large query variables + $largeVariables = []; + for ($i = 0; $i < 20; $i++) { + $largeVariables["input$i"] = str_repeat('This is a very long input string that will make the request body exceed 5000 characters. ', 20); + } + + $client = new GraphQLClient('https://api.example.com/graphql', ['handler' => $handlerStack]); + $response = $client->query( + 'mutation CreateLargeData($input: LargeInput!) { createData(input: $input) { success } }', + $largeVariables + ); + + $contents = json_decode($response->getBody()->getContents(), true); + $this->assertArrayHasKey('data', $contents); + + putenv('APP_DEBUG=false'); + } + + public function testCustomConfig(): void + { + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'data' => ['config' => 'custom'] + ])), + ]); + $handlerStack = new HandlerStack($mock); + + $client = new GraphQLClient('https://api.example.com/graphql', [ + 'handler' => $handlerStack, + 'timeout' => 30, + 'verify' => true, + 'connect_timeout' => 5 + ]); + + $response = $client->query('{ config }'); + + $contents = json_decode($response->getBody()->getContents(), true); + $this->assertEquals('custom', $contents['data']['config']); + } + + public function testChainedAuthentication(): void + { + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], json_encode([ + 'data' => ['authenticated' => true, 'role' => 'admin'] + ])), + ]); + $handlerStack = new HandlerStack($mock); + + $client = new GraphQLClient('https://api.example.com/graphql', ['handler' => $handlerStack]); + $response = $client + ->setBearerToken('jwt-token') + ->setHeaders(['X-API-Key' => 'api-key-123']) + ->query('{ authenticated role }'); + + $contents = json_decode($response->getBody()->getContents(), true); + $this->assertTrue($contents['data']['authenticated']); + $this->assertEquals('admin', $contents['data']['role']); + } +} \ No newline at end of file