Forked from
project / project_browser
153 commits behind the upstream repository.
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
DrupalDotOrgJsonApi.php 23.32 KiB
<?php
namespace Drupal\project_browser\Plugin\ProjectBrowserSource;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\Html;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ExtensionVersion;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\project_browser\DevelopmentStatus;
use Drupal\project_browser\MaintenanceStatus;
use Drupal\project_browser\Plugin\ProjectBrowserSourceBase;
use Drupal\project_browser\ProjectBrowser\Filter\BooleanFilter;
use Drupal\project_browser\ProjectBrowser\Filter\MultipleChoiceFilter;
use Drupal\project_browser\ProjectBrowser\Project;
use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage;
use Drupal\project_browser\SecurityStatus;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Response;
/**
* Drupal.org JSON:API endpoint.
*
* @ProjectBrowserSource(
* id = "drupalorg_jsonapi",
* label = @Translation("Contrib modules"),
* description = @Translation("Modules on Drupal.org queried via the JSON:API endpoint"),
* )
*/
class DrupalDotOrgJsonApi extends ProjectBrowserSourceBase {
use StringTranslationTrait;
/**
* Main domain endpoint.
*
* @const string
*/
const DRUPAL_ORG_ENDPOINT = 'https://www.drupal.org';
/**
* Endpoint to query data from.
*
* @const string
*/
const JSONAPI_ENDPOINT = self::DRUPAL_ORG_ENDPOINT . '/jsonapi';
/**
* Value of the revoked status in the security coverage field.
*
* @const string
*/
const REVOKED_STATUS = 'revoked';
/**
* This is what drupal.org plugin understands as "Covered" modules.
*
* @var array
*/
const COVERED_VALUES = ['covered'];
/**
* Constructs a MockDrupalDotOrg object.
*
* @param array $configuration
* A configuration array containing information about the plugin instance.
* @param string $plugin_id
* The plugin ID for the plugin instance.
* @param mixed $plugin_definition
* The plugin implementation definition.
* @param \Psr\Log\LoggerInterface $logger
* A logger instance.
* @param \GuzzleHttp\ClientInterface $httpClient
* A Guzzle client object.
* @param \Drupal\Core\Cache\CacheBackendInterface $cacheBin
* The cache bin.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
*/
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
protected LoggerInterface $logger,
protected ClientInterface $httpClient,
protected CacheBackendInterface $cacheBin,
protected TimeInterface $time,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('logger.factory')->get('project_browser'),
$container->get(ClientInterface::class),
$container->get('cache.project_browser'),
$container->get('datetime.time'),
);
}
/**
* Performs a request to the jsonapi and returns the results.
*
* @param string $url
* URL to query.
* @param array $query_params
* Params to pass to the query.
* @param bool $all_data
* Fetch all data from all pages (defaults to FALSE).
*
* @return array
* Results from the query.
*/
protected function fetchData(string $url, array $query_params = [], bool $all_data = FALSE): array {
// Failsafe to avoid timeouts or memory issues.
// 50 results per page x 10 iterations = 500 results.
$iteration_limit = 10;
$result = [
'code' => NULL,
'data' => NULL,
'message' => '',
];
$params = [];
try {
if (!empty($query_params)) {
$params = [
'query' => $query_params,
];
}
$response = $this->httpClient->request('GET', $url, $params);
$response_data = Json::decode($response->getBody()->getContents());
$result['code'] = $response->getStatusCode();
$result['data'] = $response_data['data'];
$result['meta'] = $response_data['meta'] ?? NULL;
if (!empty($response_data['included'])) {
$result['included'] = $response_data['included'];
}
if ($all_data) {
// Start querying the "next" pages until there are no more of them, or
// we reach the iteration max limit.
$iterations = 0;
while (!empty($response_data['links']['next']) && $iterations < $iteration_limit) {
// Params are already in the URL for the "next" request.
$url = $response_data['links']['next']['href'];
$response = $this->httpClient->request('GET', $url);
$response_data = Json::decode($response->getBody()->getContents());
$result['data'] = array_merge($result['data'], $response_data['data']);
if (!empty($response_data['included'])) {
$result['included'] = array_merge($result['included'], $response_data['included']);
}
$iterations++;
}
if ($iterations >= $iteration_limit) {
$result['message'] = $this->t('Max limit reached: Result data has been truncated to %limit records.', [
'%limit' => count($result['data']),
]);
}
}
}
catch (GuzzleException $exception) {
$this->logger->error($exception->getMessage());
$result['message'] = $exception->getMessage();
$result['code'] = $exception->getCode();
}
catch (\Throwable $exception) {
$this->logger->error($exception->getMessage());
$result['message'] = $exception->getMessage();
$result['code'] = Response::HTTP_INTERNAL_SERVER_ERROR;
}
return $result;
}
/**
* Processes the included data returned by jsonapi and map by type.
*
* @param array $included
* Data from jsonapi with all included information.
*
* @return array
* Mapped array keyed by type and id.
*/
protected function mapIncludedData(array $included): array {
$mapped_array = [];
foreach ($included as $item) {
$mapped_array[$item['type']][$item['id']] = $item['attributes'];
}
return $mapped_array;
}
/**
* Process the return of a query to a vocabulary endpoint.
*
* @param string $vocabulary
* Vocabulary to query.
*
* @return array[]
* Result in array format.
*/
protected function getVocabularyData(string $vocabulary): array {
$endpoint = self::JSONAPI_ENDPOINT . '/taxonomy_term/' . $vocabulary;
$query_params = [
'sort' => 'name',
'filter[status]' => 1,
'fields[taxonomy_term--' . $vocabulary . ']' => 'name',
];
$result = $this->fetchData($endpoint, $query_params, TRUE);
$return = [];
if ($result['code'] == Response::HTTP_OK && !empty($result['data'])) {
foreach ($result['data'] as $item) {
$return[] = [
'id' => $item['id'],
'name' => $item['attributes']['name'],
];
}
}
return $return;
}
/**
* {@inheritdoc}
*/
public function getFilterDefinitions(): array {
$filters = [];
$categories = $this->getCategories();
$choices = array_combine(
array_column($categories, 'id'),
array_column($categories, 'name'),
);
$filters['categories'] = new MultipleChoiceFilter($choices, [], $this->t('Categories'), NULL);
$filters['securityCoverage'] = new BooleanFilter(
TRUE,
$this->t('Show projects covered by a security policy'),
$this->t('Show all'),
$this->t('Security advisory coverage'),
NULL,
);
$filters['maintenanceStatus'] = new BooleanFilter(
TRUE,
$this->t('Show actively maintained projects'),
$this->t('Show all'),
$this->t('Maintenance status'),
NULL,
);
$filters['developmentStatus'] = new BooleanFilter(
TRUE,
$this->t('Show projects under active development'),
$this->t('Show all'),
$this->t('Development status'),
NULL,
);
return $filters;
}
/**
* {@inheritdoc}
*/
public function getCategories(): array {
return $this->getVocabularyData('module_categories');
}
/**
* {@inheritdoc}
*/
public function getProjects(array $query = []): ProjectsResultsPage {
$api_response = $this->fetchProjects($query);
$filter_values = $this->filterValues();
$maintained_values = $filter_values['maintained'] ?? [];
$returned_list = [];
if (is_array($api_response) && !empty($api_response['list'])) {
$related = !empty($api_response['related']) ? $api_response['related'] : NULL;
$current_drupal_version = $this->getNumericSemverVersion(\Drupal::VERSION);
foreach ($api_response['list'] as $project) {
// Map any properties from jsonapi format to the simplified record
// format used by Project Browser.
$machine_name = $project['attributes']['field_project_machine_name'];
$uid_info = $project['relationships']['uid']['data'];
$maintenance_status = $project['relationships']['field_maintenance_status']['data'] ?? [];
if (!empty($maintenance_status)) {
$maintenance_status = [
'id' => $maintenance_status['id'],
'name' => $related[$maintenance_status['type']][$maintenance_status['id']]['name'],
];
}
$development_status = $project['relationships']['field_development_status']['data'] ?? [];
if (!empty($development_status)) {
$development_status = [
'id' => $development_status['id'],
'name' => $related[$development_status['type']][$development_status['id']]['name'],
];
}
$module_categories = $project['relationships']['field_module_categories']['data'] ?? [];
if (!empty($module_categories)) {
$categories = [];
foreach ($module_categories as $module_category) {
$categories[] = [
'id' => $module_category['id'],
'name' => $related[$module_category['type']][$module_category['id']]['name'],
];
}
$module_categories = $categories;
}
$project_images = $project['relationships']['field_project_images']['data'] ?? [];
if (!empty($project_images)) {
$images = [];
foreach ($project_images as $image) {
$uri = self::DRUPAL_ORG_ENDPOINT . $related[$image['type']][$image['id']]['uri']['url'];
// Adapt the path as we are querying via www.drupal.org.
$uri = str_replace(self::DRUPAL_ORG_ENDPOINT . '/assets/', self::DRUPAL_ORG_ENDPOINT . '/files/', $uri);
$images[] = [
'file' => [
'uri' => $uri,
'resource' => 'image',
],
'alt' => $image['meta']['alt'] ?? '',
];
}
$project_images = $images;
}
$project_usage = $project['attributes']['field_active_installs'];
$project_usage_total = 0;
if ($project_usage) {
$project_usage = Json::decode($project_usage);
foreach ($project_usage as $value) {
$project_usage_total += (int) $value;
}
}
$is_compatible = FALSE;
$semver_minimum = (int) $project['attributes']['field_core_semver_minimum'];
$semver_maximum = (int) $project['attributes']['field_core_semver_maximum'];
if (($semver_minimum <= $current_drupal_version) && ($semver_maximum >= $current_drupal_version)) {
$is_compatible = TRUE;
}
$logo = [];
if (!empty($project['attributes']['field_logo_url'])) {
$logo = [
'file' => [
'uri' => $project['attributes']['field_logo_url']['uri'],
'resource' => 'image',
],
'alt' => $project['attributes']['title'] . ' logo',
];
}
$body = $this->bodyRelativeToAbsoluteUrls(
$project['attributes']['body'] ?? ['summary' => '', 'value' => ''], 'https://www.drupal.org');
$project_object = new Project(
logo: $logo,
isCompatible: $is_compatible,
isMaintained: in_array($maintenance_status['id'], $maintained_values),
isCovered: in_array($project['attributes']['field_security_advisory_coverage'], self::COVERED_VALUES),
projectUsageTotal: $project_usage_total,
machineName: $machine_name,
body: $body,
title: $project['attributes']['title'],
author: [
'name' => $related[$uid_info['type']][$uid_info['id']]['name'],
],
packageName: $project['attributes']['field_composer_namespace'] ?? 'drupal/' . $machine_name,
categories: $module_categories,
images: $project_images,
url: Url::fromUri('https://www.drupal.org/project/' . $machine_name),
);
$returned_list[] = $project_object;
}
}
return $this->createResultsPage($returned_list, $api_response['total_results'] ?? 0);
}
/**
* Convert relative URLs found in the body to absolute URLs.
*
* @param array $body
* Body array field containing summary and value properties.
* @param string $base_url
* Base URL to prepend to relative links.
*
* @return array
* Body array with relative URLs converted to absolute ones.
*/
protected function bodyRelativeToAbsoluteUrls(array $body, string $base_url = self::DRUPAL_ORG_ENDPOINT): array {
if (empty($body['value'])) {
$body['value'] = $body['summary'] ?? '';
}
$body['value'] = Html::transformRootRelativeUrlsToAbsolute($body['value'], $base_url);
return $body;
}
/**
* Maps a given field to the allowed fields to sort results.
*
* @param string $field
* Name of the field.
*
* @return null|string
* Mapped field name or NULL if not available.
*
* @see ProjectBrowserSourceBase::getSortOptions()
*/
protected function mapSortField(string $field): ?string {
$map = [
'usage_total' => 'active_installs_total',
'created' => 'created',
// 'best_match' would be the default sort.
'best_match' => NULL,
'a_z' => 'title',
'z_a' => 'title',
];
return $map[$field] ?? NULL;
}
/**
* Maps a given field to the direction within the allowed values.
*
* @param string $field
* Name of the field.
*
* @return null|string
* Mapped direction or NULL if not available.
*
* @see ProjectBrowserSourceBase::getSortOptions()
*/
protected function mapSortDirection(string $field): ?string {
$map = [
'usage_total' => 'DESC',
'created' => 'DESC',
// 'best_match' would be the default sort.
'best_match' => NULL,
'a_z' => 'ASC',
'z_a' => 'DESC',
];
return $map[$field] ?? NULL;
}
/**
* Fetches the projects from the jsonapi backend.
*
* @param array $query
* Query parameters.
*
* @return array
* Array containing the results and the total number of records.
*/
protected function fetchProjects(array $query): array {
$endpoint = self::JSONAPI_ENDPOINT . '/index/project_modules';
$query = $this->convertQueryOptions($query);
$query_params = [
'filter[status]' => 1,
// For now, we only want full "module" projects.
'filter[type]' => 'project_module',
'filter[project_type]' => 'full',
'page[limit]' => $query['limit'],
'page[offset]' => $query['limit'] * $query['page'],
'include' => 'field_supporting_organizations,field_supporting_organizations.field_supporting_organization,field_module_categories,field_maintenance_status,field_development_status,uid,field_project_images',
];
if (!is_null($query['sort'])) {
$query_params['sort'] = $query['sort'];
}
if (!empty($query['search'])) {
$query_params['filter[fulltext]'] = $query['search'];
}
if (!empty($query['machine_name'])) {
$query_params['filter[machine_name]'] = $query['machine_name'];
}
// For now, we only want compatible projects.
$query_params = $this->addCoreVersionCheck($query_params);
$query_params = $this->addQueryParamsMultivalue('module_categories_uuid', $query['categories'] ?? '', $query_params);
$query_params = $this->addQueryParamsMultivalue('maintenance_status_uuid', $query['maintenance_status'] ?? '', $query_params);
$query_params = $this->addQueryParamsMultivalue('development_status_uuid', $query['development_status'] ?? '', $query_params);
$query_params = $this->addQueryParamsMultivalue('security_coverage', $query['security_advisory_coverage'] ?? '', $query_params);
// We will never want 'revoked' projects.
$query_params = $this->addQueryParamsMultivalue('security_coverage', self::REVOKED_STATUS, $query_params, TRUE);
$result = $this->fetchData($endpoint, $query_params);
$return = [
'total_results' => 0,
'list' => [],
];
if ($result['code'] === Response::HTTP_OK && !empty($result['data'])) {
// Related data referenced by any possible data entry.
$included = !empty($result['included']) ? $this->mapIncludedData($result['included']) : FALSE;
$return['related'] = $included;
$return['total_results'] = $result['meta']['count'] ?? count($result['data']);
$return['list'] = $result['data'];
}
return $return;
}
/**
* Translates a numeric semver version into a number in the expected format.
*
* It will do three blocks of three digits with padding zeros to the left.
* ie:
* - 9.3.6 will translate to 9003006.
* - 10.4.12 will translate to 10004012.
*
* @param string $version
* Semver version to check. It should follow X.Y.Z format.
*
* @return int
* Numeric representation of the given version.
*/
protected function getNumericSemverVersion(string $version): int {
$version_object = ExtensionVersion::createFromVersionString($version);
if ($extra = $version_object->getVersionExtra()) {
$version = str_replace("-$extra", '', $version);
}
$minor_version = $version_object->getMinorVersion() ?? 0;
$patch_version = explode('.', $version)[2] ?? '0';
return (int) (
$version_object->getMajorVersion() .
str_pad($minor_version, 3, '0', STR_PAD_LEFT) .
str_pad($patch_version, 3, '0', STR_PAD_LEFT)
);
}
/**
* Build the right query based on the field name and the values given.
*
* @param string $field_name
* Vocabulary to query.
* @param string $values
* Comma-separated list of values to check, if any.
* @param array $query_params
* Query params that will be passed to the request.
* @param bool $negate
* Make the query a 'NOT IN' instead of an 'IN'.
*
* @return array
* New list of params containing the new filters.
*/
protected function addQueryParamsMultivalue($field_name, string $values, array $query_params, $negate = FALSE): array {
if (!empty($values)) {
$values = explode(',', $values);
$operator = ($negate) ? 'NOT IN' : 'IN';
$field = ($negate) ? 'n_' . $field_name : $field_name;
$index = 0;
foreach ($values as $value) {
$value = trim($value);
$query_params['filter[' . $field . '][value][' . $index . ']'] = $value;
$index++;
}
$query_params['filter[' . $field . '][operator]'] = $operator;
$query_params['filter[' . $field . '][path]'] = $field_name;
}
return $query_params;
}
/**
* Add the core version filters to the query.
*
* @param array $query_params
* Query params that will be passed to the request.
*
* @return array
* New list of params containing the new filters.
*/
protected function addCoreVersionCheck(array $query_params): array {
$current_drupal_version = $this->getNumericSemverVersion(\Drupal::VERSION);
if ($current_drupal_version) {
$field = 'core_semver_minimum';
$query_params['filter[' . $field . '][value]'] = $current_drupal_version;
$query_params['filter[' . $field . '][operator]'] = '<=';
$query_params['filter[' . $field . '][path]'] = $field;
$field = 'core_semver_maximum';
$query_params['filter[' . $field . '][value]'] = $current_drupal_version;
$query_params['filter[' . $field . '][operator]'] = '>=';
$query_params['filter[' . $field . '][path]'] = $field;
}
return $query_params;
}
/**
* Returns the filter values from the www.drupal.org endpoint.
*
* @return array
* Filter values by taxonomy.
*/
protected function filterValues(): array {
$values = [];
$url = self::DRUPAL_ORG_ENDPOINT . '/drupalorg-api/project-browser-filters';
$filter_values = $this->cacheBin->get('DrupalDotOrgJsonApi:filter_values');
if ($filter_values) {
$values = $filter_values->data;
}
else {
$expiry_time = $this->time->getRequestTime() + 3600;
try {
$response = $this->httpClient->request('GET', $url);
$values = Json::decode($response->getBody()->getContents());
$this->cacheBin->set('DrupalDotOrgJsonApi:filter_values', $values, $expiry_time);
}
catch (GuzzleException $exception) {
$this->logger->error($exception->getMessage());
}
catch (\Throwable $exception) {
$this->logger->error($exception->getMessage());
}
}
return $values;
}
/**
* {@inheritdoc}
*/
protected function convertQueryOptions(array $query = []): array {
$filter_values = $this->filterValues();
$active_values = $filter_values['active'] ?? [];
$maintained_values = $filter_values['maintained'] ?? [];
// Sort options.
$sort = NULL;
if (!empty($query['sort'])) {
$sort = $this->mapSortDirection($query['sort']);
if (in_array($sort, ['ASC', 'DESC'])) {
$sort = ($sort == 'DESC') ? '-' : '';
$sort_field = $this->mapSortField($query['sort']);
$sort = ($sort_field) ? $sort . $sort_field : FALSE;
}
}
$query['sort'] = $sort;
// Maintenance options.
$maintenance = NULL;
if (!empty($query['maintenance_status'])) {
if ($query['maintenance_status'] == MaintenanceStatus::Maintained->value) {
$maintenance = implode(',', $maintained_values);
}
}
$query['maintenance_status'] = $maintenance;
// Development options.
$development = NULL;
if (!empty($query['development_status'])) {
if ($query['development_status'] == DevelopmentStatus::Active->value) {
$development = implode(',', $active_values);
}
}
$query['development_status'] = $development;
// Security options.
$security = NULL;
if (!empty($query['security_advisory_coverage'])) {
if ($query['security_advisory_coverage'] == SecurityStatus::Covered->value) {
$security = implode(',', self::COVERED_VALUES);
}
}
$query['security_advisory_coverage'] = $security;
// Defaults in case none is given.
$query['page'] = $query['page'] ?? 0;
$query['limit'] = $query['limit'] ?? 12;
return $query;
}
/**
* {@inheritdoc}
*/
public function getSortOptions(): array {
return [
'best_match' => [
'id' => 'best_match',
'text' => $this->t('Most relevant'),
],
'created' => [
'id' => 'created',
'text' => $this->t('Newest first'),
],
];
}
}