Skip to content
Snippets Groups Projects
Unverified Commit bd5dc2f6 authored by Mateu Aguiló Bosch's avatar Mateu Aguiló Bosch
Browse files

feat: initial implementation

parent 53fdc25e
No related branches found
No related tags found
No related merge requests found
Showing
with 1185 additions and 0 deletions
name: API Proxy
description: Puts Drupal between the front-end and the 3rd party API.
core: 8.x
type: module
api_proxy.form:
title: 'API Proxy'
description: 'Configure the behavior of the API proxies.'
route_name: api_proxy.form
parent: system.admin_config_services
api_proxy.form_tab:
route_name: api_proxy.form
title: 'API Proxy'
base_route: api_proxy.form
api_proxy.settings_tab:
route_name: api_proxy.settings
title: 'Settings'
base_route: api_proxy.form
use api proxies:
title: 'Use the API proxies'
api_proxy.form:
path: '/admin/config/services/api-proxy'
defaults:
_form: '\Drupal\api_proxy\Form\ApiProxyForm'
_title: 'API Proxy'
requirements:
_permission: 'administer site configuration'
api_proxy.settings:
path: '/admin/config/services/api-proxy/settings'
defaults:
_form: '\Drupal\api_proxy\Form\SettingsForm'
_title: 'Configure HTTP API proxies'
requirements:
_permission: 'administer site configuration'
api_proxy.forwarder:
path: '/api-proxy/{api_proxy}'
options:
parameters:
api_proxy:
type: 'api_proxy'
methods: [GET, PUT, POST, PATCH, DELETE, OPTIONS]
defaults:
_controller: Drupal\api_proxy\Controller\Forwarder::forward
_title: 'API Proxy request forwarder'
requirements:
# TODO: Add granular permissions to use each one of the APIs.
_permission: 'use api proxies'
services:
Drupal\api_proxy\Plugin\HttpApiPluginManager:
autowire: true
parent: default_plugin_manager
Drupal\api_proxy\ParamConverter\HttpApiProxyConverter:
autowire: true
tags:
- { name: paramconverter }
Drupal\api_proxy\EventSubscriber\OptionsRequestSubscriber:
decorates: options_request_listener
arguments:
- '@router.route_provider'
- '@Drupal\api_proxy\EventSubscriber\OptionsRequestSubscriber.inner'
tags:
- { name: event_subscriber }
{
"name": "drupal/api_proxy",
"description": "Puts Drupal between the front-end and the 3rd party API.",
"type": "drupal-module",
"homepage": "https://drupal.org/project/api_proxy",
"support": {
"issues": "https://drupal.org/project/issues/api_proxy",
"source": "https://git.drupalcode.org/project/api_proxy"
},
"license": "GPL-2.0+",
"minimum-stability": "dev",
"require": {
"lstrojny/functional-php": "^1.9"
}
}
api_proxies: []
api_proxy.settings:
type: config_object
label: 'API Proxy Settings'
mapping:
api_proxies:
type: sequence
label: 'API Proxies'
sequence:
type: api_proxy.settings.api_proxy_plugin.[%key]
<?php
namespace Drupal\api_proxy\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* Annotation class for the HTTP API proxy plugins.
*
* @Annotation
*/
final class HttpApi extends Plugin {
/**
* The plugin ID.
*
* @var string
*/
public $id;
/**
* The human-readable name of the formatter type.
*
* @var \Drupal\Core\Annotation\Translation
*
* @ingroup plugin_translatable
*/
public $label;
/**
* A short description of the formatter type.
*
* @var \Drupal\Core\Annotation\Translation
*
* @ingroup plugin_translatable
*/
public $description;
/**
* The name of the field formatter class.
*
* This is not provided manually, it will be added by the discovery mechanism.
*
* @var string
*/
public $class;
/**
* The service URL for the proxy.
*
* @var string
*/
public $serviceUrl;
}
<?php
namespace Drupal\api_proxy\Controller;
use Drupal\api_proxy\Plugin\HttpApiInterface;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Cache\CacheableResponse;
use Drupal\Core\Http\Exception\CacheableBadRequestHttpException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Main controller to forward requests.
*/
final class Forwarder {
const QUERY_PARAM_URI = '_api_proxy_uri';
/**
* Forwards incoming requests to the connected API.
*
* @param \Drupal\api_proxy\Plugin\HttpApiInterface $api_proxy
* The API proxy plugin.
* @param \Symfony\Component\HttpFoundation\Request $request
* The incoming request.
*
* @return \Symfony\Component\HttpFoundation\Response
* The response object.
*/
public function forward(HttpApiInterface $api_proxy, Request $request): Response {
$third_party_uri = $this->sanitizeUri($request->query->get(static::QUERY_PARAM_URI));
$cache_contexts = ['url.query_args:' . static::QUERY_PARAM_URI];
$cacheability = (new CacheableMetadata())->addCacheContexts($cache_contexts);
if (empty($third_party_uri)) {
throw new CacheableBadRequestHttpException(
$cacheability,
sprintf('Unable to find a valid URI in the %s query parameter.', static::QUERY_PARAM_URI)
);
}
$response = $api_proxy->forward($request, $third_party_uri);
if ($response instanceof CacheableResponse) {
$response->addCacheableDependency($cacheability);
}
return $response;
}
private function sanitizeUri(string $uri) {
return UrlHelper::isValid($uri) ? $uri : '';
}
}
<?php
namespace Drupal\api_proxy\EventSubscriber;
use Drupal\api_proxy\Plugin\HttpApiPluginBase;
use Symfony\Cmf\Component\Routing\RouteProviderInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Handles options requests.
*/
class OptionsRequestSubscriber implements EventSubscriberInterface {
const ROUTE_NAME = 'api_proxy.forwarder';
/**
* The route provider.
*
* @var \Symfony\Cmf\Component\Routing\RouteProviderInterface
*/
protected $routeProvider;
/**
* The decorated service.
*
* @var \Symfony\Component\EventDispatcher\EventSubscriberInterface
*/
protected $subject;
/**
* Creates a new OptionsRequestSubscriber instance.
*
* @param \Symfony\Cmf\Component\Routing\RouteProviderInterface $route_provider
* The route provider.
* @param \Symfony\Component\EventDispatcher\EventSubscriberInterface
* The decorated service.
*/
public function __construct(RouteProviderInterface $route_provider, EventSubscriberInterface $subject) {
$this->routeProvider = $route_provider;
$this->subject = $subject;
}
/**
* Tries to handle the options request.
*
* @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event
* The request event.
*/
public function onRequest(GetResponseEvent $event) {
$request = $event->getRequest();
$routes = $this->routeProvider->getRouteCollectionForRequest($event->getRequest());
if ($request->getMethod() !== 'OPTIONS') {
return;
}
$route_name = current(array_filter(
array_keys($routes->all()),
function ($route_name) {
return $route_name === static::ROUTE_NAME;
}
));
if (!$route_name) {
$this->subject->onRequest($event);
return;
}
$param_name = key($routes->get($route_name)->getOption('parameters'));
$proxy = $request->attributes->get($param_name);
assert($proxy instanceof HttpApiPluginBase);
$event->setResponse($proxy->corsResponse($request));
}
/**
* {@inheritdoc}
*/
public
static function getSubscribedEvents() {
// Set a high priority so it is executed before routing.
$events[KernelEvents::REQUEST][] = ['onRequest', 31];
return $events;
}
}
<?php
namespace Drupal\api_proxy\Form;
use Drupal\api_proxy\Plugin\HttpApiPluginManager;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* A form to manually enqueue warming operations.
*/
final class ApiProxyForm extends FormBase {
/**
* The HTTP API proxy plugin manager.
*
* @var \Drupal\api_proxy\Plugin\HttpApiPluginManager
*/
private $apiProxyManager;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): self {
/** @var \Drupal\api_proxy\Form\ApiProxyForm $form_object */
$form_object = parent::create($container);
$form_object->setApiProxyManager($container->get(HttpApiPluginManager::class));
return $form_object;
}
/**
* Set the HTTP API proxy manager.
*
* @param \Drupal\api_proxy\Plugin\HttpApiPluginManager $api_proxy_manager
* The plugin manager.
*/
public function setApiProxyManager(HttpApiPluginManager $api_proxy_manager): void {
$this->apiProxyManager = $api_proxy_manager;
}
/**
* {@inheritdoc}
*/
public function getFormId(): string {
return 'api_proxy.form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state): array {
$form['help'] = [
'#type' => 'item',
'#description' => $this->t('This page allows you to enqueue cache warming operations manually. This will put the cache warming operations in a queue. If you want to actually execute them right away you can force processing the queue. A good way to do that is by installing the <a href=":url">Queue UI</a> module or using Drush. This module will provide a UI to process an entire queue.', [':url' => 'https://www.drupal.org/project/queue_ui']),
];
$html = array_reduce($this->apiProxyManager->getDefinitions(), function ($carry, array $definition) {
return $carry . '<dt>'. $definition['label'] .'</dt><dd>'. $definition['description'] .'</dd>';
}, '');
$form['apis'] = [
'#type' => 'details',
'#title' => $this->t('Installed APIs'),
'#collapsible' => FALSE,
'#open' => TRUE,
];
$form['apis']['info'] = [
'#type' => 'html_tag',
'#tag' => 'dl',
'#value' => $html,
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state): void {}
}
<?php
namespace Drupal\api_proxy\Form;
use Drupal\api_proxy\Plugin\HttpApiPluginBase;
use Drupal\api_proxy\Plugin\HttpApiPluginManager;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformState;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Settings form for the api_proxy module.
*
* This form aggregates the configuration of all the api_proxy plugins.
*/
final class SettingsForm extends ConfigFormBase {
/**
* The plugin manager for the HTTP API proxies.
*
* @var \Drupal\api_proxy\Plugin\HttpApiPluginManager
*/
private $apiProxyManager;
/**
* {@inheritdoc}
*/
public function __construct(ConfigFactoryInterface $config_factory, HttpApiPluginManager $api_proxy_manager) {
$this->setConfigFactory($config_factory);
$this->apiProxyManager = $api_proxy_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('config.factory'),
$container->get(HttpApiPluginManager::class)
);
}
/**
* {@inheritdoc}
*/
protected function getEditableConfigNames() {
return ['api_proxy.settings'];
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'api_proxy_settings';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$form = parent::buildForm($form, $form_state);
$form['help'] = [
'#type' => 'html_tag',
'#tag' => 'p',
'#value' => $this->t('Configure the behaviors for the HTTP API proxies. Each proxy is a plugin that may contain specific settings. They are all configured here.'),
];
$api_proxies = $this->apiProxyManager->getHttpApis();
$form['api_proxies'] = [
'#type' => 'vertical_tabs',
'#title' => $this->t('HTTP API proxies'),
];
$subform_state = SubformState::createForSubform(
$form,
$form,
$form_state
);
$form += array_reduce(
$api_proxies,
function ($carry, HttpApiPluginBase $api_proxy) use ($subform_state) {
return $api_proxy->buildConfigurationForm($carry, $subform_state) + $carry;
},
$form
);
return $form;
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$api_proxies = $this->apiProxyManager->getHttpApis();
array_map(function (HttpApiPluginBase $api_proxy) use (&$form, $form_state) {
$id = $api_proxy->getPluginId();
$subform_state = SubformState::createForSubform($form[$id], $form, $form_state);
$api_proxy->submitConfigurationForm($form[$id], $subform_state);
}, $api_proxies);
$name = $this->getEditableConfigNames();
$config_name = reset($name);
$config = $this->configFactory()->getEditable($config_name);
$api_proxy_configs = array_reduce($api_proxies, function ($carry, HttpApiPluginBase $api_proxy) {
$carry[$api_proxy->getPluginId()] = $api_proxy->getConfiguration();
return $carry;
}, []);
$config->set('api_proxies', $api_proxy_configs);
$config->save();
$message = $this->t('Settings saved for plugin(s): %names', [
'%names' => implode(', ', array_map(function (HttpApiPluginBase $api_proxy) {
return $api_proxy->getPluginDefinition()['label'];
}, $api_proxies))
]);
$this->messenger()->addStatus($message);
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
array_map(function (HttpApiPluginBase $api_proxy) use (&$form, $form_state) {
$id = $api_proxy->getPluginId();
$subform_state = SubformState::createForSubform($form[$id], $form, $form_state);
$api_proxy->validateConfigurationForm($form[$id], $subform_state);
}, $this->apiProxyManager->getHttpApis());
}
}
<?php
namespace Drupal\api_proxy\ParamConverter;
use Drupal\api_proxy\Plugin\HttpApiInterface;
use Drupal\api_proxy\Plugin\HttpApiPluginBase;
use Drupal\api_proxy\Plugin\HttpApiPluginManager;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Http\Exception\CacheableNotFoundHttpException;
use Drupal\Core\ParamConverter\ParamConverterInterface;
use Symfony\Component\Routing\Route;
/**
* Converts the parameter into the full object.
*/
final class HttpApiProxyConverter implements ParamConverterInterface {
const PARAM_TYPE = 'api_proxy';
/**
* The plugin manager.
*
* @var \Drupal\api_proxy\Plugin\HttpApiPluginManager
*/
private $pluginManager;
/**
* {@inheritdoc}
*/
public function __construct(HttpApiPluginManager $plugin_manager) {
$this->pluginManager = $plugin_manager;
}
/**
* {@inheritdoc}
*/
public function convert($value, $definition, $name, array $defaults) {
$proxy = current($this->pluginManager->getHttpApis([$value]));
if ($proxy instanceof HttpApiInterface) {
return $proxy;
}
throw new CacheableNotFoundHttpException(
(new CacheableMetadata())->addCacheContexts(['route']),
sprintf('The API proxy for "%s" was not found.', $value)
);
}
/**
* {@inheritdoc}
*/
public function applies($definition, $name, Route $route) {
return (!empty($definition['type']) && $definition['type'] === static::PARAM_TYPE);
}
}
<?php
namespace Drupal\api_proxy\Plugin;
use Drupal\Core\Cache\CacheableResponse;
use Drupal\Core\Form\SubformStateInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
interface HttpApiInterface {
public function getBaseUrl(): string;
public function shouldForwardHeaders(): bool;
public function shouldForwardQueryStingParams(): bool;
public function getAdditionalHeaders(): array;
public function getAdditionalQueryStringParams(): array;
public function getTimeout(): ?int;
public function isCacheForced(): int;
public function getForcedCacheTtl(): int;
public function changeInputToDrupal(Request $request): Request;
public function changeOutputFromApi(Response $response): Response;
public function forward(Request $request, string $uri): Response;
public function corsResponse(Request $request): CacheableResponse;
/**
* Adds additional form elements to the configuration form.
*
* @param array $form
* The configuration form to alter for the this plugin settings.
* @param \Drupal\Core\Form\SubformStateInterface $form_state
* The form state for the plugin settings.
*
* @return array
* The form with additional elements.
*/
public function addMoreConfigurationFormElements(array $form, SubformStateInterface $form_state): array;
}
<?php
namespace Drupal\api_proxy\Plugin;
use Drupal\api_proxy\Controller\Forwarder;
use Drupal\Component\Plugin\ConfigurablePluginInterface;
use Drupal\Component\Utility\Crypt;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Cache\CacheableResponse;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformState;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Plugin\PluginBase;
use Drupal\Core\Plugin\PluginFormInterface;
use GuzzleHttp\ClientInterface;
use Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Base class for HTTP API plugins that implement settings forms.
*
* @see \Drupal\api_proxy\Annotation\HttpApi
* @see \Drupal\api_proxy\Plugin\HttpApiPluginManager
* @see \Drupal\api_proxy\Plugin\HttpApiInterface
*
* @see plugin_api
*/
abstract class HttpApiPluginBase extends PluginBase implements ContainerFactoryPluginInterface, PluginFormInterface, ConfigurablePluginInterface, HttpApiInterface {
/**
* The HTTP client.
*
* @var \GuzzleHttp\ClientInterface
*/
private $client;
/**
* Translates between Symfony and PRS objects.
*
* @var \Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface
*/
private $foundationFactory;
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, ClientInterface $client, HttpFoundationFactoryInterface $foundation_factory) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->client = $client;
$this->foundationFactory = $foundation_factory;
if (empty($plugin_definition['serviceUrl']) || !UrlHelper::isValid($plugin_definition['serviceUrl'])) {
throw new \InvalidArgumentException('Please ensure the serviceUrl annotation property is set with a valid URL in the plugin definition.');
}
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): self {
$settings = $container->get('config.factory')
->get('api_proxy.settings')
->get('api_proxies');
$plugin_settings = empty($settings[$plugin_id]) ? [] : $settings[$plugin_id];
$configuration = array_merge($plugin_settings, $configuration);
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('http_client'),
$container->get('psr7.http_foundation_factory')
);
}
/**
* {@inheritdoc}
*/
public function getConfiguration(): array {
return [
'id' => $this->getPluginId(),
] + $this->configuration + $this->defaultConfiguration();
}
/**
* {@inheritdoc}
*/
public function setConfiguration(array $configuration): self {
$this->configuration = $configuration + $this->defaultConfiguration();
return $this;
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration(): array {
return [
'baseUrl' => '',
'forwardHeaders' => TRUE,
'forwardQueryStingParams' => FALSE,
'additionalHeaders' => [],
'additionalQueryStringParams' => [],
'timeout' => NULL,
'forceCache' => FALSE,
'forcedCacheTtl' => 3 * 60,
'cors' => [
'origin' => [],
'methods' => ['GET', 'OPTIONS'],
'max_age' => 1 * 60 * 60,
'headers' => '',
]
];
}
/**
* {@inheritdoc}
*/
public function getBaseUrl(): string {
return $this->getPluginDefinition()['serviceUrl'];
}
/**
* {@inheritdoc}
*/
public function shouldForwardHeaders(): bool {
return $this->getConfiguration()['forwardHeaders'];
}
/**
* {@inheritdoc}
*/
public function shouldForwardQueryStingParams(): bool {
return $this->getConfiguration()['forwardQueryStingParams'];
}
/**
* {@inheritdoc}
*/
public function getAdditionalHeaders(): array {
return $this->getConfiguration()['additionalHeaders'];
}
/**
* {@inheritdoc}
*/
public function getAdditionalQueryStringParams(): array {
return $this->getConfiguration()['additionalQueryStringParams'];
}
/**
* {@inheritdoc}
*/
public function getTimeout(): ?int {
return $this->getConfiguration()['timeout'];
}
/**
* {@inheritdoc}
*/
public function isCacheForced(): int {
return $this->getConfiguration()['forceCache'];
}
/**
* {@inheritdoc}
*/
public function getForcedCacheTtl(): int {
return $this->getConfiguration()['forcedCacheTtl'];
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state): void {
// TODO: Write the validation for the headers and the query string params.
// $frequency = $form_state->getValue('frequency');
// $batch_size = $form_state->getValue('batchSize');
// if (!is_numeric($frequency) || $frequency < 0) {
// $form_state->setError($form[$this->getPluginId()]['frequency'], $this->t('Frequency should be a positive number.'));
// }
// if (!is_numeric($batch_size) || $batch_size < 1) {
// $form_state->setError($form[$this->getPluginId()]['batchSize'], $this->t('Batch size should be a number greater than 1.'));
// }
}
/**
* {@inheritdoc}
*/
public function calculateDependencies(): array {
return [];
}
/**
* {@inheritdoc}
*/
final public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
$configuration = $this->getConfiguration() + $this->defaultConfiguration();
$plugin_id = $configuration['id'];
$definition = $this->getPluginDefinition();
$form[$plugin_id] = empty($form[$plugin_id]) ? [
'#type' => 'details',
'#open' => TRUE,
'#title' => empty($definition['label']) ? $plugin_id : $definition['label'],
'#group' => 'api_proxies',
'#tree' => TRUE,
] : $form[$plugin_id];
if (!empty($definition['description'])) {
$form[$plugin_id]['description'] = [
'#type' => 'html_tag',
'#tag' => 'em',
'#value' => $definition['description'],
];
}
$form[$plugin_id]['forwardHeaders'] = [
'#type' => 'checkbox',
'#title' => $this->t('Forward headers?'),
'#description' => $this->t('Check this to send headers in the incoming request to the 3rd party API.'),
'#default_value' => $this->shouldForwardHeaders(),
];
$form[$plugin_id]['forwardQueryStingParams'] = [
'#type' => 'checkbox',
'#title' => $this->t('Forward query string parameters?'),
'#description' => $this->t('Check this to send query string parameters in the incoming request to the 3rd party API.'),
'#default_value' => $this->shouldForwardQueryStingParams(),
];
$lines = [];
foreach ($this->getAdditionalHeaders() as $name => $value) {
$lines[] = sprintf('%s: %s', $name, $value);
}
$form[$plugin_id]['additionalHeaders'] = [
'#type' => 'textarea',
'#title' => $this->t('Additional headers'),
'#description' => $this->t('Additional headers to send to the 3rd party API. Add one header per line. Separate header name and value with a ":". Example: <code>Accept-Encoding: gzip</code>.'),
'#default_value' => implode("\n", $lines),
];
$lines = [];
foreach ($this->getAdditionalQueryStringParams() as $name => $value) {
$lines[] = sprintf('%s=%s', $name, $value);
}
$form[$plugin_id]['additionalQueryStringParams'] = [
'#type' => 'textarea',
'#title' => $this->t('Additional query string parameters'),
'#description' => $this->t('Additional query string parameters to send to the 3rd party API. Add one parameter per line. Separate parameter name and value with a "=". Example: <code>include[rating][foo][]=6</code>.'),
'#default_value' => implode("\n", $lines),
];
$form[$plugin_id]['timeout'] = [
'#type' => 'number',
'#title' => $this->t('Timeout'),
'#description' => $this->t('Fail the request to the 3rd party HTTP API after this many seconds.'),
'#default_value' => $this->getTimeout(),
];
$form[$plugin_id]['forceCache'] = [
'#type' => 'checkbox',
'#title' => $this->t('Force response caching'),
'#description' => $this->t('Responses are cached in Page Cache respecting the Cache-Control headers from the 3rd party HTTP API by default. Check this box to force caching in any situation.'),
'#default_value' => $this->isCacheForced(),
];
$form[$plugin_id]['forcedCacheTtl'] = [
'#type' => 'number',
'#title' => $this->t('Cache TTL'),
'#description' => $this->t('Forced cache TTL in seconds. Use <code>0</code> for skip caching. Use <code>-1</code> for permanent caching.'),
'#default_value' => $this->getForcedCacheTtl(),
'#states' => [
'visible' => [
'input[name="' . $plugin_id . '[forceCache]"]' => ['checked' => TRUE],
],
],
];
$form[$plugin_id]['cors'] = [
'#type' => 'details',
'#title' => $this->t('CORS'),
'#open' => TRUE,
'origin' => [
'#type' => 'textarea',
'#title' => $this->t('Allowed Origins'),
'#description' => $this->t('The candidates for contents of the <code>Access-Control-Allow-Origin</code> header. One per line. Note: you can use <code>*</code> here, but it is not recommended. Example: <pre><code>http://dev.example.com<br />https://example.com</code></pre>'),
'#default_value' => implode("\n", $configuration['cors']['origin']),
],
'methods' => [
'#type' => 'checkboxes',
'#title' => $this->t('Allowed methods'),
'#description' => $this->t('The contents of the <code>Access-Control-Allow-Methods</code> header.'),
'#options' => [
'GET' => 'GET',
'POST' => 'POST',
'PUT' => 'PUT',
'PATCH' => 'PATCH',
'DELETE' => 'DELETE',
'OPTIONS' => 'OPTIONS',
],
'#default_value' => $configuration['cors']['methods'],
],
'max_age' => [
'#type' => 'number',
'#title' => $this->t('Max age'),
'#description' => $this->t('The contents of the <code>Access-Control-Max-Age</code> header.'),
'#default_value' => $configuration['cors']['max_age'],
],
'headers' => [
'#type' => 'textfield',
'#title' => $this->t('Allowed Headers'),
'#description' => $this->t('List of coma-separated headers that are allowed. This will be set in the value of <code>Access-Control-Allow-Headers</code>.'),
'#default_value' => $configuration['cors']['headers'],
],
];
$subform_state = SubformState::createForSubform($form[$plugin_id], $form, $form_state);
$form[$plugin_id] = $this->addMoreConfigurationFormElements($form[$plugin_id], $subform_state);
return $form;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void {
$values = $form_state->getValues();
$values['additionalHeaders'] = $this->parseHeaders(
$values['additionalHeaders']
);
$values['additionalQueryStringParams'] = $this->parseQueryStringParams(
$values['additionalQueryStringParams']
);
$values['cors']['origin'] = $this->parseMultiline($values['cors']['origin']);
$values['timeout'] = $values['timeout'] ?: NULL;
$this->setConfiguration($values + $this->configuration);
}
/**
* {@inheritdoc}
*/
public function changeInputToDrupal(Request $request): Request {
return $request;
}
/**
* {@inheritdoc}
*/
public function changeOutputFromApi(Response $response): Response {
return $response;
}
/**
* {@inheritdoc}
*/
public function forward(Request $request, string $uri): Response {
$parsed_uri = UrlHelper::parse($uri);
$api_uri = rtrim($this->getBaseUrl(), '/') . '/' . ltrim($parsed_uri['path'], '/');
$options = [
'query' => $this->calculateQueryStringParams($request->query->all(), $parsed_uri['query'] ?? []),
'headers' => $this->calculateHeaders($request->headers->all()),
];
if ($body = $request->getContent()) {
$options['body'] = $body;
}
$psr7_response = $this->client->request(
$request->getMethod(),
$api_uri,
$options
);
$response = $this->foundationFactory->createResponse($psr7_response);
$changed_response = $this->changeOutputFromApi($response);
// Add CORS headers.
$response->headers->add(
$this->calculateCorsHeaders($request)
);
return $this->maybeMakeResponseCacheable($changed_response);
}
public function corsResponse(Request $request): CacheableResponse {
$headers = $this->calculateCorsHeaders($request);
return CacheableResponse::create(NULL, 200, $headers)
->setVary('Origin', FALSE)
->setCache([
'max_age' => $headers['Access-Control-Max-Age'],
]);
}
private function calculateCorsHeaders(Request $request): array {
$cors_config = $this->getConfiguration()['cors'];
$ttl = $cors_config['max_age'];
$methods = implode(', ', array_filter($cors_config['methods']));
$headers = [
'Allow' => $methods,
'Access-Control-Allow-Methods' => $methods,
'Access-Control-Max-Age' => $ttl,
'Access-Control-Allow-Headers' => $cors_config['headers'],
];
$origin = $request->headers->get('Origin');
$candidates = $cors_config['origin'] ?? [];
if ($this->matchesOrigin($origin, $candidates)) {
$headers['Access-Control-Allow-Origin'] = $origin;
}
return $headers;
}
private function maybeMakeResponseCacheable(Response $response): Response {
$configured_ttl = $this->isCacheForced() ? $this->getForcedCacheTtl() : 0;
$response_ttl = (int) $response->getMaxAge();
$ttl = $configured_ttl > $response_ttl ? $configured_ttl : $response_ttl;
if (!$ttl) {
return $response;
}
$cacheable_response = new CacheableResponse(
$response->getContent(),
$response->getStatusCode(),
$response->headers->all()
);
$cacheable_response->setCache([
'max_age' => $ttl,
'public' => TRUE,
'etag' => Crypt::hashBase64(
$cacheable_response->getContent() . implode('', $cacheable_response->headers->all())
)
]);
return $cacheable_response;
}
protected function calculateHeaders(array $headers): array {
$new_headers = array_diff_key($headers, ['host' => NULL]);
$new_headers['x-forwarded-host'] = $headers['host'] ?? '';
return array_merge(
$this->shouldForwardHeaders() ? $new_headers : [],
$this->getAdditionalHeaders()
);
}
protected function calculateQueryStringParams(array $qs, array $input_qs): array {
return array_diff_key(
array_merge(
$this->shouldForwardQueryStingParams() ? $qs : [],
$input_qs,
$this->getAdditionalQueryStringParams()
),
[Forwarder::QUERY_PARAM_URI => NULL]
);
}
private function parseHeaders(string $input) {
return array_filter(array_reduce(
array_filter(explode("\n", $input)),
function ($carry, $header) {
list($name, $val) = array_map('trim', explode(':', $header, 2));
return array_merge($carry, [$name => $val]);
},
[]
));
}
private function parseQueryStringParams(string $input) {
return array_filter(array_reduce(
array_filter(explode("\n", $input)),
function ($carry, $header) {
list($name, $val) = array_map('trim', explode('=', $header, 2));
return array_merge($carry, [$name => $val]);
},
[]
));
}
private function parseMultiline(string $input) {
return array_filter(array_map('trim', explode("\n", $input)));
}
private function matchesOrigin($origin, $candidates): bool {
return array_reduce($candidates, function (bool $carry, string $candidate) use ($origin): bool {
// An origin can match in 2 ways:
// 1. There is a '*' in the candidates.
// 2. The origin is in the cadidates.
return $carry || $candidate === '*' || $candidate === $origin;
}, FALSE);
}
}
<?php
namespace Drupal\api_proxy\Plugin;
use Drupal\Component\Plugin\Exception\PluginException;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\api_proxy\Annotation\HttpApi;
/**
* Manager for the HTTP API proxy plugins.
*/
final class HttpApiPluginManager extends DefaultPluginManager {
/**
* Constructs a new HookPluginManager object.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* Cache backend instance to use.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler to invoke the alter hook with.
*/
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
$this->alterInfo(FALSE);
parent::__construct('Plugin/api_proxy', $namespaces, $module_handler, HttpApiInterface::class, HttpApi::class);
$this->setCacheBackend($cache_backend, 'http_api_plugins');
}
/**
* Instantiates all the HTTP API plugins.
*
* @return \Drupal\api_proxy\Plugin\HttpApiPluginBase[]
* The plugin instances.
*/
public function getHttpApis($plugin_ids = NULL): array {
if (!$plugin_ids) {
$definitions = $this->getDefinitions();
$plugin_ids = array_map(function ($definition) {
return empty($definition) ? NULL : $definition['id'];
}, $definitions);
$plugin_ids = array_filter(array_values($plugin_ids));
}
$api_proxies = array_map(function ($plugin_id) {
try {
return $this->createInstance($plugin_id);
}
catch (PluginException $exception) {
return NULL;
}
}, $plugin_ids);
return array_filter($api_proxies, function ($api_proxy) {
return $api_proxy instanceof HttpApiPluginBase;
});
}
}
<?php
namespace Drupal\api_proxy\Plugin\api_proxy;
use Drupal\api_proxy\Annotation\HttpApi;
use Drupal\api_proxy\Plugin\HttpApiPluginBase;
use Drupal\Core\Annotation\Translation;
use Drupal\Core\Form\SubformStateInterface;
use Symfony\Component\HttpFoundation\Response;
/**
* @HttpApi(
* id = "g2-crowd",
* label = @Translation("G2 Crowd"),
* description = @Translation("Proxies requests to the G2 Crowd API."),
* serviceUrl = "https://data.g2.com/api/v1",
* )
*/
final class G2Crowd extends HttpApiPluginBase {
use HttpApiCommonConfigs;
/**
* {@inheritdoc}
*/
public function addMoreConfigurationFormElements(array $form, SubformStateInterface $form_state): array {
$configuration = $this->configuration;
return array_merge(
$form,
['auth_token' => $this->authTokenConfigForm($configuration)]
);
}
/**
* {@inheritdoc}
*/
protected function calculateHeaders(array $headers): array {
$configuration = $this->getConfiguration();
$header = sprintf('Token token=%s', $configuration['auth_token'] ?? '');
return array_merge(
parent::calculateHeaders($headers),
['Authorization' => $header]
);
}
public function changeOutputFromApi(Response $response): Response {
$response->headers->remove('transfer-encoding');
return $response;
}
}
<?php
namespace Drupal\api_proxy\Plugin\api_proxy;
trait HttpApiCommonConfigs {
protected function authTokenConfigForm(array $configuration): array {
return [
'#type' => 'item',
'#title' => $this->t('Authentication token (%status)', ['%status' => empty($configuration['auth_token']) ? $this->t('Not set') : $this->t('Successfully set')]),
'#description' => $this->t(
'The authentication token to access the G2 API. <strong>IMPORTANT:</strong> do not export configuration to the repository with sensitive data, instead set <code>$config[\'api_proxy.settings\'][\'api_proxies\'][\'@id\'][\'auth_token\'] = \'YOUR-TOKEN\';</code> in your <code>settings.local.php</code> (or similar) to store your secret.',
['@id' => $this->getPluginId()]
),
];
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment