Skip to content

Commit

Permalink
Merge pull request #145 from City-of-Helsinki/UHF-9239-generic-api-ma…
Browse files Browse the repository at this point in the history
…nager

UHF-9239: Move ApiManager from helfi_navigation to helfi_api_base.
  • Loading branch information
hyrsky authored Jan 16, 2024
2 parents 84e9f20 + 845167a commit ab8b3d9
Show file tree
Hide file tree
Showing 12 changed files with 885 additions and 2 deletions.
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

0 comments on commit ab8b3d9

Please sign in to comment.