Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UHF-9239: Move ApiManager from helfi_navigation to helfi_api_base. #145

Merged
merged 11 commits into from
Jan 16, 2024
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ A base module for [drupal-helfi-platform](https://github.com/City-of-Helsinki/dr
## Features

- [API user manager](documentation/api-accounts.md): Allows API users to be created/managed from an environment variable.
- [API client](documentation/api-client.md): Services for caching and mocking http responses.
- [Automatic external cache invalidation](documentation/automatic-external-cache-invalidation.md): Invalidate caches from external projects using [PubSub messaging](documentation/pubsub-messaging.md) service.
- [Automatic revision deletion](documentation/revisions.md): Clean up old entity revisions automatically.
- [Debug collector](documentation/debug.md): A plugin to collect and show various debug information in one place.
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"php": "^8.1",
"t4web/composer-lock-parser": "^1.0",
"textalk/websocket": "^1.6",
"webmozart/assert": "^1.0"
"webmozart/assert": "^1.0",
"ext-curl": "*"
},
"conflict": {
"drupal/helfi_debug": "*"
Expand Down
49 changes: 49 additions & 0 deletions documentation/api-client.md
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.
9 changes: 9 additions & 0 deletions helfi_api_base.services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,12 @@ services:
- '@entity_type.manager'
- '@config.factory'
- '@database'

helfi_api_base.api_client_base:
class: Drupal\helfi_api_base\ApiClient\ApiClient
abstract: true
arguments:
- '@http_client'
- '@cache.default'
- '@datetime.time'
- '@helfi_api_base.environment_resolver'
252 changes: 252 additions & 0 deletions src/ApiClient/ApiClient.php
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;
}

}
32 changes: 32 additions & 0 deletions src/ApiClient/ApiFixture.php
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)));
}

}
23 changes: 23 additions & 0 deletions src/ApiClient/ApiResponse.php
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
) {
}

}
Loading
Loading