-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #145 from City-of-Helsinki/UHF-9239-generic-api-ma…
…nager UHF-9239: Move ApiManager from helfi_navigation to helfi_api_base.
- Loading branch information
Showing
12 changed files
with
885 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
# API client | ||
|
||
Service for HTTP JSON APIs. | ||
|
||
Features: | ||
- Simple caching. | ||
- Optional response mocking for local environment. | ||
|
||
## Usage | ||
|
||
Create your own client service from abstract service `helfi_api_base.api_client_base`. You must provide your own logger. Optionally you can provide default request parameters. | ||
|
||
```yaml | ||
# my_module.services.yml | ||
my_module.my_api: | ||
parent: helfi_api_base.api_manager | ||
arguments: | ||
- '@logger.channel.my_module' | ||
# Optional: | ||
- { timeout: 30 } | ||
``` | ||
Actual requests are usually made in the callback of `cache()` method. The callback must return `CacheValue`. | ||
|
||
```php | ||
use Drupal\helfi_api_base\ApiClient\CacheValue; | ||
/** @var Drupal\helfi_api_base\ApiClient\ApiClient $client */ | ||
$client = \Drupal::service('my_module.my_api'); | ||
$response = $client->cache($id, fn () => new CacheValue( | ||
// Actual HTTP response. | ||
$client->makeRequest('GET', 'https://example.com/api/v1/foo'), | ||
// TTL. | ||
$client->cacheMaxAge(ttl: 180), | ||
// Custom cache tags. | ||
['user:1'] | ||
)); | ||
``` | ||
|
||
### Mocking | ||
|
||
In local environment, the `makeRequestWithFixture` method returns response from JSON file if the response fails. | ||
|
||
```php | ||
$client->makeRequestWithFixture('path-to-fixture.json', 'GET', 'https://example.com/fails-in-local'), | ||
``` | ||
|
||
*Warning*: The client fail any further requests to `makeRequestWithFixture` instantly after one failed requests. This is to prevent blocking the rendering process and cause the site to time-out. You should not share the client for different purposes that need fault tolerance. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,252 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Drupal\helfi_api_base\ApiClient; | ||
|
||
use Drupal\Component\Datetime\TimeInterface; | ||
use Drupal\Core\Cache\CacheBackendInterface; | ||
use Drupal\helfi_api_base\Environment\EnvironmentResolverInterface; | ||
use GuzzleHttp\ClientInterface; | ||
use GuzzleHttp\Exception\ClientException; | ||
use GuzzleHttp\Exception\ConnectException; | ||
use GuzzleHttp\Exception\GuzzleException; | ||
use GuzzleHttp\Exception\TransferException; | ||
use GuzzleHttp\Utils; | ||
use Psr\Log\LoggerInterface; | ||
|
||
/** | ||
* Fetch data from HTTP API. | ||
* | ||
* Provides simple caching and fixtures (for local environment). | ||
*/ | ||
class ApiClient { | ||
|
||
/** | ||
* Whether to bypass cache or not. | ||
* | ||
* @var bool | ||
*/ | ||
private bool $bypassCache = FALSE; | ||
|
||
/** | ||
* The previous exception. | ||
* | ||
* @var \Exception|null | ||
*/ | ||
private ?\Exception $previousException = NULL; | ||
|
||
/** | ||
* Construct an instance. | ||
* | ||
* @param \GuzzleHttp\ClientInterface $httpClient | ||
* The HTTP client. | ||
* @param \Drupal\Core\Cache\CacheBackendInterface $cache | ||
* The cache service. | ||
* @param \Drupal\Component\Datetime\TimeInterface $time | ||
* The time service. | ||
* @param \Drupal\helfi_api_base\Environment\EnvironmentResolverInterface $environmentResolver | ||
* The environment resolver. | ||
* @param \Psr\Log\LoggerInterface $logger | ||
* Logger channel. | ||
* @param array $defaultOptions | ||
* Default request options. | ||
*/ | ||
public function __construct( | ||
private readonly ClientInterface $httpClient, | ||
private readonly CacheBackendInterface $cache, | ||
protected readonly TimeInterface $time, | ||
protected readonly EnvironmentResolverInterface $environmentResolver, | ||
protected readonly LoggerInterface $logger, | ||
private readonly array $defaultOptions = [], | ||
) { | ||
} | ||
|
||
/** | ||
* Allow cache to be bypassed. | ||
* | ||
* @return $this | ||
* The self. | ||
*/ | ||
public function withBypassCache() : self { | ||
$instance = clone $this; | ||
$instance->bypassCache = TRUE; | ||
return $instance; | ||
} | ||
|
||
/** | ||
* Gets the default request options. | ||
* | ||
* @param string $environmentName | ||
* Environment name. | ||
* @param array $options | ||
* The optional options. | ||
* | ||
* @return array | ||
* The request options. | ||
*/ | ||
protected function getRequestOptions(string $environmentName, array $options = []) : array { | ||
// Hardcode cURL options. | ||
// Curl options are keyed by PHP constants so there is no easy way to | ||
// define them in yaml files yet. See: https://www.drupal.org/node/3403883 | ||
$default = $this->defaultOptions + [ | ||
'curl' => [CURLOPT_TCP_KEEPALIVE => TRUE], | ||
]; | ||
|
||
if ($environmentName === 'local') { | ||
// Disable SSL verification in local environment. | ||
$default['verify'] = FALSE; | ||
} | ||
|
||
return array_merge_recursive($options, $default); | ||
} | ||
|
||
/** | ||
* Makes HTTP request. | ||
* | ||
* @param string $method | ||
* Request method. | ||
* @param string $url | ||
* The endpoint in the instance. | ||
* @param array $options | ||
* Body for requests. | ||
* | ||
* @return \Drupal\helfi_api_base\ApiClient\ApiResponse | ||
* The JSON object. | ||
* | ||
* @throws \GuzzleHttp\Exception\GuzzleException | ||
*/ | ||
public function makeRequest( | ||
string $method, | ||
string $url, | ||
array $options = [], | ||
): ApiResponse { | ||
$activeEnvironmentName = $this->environmentResolver | ||
->getActiveEnvironment() | ||
->getEnvironmentName(); | ||
|
||
$options = $this->getRequestOptions($activeEnvironmentName, $options); | ||
|
||
$response = $this->httpClient->request($method, $url, $options); | ||
|
||
return new ApiResponse(Utils::jsonDecode($response->getBody()->getContents())); | ||
} | ||
|
||
/** | ||
* Makes HTTP request with fixture. | ||
* | ||
* @param string $fixture | ||
* File for mock data if requests fail in local environment. | ||
* @param string $method | ||
* Request method. | ||
* @param string $url | ||
* The endpoint in the instance. | ||
* @param array $options | ||
* Body for requests. | ||
* | ||
* @return \Drupal\helfi_api_base\ApiClient\ApiResponse | ||
* The JSON object. | ||
* | ||
* @throws \GuzzleHttp\Exception\GuzzleException | ||
*/ | ||
public function makeRequestWithFixture( | ||
string $fixture, | ||
string $method, | ||
string $url, | ||
array $options = [], | ||
): ApiResponse { | ||
try { | ||
if ($this->previousException instanceof \Exception) { | ||
// Fail any further request instantly after one failed request, so we | ||
// don't block the rendering process and cause the site to time-out. | ||
throw $this->previousException; | ||
} | ||
|
||
return $this->makeRequest($method, $url, $options); | ||
} | ||
catch (\Exception $e) { | ||
if ($e instanceof GuzzleException) { | ||
$this->previousException = $e; | ||
} | ||
|
||
$activeEnvironmentName = $this->environmentResolver | ||
->getActiveEnvironment() | ||
->getEnvironmentName(); | ||
|
||
// Serve mock data in local environments if requests fail. | ||
if ( | ||
($e instanceof ClientException || $e instanceof ConnectException) && | ||
$activeEnvironmentName === 'local' | ||
) { | ||
$this->logger->warning( | ||
sprintf('Request failed: %s. Mock data is used instead.', $e->getMessage()) | ||
); | ||
|
||
return ApiFixture::requestFromFile($fixture); | ||
} | ||
|
||
throw $e; | ||
} | ||
} | ||
|
||
/** | ||
* Gets the cached data for given response. | ||
* | ||
* @param string $key | ||
* The cache key. | ||
* @param callable $callback | ||
* The callback to handle requests. | ||
* | ||
* @return \Drupal\helfi_api_base\ApiClient\CacheValue|null | ||
* The cache or null. | ||
* | ||
* @throws \GuzzleHttp\Exception\GuzzleException | ||
*/ | ||
public function cache(string $key, callable $callback) : ?CacheValue { | ||
$exception = new TransferException(); | ||
$value = ($cache = $this->cache->get($key)) ? $cache->data : NULL; | ||
|
||
// Attempt to re-fetch the data in case cache does not exist, cache has | ||
// expired, or bypass cache is set to true. | ||
if ( | ||
($value instanceof CacheValue && $value->hasExpired($this->time->getRequestTime())) || | ||
$this->bypassCache || | ||
$value === NULL | ||
) { | ||
try { | ||
$value = $callback(); | ||
$this->cache->set($key, $value, tags: $value->tags); | ||
return $value; | ||
} | ||
catch (GuzzleException $e) { | ||
// Request callback failed. Catch the exception, so we can still use | ||
// stale cache if it exists. | ||
$exception = $e; | ||
} | ||
} | ||
|
||
if ($value instanceof CacheValue) { | ||
return $value; | ||
} | ||
|
||
// We should only reach this if: | ||
// 1. Cache does not exist ($value is NULL). | ||
// 2. API request fails, and we cannot re-populate the cache (caught the | ||
// exception). | ||
throw $exception; | ||
} | ||
|
||
/** | ||
* Helper method for calculating cache max age. | ||
* | ||
* @param int $ttl | ||
* Time to live in seconds. | ||
* | ||
* @return int | ||
* Expires timestamp. | ||
*/ | ||
public function cacheMaxAge(int $ttl): int { | ||
return $this->time->getRequestTime() + $ttl; | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
<?php | ||
|
||
namespace Drupal\helfi_api_base\ApiClient; | ||
|
||
use Drupal\Core\File\Exception\FileNotExistsException; | ||
use GuzzleHttp\Utils; | ||
|
||
/** | ||
* Helper class for mocking api responses. | ||
*/ | ||
final class ApiFixture { | ||
|
||
/** | ||
* Get response from fixture file. | ||
* | ||
* @param string $fileName | ||
* Fixture file. | ||
* | ||
* @return ApiResponse | ||
* Mocked response. | ||
*/ | ||
public static function requestFromFile(string $fileName): ApiResponse { | ||
if (!file_exists($fileName)) { | ||
throw new FileNotExistsException( | ||
sprintf('The mock file "%s" was not found.', basename($fileName)) | ||
); | ||
} | ||
|
||
return new ApiResponse(Utils::jsonDecode(file_get_contents($fileName))); | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
<?php | ||
|
||
declare(strict_types = 1); | ||
|
||
namespace Drupal\helfi_api_base\ApiClient; | ||
|
||
/** | ||
* A value object to store API responses. | ||
*/ | ||
final class ApiResponse { | ||
|
||
/** | ||
* Constructs a new instance. | ||
* | ||
* @param array|object $data | ||
* The response. | ||
*/ | ||
public function __construct( | ||
public readonly array|object $data | ||
) { | ||
} | ||
|
||
} |
Oops, something went wrong.