refactor(Fetcher): properly type AppStore fetcher

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-04-28 16:32:45 +02:00
parent e6a1bfd354
commit e7a2f660c7
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
9 changed files with 224 additions and 32 deletions

View file

@ -128,7 +128,7 @@ class AppSettingsController extends Controller {
#[NoCSRFRequired]
public function getAppDiscoverJSON(): JSONResponse {
$data = $this->discoverFetcher->get(true);
return new JSONResponse(array_values($data));
return new JSONResponse($data);
}
/**

View file

@ -1161,6 +1161,7 @@ return array(
'OC\\App\\AppStore\\Fetcher\\AppFetcher' => $baseDir . '/lib/private/App/AppStore/Fetcher/AppFetcher.php',
'OC\\App\\AppStore\\Fetcher\\CategoryFetcher' => $baseDir . '/lib/private/App/AppStore/Fetcher/CategoryFetcher.php',
'OC\\App\\AppStore\\Fetcher\\Fetcher' => $baseDir . '/lib/private/App/AppStore/Fetcher/Fetcher.php',
'OC\\App\\AppStore\\Fetcher\\ResponseDefinitions' => $baseDir . '/lib/private/App/AppStore/Fetcher/ResponseDefinitions.php',
'OC\\App\\AppStore\\Version\\Version' => $baseDir . '/lib/private/App/AppStore/Version/Version.php',
'OC\\App\\AppStore\\Version\\VersionParser' => $baseDir . '/lib/private/App/AppStore/Version/VersionParser.php',
'OC\\App\\CompareVersion' => $baseDir . '/lib/private/App/CompareVersion.php',

View file

@ -1202,6 +1202,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\App\\AppStore\\Fetcher\\AppFetcher' => __DIR__ . '/../../..' . '/lib/private/App/AppStore/Fetcher/AppFetcher.php',
'OC\\App\\AppStore\\Fetcher\\CategoryFetcher' => __DIR__ . '/../../..' . '/lib/private/App/AppStore/Fetcher/CategoryFetcher.php',
'OC\\App\\AppStore\\Fetcher\\Fetcher' => __DIR__ . '/../../..' . '/lib/private/App/AppStore/Fetcher/Fetcher.php',
'OC\\App\\AppStore\\Fetcher\\ResponseDefinitions' => __DIR__ . '/../../..' . '/lib/private/App/AppStore/Fetcher/ResponseDefinitions.php',
'OC\\App\\AppStore\\Version\\Version' => __DIR__ . '/../../..' . '/lib/private/App/AppStore/Version/Version.php',
'OC\\App\\AppStore\\Version\\VersionParser' => __DIR__ . '/../../..' . '/lib/private/App/AppStore/Version/VersionParser.php',
'OC\\App\\CompareVersion' => __DIR__ . '/../../..' . '/lib/private/App/CompareVersion.php',

View file

@ -16,6 +16,12 @@ use OCP\IConfig;
use OCP\Support\Subscription\IRegistry;
use Psr\Log\LoggerInterface;
/**
* Fetch app discover section entries from the app store
*
* @psalm-import-type AppStoreFetcherDiscoverElement from ResponseDefinitions
* @template-extends Fetcher<AppStoreFetcherDiscoverElement>
*/
class AppDiscoverFetcher extends Fetcher {
public const INVALIDATE_AFTER_SECONDS = 86400;
@ -46,13 +52,14 @@ class AppDiscoverFetcher extends Fetcher {
* Get the app discover section entries
*
* @param bool $allowUnstable Include also upcoming entries
* @return list<AppStoreFetcherDiscoverElement>
*/
#[\Override]
public function get($allowUnstable = false) {
public function get($allowUnstable = false): array {
$entries = parent::get(false);
$now = new DateTimeImmutable();
return array_filter($entries, function (array $entry) use ($now, $allowUnstable) {
return array_values(array_filter($entries, function (array $entry) use ($now, $allowUnstable) {
// Always remove expired entries
if (isset($entry['expiryDate'])) {
try {
@ -80,7 +87,7 @@ class AppDiscoverFetcher extends Fetcher {
}
// Otherwise the entry is not time limited and should stay
return true;
});
}));
}
public function getETag(): ?string {

View file

@ -15,6 +15,13 @@ use OCP\IConfig;
use OCP\Support\Subscription\IRegistry;
use Psr\Log\LoggerInterface;
/**
* Fetch app data from the app store
*
* @psalm-import-type AppStoreFetcherApp from ResponseDefinitions
* @psalm-import-type AppStoreFetcherAppReleasesEntry from ResponseDefinitions
* @template-extends Fetcher<AppStoreFetcherApp>
*/
class AppFetcher extends Fetcher {
/** @var bool */
private $ignoreMaxVersion;
@ -45,15 +52,10 @@ class AppFetcher extends Fetcher {
/**
* Only returns the latest compatible app release in the releases array
*
* @param string $ETag
* @param string $content
* @param bool [$allowUnstable] Allow unstable releases
*
* @return array
* @inheritDoc
*/
#[\Override]
protected function fetch($ETag, $content, $allowUnstable = false) {
/** @var mixed[] $response */
protected function fetch($ETag, $content, $allowUnstable = false): array {
$response = parent::fetch($ETag, $content);
if (!isset($response['data']) || $response['data'] === null) {
@ -65,6 +67,7 @@ class AppFetcher extends Fetcher {
$allowNightly = $allowUnstable || $this->getChannel() === 'daily' || $this->getChannel() === 'git';
foreach ($response['data'] as $dataKey => $app) {
/** @var list<AppStoreFetcherAppReleasesEntry> $releases */
$releases = [];
// Filter all compatible releases
@ -113,7 +116,7 @@ class AppFetcher extends Fetcher {
if (empty($releases)) {
// Remove apps that don't have a matching release
$response['data'][$dataKey] = [];
unset($response['data'][$dataKey]);
continue;
}
@ -141,11 +144,6 @@ class AppFetcher extends Fetcher {
return $response;
}
/**
* @param string $version
* @param string $fileName
* @param bool $ignoreMaxVersion
*/
#[\Override]
public function setVersion(string $version, string $fileName = 'apps.json', bool $ignoreMaxVersion = true) {
parent::setVersion($version);
@ -165,9 +163,9 @@ class AppFetcher extends Fetcher {
// If the admin specified a allow list, filter apps from the appstore
if (is_array($allowList) && $this->registry->delegateHasValidSubscription()) {
return array_filter($apps, function ($app) use ($allowList) {
return array_values(array_filter($apps, function (array $app) use ($allowList) {
return in_array($app['id'], $allowList);
});
}));
}
return $apps;

View file

@ -15,6 +15,14 @@ use OCP\IConfig;
use OCP\Support\Subscription\IRegistry;
use Psr\Log\LoggerInterface;
/**
* Fetch categories from the app store server.
* The categories are listed as an array containing the id and the translations for the category.
* The key of the translations array is the language code and the value is an array containing the name and description of the category.
*
* @psalm-import-type AppStoreFetcherCategory from ResponseDefinitions
* @template-extends Fetcher<AppStoreFetcherCategory>
*/
class CategoryFetcher extends Fetcher {
public function __construct(
Factory $appDataFactory,

View file

@ -21,6 +21,11 @@ use OCP\ServerVersion;
use OCP\Support\Subscription\IRegistry;
use Psr\Log\LoggerInterface;
/**
* Base class for fetching app store data
*
* @template T of array
*/
abstract class Fetcher {
public const INVALIDATE_AFTER_SECONDS = 3600;
public const INVALIDATE_AFTER_SECONDS_UNSTABLE = 900;
@ -53,18 +58,19 @@ abstract class Fetcher {
/**
* Fetches the response from the server
*
* @param string $ETag
* @param string $content
* @param string $ETag - The ETag of the cached response
* @param string $content - The content of the response
* @param bool $allowUnstable - Allow unstable releases
*
* @return array
* @return array{data: list<T>, ETag?: string, timestamp: int, ncversion: string}|array<never, never>
*/
protected function fetch($ETag, $content, $allowUnstable = false) {
$appstoreenabled = $this->config->getSystemValueBool('appstoreenabled', true);
protected function fetch($ETag, $content, $allowUnstable = false): array {
$appstoreEnabled = $this->config->getSystemValueBool('appstoreenabled', true);
if ((int)$this->config->getAppValue('settings', 'appstore-fetcher-lastFailure', '0') > time() - self::RETRY_AFTER_FAILURE_SECONDS) {
return [];
}
if (!$appstoreenabled) {
if (!$appstoreEnabled) {
return [];
}
@ -117,15 +123,15 @@ abstract class Fetcher {
/**
* Returns the array with the entries on the appstore server
*
* @param bool [$allowUnstable] Allow unstable releases
* @return array
* @param bool $allowUnstable - Allow unstable releases
* @return list<T>
*/
public function get($allowUnstable = false) {
$appstoreenabled = $this->config->getSystemValueBool('appstoreenabled', true);
$internetavailable = $this->config->getSystemValueBool('has_internet_connection', true);
public function get($allowUnstable = false): array {
$appstoreEnabled = $this->config->getSystemValueBool('appstoreenabled', true);
$internetAvailable = $this->config->getSystemValueBool('has_internet_connection', true);
$isDefaultAppStore = $this->config->getSystemValueString('appstoreurl', self::APP_STORE_URL) === self::APP_STORE_URL;
if (!$appstoreenabled || (!$internetavailable && $isDefaultAppStore)) {
if (!$appstoreEnabled || (!$internetAvailable && $isDefaultAppStore)) {
$this->logger->info('AppStore is disabled or this instance has no Internet connection to access the default app store', ['app' => 'appstoreFetcher']);
return [];
}

View file

@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OC\App\AppStore\Fetcher;
/**
* @psalm-type AppStoreFetcherCategory = array{
* id: string,
* translations: array<string, array{
* name: string,
* description: string,
* }>,
* }
*
* @psalm-type AppStoreFetcherAppAuthor = array{
* name: string,
* mail: string,
* homepage: string,
* }
*
* @psalm-type AppStoreFetcherAppScreenshot = array{
* url: string,
* smallThumbnail: string,
* }
*
* @psalm-type AppStoreFetcherAppTranslationsEntry = array{
* name: string,
* summary: string,
* description: string,
* }
*
* @psalm-type AppStoreFetcherAppTranslations = array{ en: AppStoreFetcherAppTranslationsEntry } & array<string, AppStoreFetcherAppTranslationsEntry>
*
* @psalm-type AppStoreFetcherAppReleasesEntryTranslations = array{ en: array{ changelog: string } } & array<string, array{ changelog: string }>
*
* @psalm-type AppStoreFetcherAppReleasesEntryRequirements = array{
* id: string,
* versionSpec: string,
* rawVersionSpec: string,
* }
*
* @psalm-type AppStoreFetcherAppReleasesEntry = array{
* version: string,
* phpExtensions?: list<AppStoreFetcherAppReleasesEntryRequirements>,
* databases?: list<AppStoreFetcherAppReleasesEntryRequirements>,
* shellCommands?: list<string>,
* phpVersionSpec: string,
* platformVersionSpec: string,
* minIntSize: int,
* download: string,
* created: string,
* licenses?: list<string>,
* lastModified: string,
* isNightly: boolean,
* rawPhpVersionSpec: string,
* rawPlatformVersionSpec: string,
* signature: string,
* translations: AppStoreFetcherAppReleasesEntryTranslations,
* signatureDigest: string,
* }
*
* @psalm-type AppStoreFetcherApp = array{
* id: string,
* authors?: list<AppStoreFetcherAppAuthor>,
* categories: string[],
* certificate: string,
* created: string,
* lastModified: string,
* translations: AppStoreFetcherAppTranslations,
* releases?: list<AppStoreFetcherAppReleasesEntry>,
* screenshots?: list<AppStoreFetcherAppScreenshot>,
* adminDocs: string,
* userDocs: string,
* developerDocs: string,
* discussion: string,
* issueTracker: string,
* website: string,
* isFeatured: boolean,
* ratingRecent: float,
* ratingOverall: float,
* ratingNumRecent: int,
* ratingNumOverall: int,
* }
*
* @psalm-type AppStoreFetcherDiscoverLocalizedString = array{en: string} & array<string, string>
*
* @psalm-type AppStoreFetcherDiscoverMediaSource = array{
* mime: string,
* src: string,
* }
*
* @psalm-type AppStoreFetcherDiscoverMediaContent = array{
* src: AppStoreFetcherDiscoverMediaSource|list<AppStoreFetcherDiscoverMediaSource>,
* alt: string,
* link?: string,
* }
*
* @psalm-type AppStoreFetcherDiscoverLocalizedMediaContent = array{en: AppStoreFetcherDiscoverMediaContent} & array<string, AppStoreFetcherDiscoverMediaContent>
*
* @psalm-type AppStoreFetcherDiscoverMediaObject = array{
* content: AppStoreFetcherDiscoverLocalizedMediaContent,
* alignment?: 'start'|'end'|'center',
* }
*
* @psalm-type AppStoreFetcherDiscoverContainerBase = array{
* id?: string,
* order?: int,
* headline?: AppStoreFetcherDiscoverLocalizedString,
* text?: AppStoreFetcherDiscoverLocalizedString,
* link?: string,
* date?: string,
* expiryDate?: string,
* }
*
* @psalm-type AppStoreFetcherDiscoverAppElement = array{
* type: 'app',
* appId: string,
* }
*
* @psalm-type AppStoreFetcherDiscoverPostElement = AppStoreFetcherDiscoverContainerBase & array{
* type: 'post',
* media?: AppStoreFetcherDiscoverMediaObject,
* }
*
* @psalm-type AppStoreFetcherDiscoverShowcaseElement = AppStoreFetcherDiscoverContainerBase & array{
* type: 'showcase',
* content: list<AppStoreFetcherDiscoverAppElement|AppStoreFetcherDiscoverPostElement>,
* }
*
* @psalm-type AppStoreFetcherDiscoverCarouselElement = AppStoreFetcherDiscoverContainerBase & array{
* type: 'carousel',
* content: list<AppStoreFetcherDiscoverPostElement>,
* }
*
* @psalm-type AppStoreFetcherDiscoverElement =
* AppStoreFetcherDiscoverPostElement|
* AppStoreFetcherDiscoverShowcaseElement|
* AppStoreFetcherDiscoverCarouselElement
*/
final class ResponseDefinitions {
}

View file

@ -29,7 +29,7 @@ class DependencyAnalyzer {
}
/**
* @return array of missing dependencies
* @return list<string> of missing dependencies
*/
public function analyze(array $appInfo, bool $ignoreMax = false): array {
if (isset($appInfo['dependencies'])) {
@ -104,6 +104,9 @@ class DependencyAnalyzer {
return $this->compare($first, $second, '<');
}
/**
* @return list<string> of missing dependencies
*/
private function analyzePhpVersion(array $dependencies): array {
$missing = [];
if (isset($dependencies['php']['@attributes']['min-version'])) {
@ -127,6 +130,9 @@ class DependencyAnalyzer {
return $missing;
}
/**
* @return list<string> of missing dependencies
*/
private function analyzeArchitecture(array $dependencies): array {
$missing = [];
if (!isset($dependencies['architecture'])) {
@ -150,6 +156,9 @@ class DependencyAnalyzer {
return $missing;
}
/**
* @return list<string> of missing dependencies
*/
private function analyzeDatabases(array $dependencies): array {
$missing = [];
if (!isset($dependencies['database'])) {
@ -176,6 +185,9 @@ class DependencyAnalyzer {
return $missing;
}
/**
* @return list<string> of missing dependencies
*/
private function analyzeCommands(array $dependencies): array {
$missing = [];
if (!isset($dependencies['command'])) {
@ -202,6 +214,9 @@ class DependencyAnalyzer {
return $missing;
}
/**
* @return list<string> of missing dependencies
*/
private function analyzeLibraries(array $dependencies): array {
$missing = [];
if (!isset($dependencies['lib'])) {
@ -243,6 +258,9 @@ class DependencyAnalyzer {
return $missing;
}
/**
* @return list<string> of missing dependencies
*/
private function analyzeOS(array $dependencies): array {
$missing = [];
if (!isset($dependencies['os'])) {
@ -267,10 +285,16 @@ class DependencyAnalyzer {
return $missing;
}
/**
* @return list<string> of missing dependencies
*/
private function analyzeServer(array $appInfo, bool $ignoreMax): array {
return $this->analyzeServerVersion($this->platform->getOcVersion(), $appInfo, $ignoreMax);
}
/**
* @return list<string> of missing dependencies
*/
public function analyzeServerVersion(string $serverVersion, array $appInfo, bool $ignoreMax): array {
$missing = [];
$minVersion = null;