Convert Resource Request/Response classes to Bag (#225)

* refactor: use Bag transformers for (Plugin|Theme)HotTagsResponse

* refactor: replace more static constructors with Transforms

* zap: rm unused ThemeUpdateCheckTranslationCollection (whew) VO

* refactor: inline ThemeUpdateData::fromModelCollection

* refactor: embaggify QueryPluginsRequest

* refactor: Bag up PluginInformationRequest

* refactor: throw PluginUpdateRequest into the Bag

* test: move phpstan.neon -> phpstan.dist.neon

* refactor: tighten up update check request types

* test: use more realistic plugin filenames in update check tests

* refactor: use single query for plugin update check

* refactor: convert plugin updates to Bag (i'm out of "bag" neologisms)

* refactor: use ThemeResponse VO for theme info requests

* refactor: commit baggravated assault on ThemeCollection/ThemeResource ;)

* refactor: bag up the rest of the Resource types

* tweak: slipstream in dependabot upgrade
This commit is contained in:
Chuck Adams 2025-04-02 20:40:32 -06:00 committed by GitHub
parent 60f4feb4e5
commit d9db849e11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 843 additions and 984 deletions

View file

@ -15,7 +15,7 @@ jobs:

steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
with:
egress-policy: audit


4
.gitignore vendored
View file

@ -22,9 +22,9 @@ yarn-error.log
/.vscode
/.zed

phpstan.neon

# helper files would not normally be ignored, but they're currently broken, so it's a local decision to use them
_ide_helper.php
.phpstorm.meta.php

grumphp.yml
π

View file

@ -3,15 +3,14 @@
namespace App\Http\Controllers\API\WpOrg\Plugins;

use App\Http\Controllers\Controller;
use App\Http\Requests\Plugins\PluginInformationRequest;
use App\Http\Requests\Plugins\QueryPluginsRequest;
use App\Http\Resources\Plugins\ClosedPluginResource;
use App\Http\Resources\Plugins\PluginCollection;
use App\Http\Resources\Plugins\PluginResource;
use App\Models\WpOrg\ClosedPlugin;
use App\Services\Plugins\PluginHotTagsService;
use App\Services\Plugins\PluginInformationService;
use App\Services\Plugins\QueryPluginsService;
use App\Values\WpOrg\Plugins\ClosedPluginResponse;
use App\Values\WpOrg\Plugins\PluginInformationRequest;
use App\Values\WpOrg\Plugins\PluginResponse;
use App\Values\WpOrg\Plugins\QueryPluginsRequest;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

@ -26,31 +25,26 @@ class PluginInformation_1_2_Controller extends Controller
public function __invoke(Request $request): JsonResponse
{
return match ($request->query('action')) {
'query_plugins' => $this->queryPlugins(new QueryPluginsRequest($request->all())),
'plugin_information' => $this->pluginInformation(new PluginInformationRequest($request->all())),
'query_plugins' => $this->queryPlugins(QueryPluginsRequest::from($request)),
'plugin_information' => $this->pluginInformation(PluginInformationRequest::from($request)),
'hot_tags', 'popular_tags' => $this->hotTags($request),
default => response()->json(['error' => 'Invalid action'], 400),
};
}

private function pluginInformation(PluginInformationRequest $request): JsonResponse
private function pluginInformation(PluginInformationRequest $req): JsonResponse
{
$slug = $request->getSlug();
if (!$slug) {
return response()->json(['error' => 'Slug is required'], 400);
}

$plugin = $this->pluginInfo->findBySlug($request->getSlug());
$plugin = $this->pluginInfo->findBySlug($req->slug);

if (!$plugin) {
return response()->json(['error' => 'Plugin not found'], 404);
}

if ($plugin instanceof ClosedPlugin) {
$resource = new ClosedPluginResource($plugin);
$resource = ClosedPluginResponse::from($plugin);
$status = 404;
} else {
$resource = new PluginResource($plugin);
$resource = PluginResponse::from($plugin)->asPluginInformationResponse();
$status = 200;
}
return response()->json($resource, $status);
@ -58,21 +52,8 @@ class PluginInformation_1_2_Controller extends Controller

private function queryPlugins(QueryPluginsRequest $request): JsonResponse
{
$result = $this->queryPlugins->queryPlugins(
page: $request->getPage(),
perPage: $request->getPerPage(),
search: $request->query('search'),
tag: $request->query('tag'),
author: $request->query('author'),
browse: $request->getBrowse(),
);

return response()->json(new PluginCollection(
PluginResource::collection($result['plugins']),
$result['page'],
$result['totalPages'],
$result['total'],
));
$result = $this->queryPlugins->queryPlugins($request);
return response()->json($result);
}

private function hotTags(Request $request): JsonResponse

View file

@ -3,38 +3,25 @@
namespace App\Http\Controllers\API\WpOrg\Plugins;

use App\Http\Controllers\Controller;
use App\Http\Requests\Plugins\PluginUpdateRequest;
use App\Http\Resources\Plugins\PluginUpdateCollection;
use App\Services\Plugins\PluginUpdateService;
use App\Values\WpOrg\Plugins\PluginUpdateCheckRequest;
use Illuminate\Http\JsonResponse;

use function Safe\json_decode;
use Illuminate\Http\Request;

class PluginUpdateCheck_1_1_Controller extends Controller
{
public function __construct(
private readonly PluginUpdateService $pluginService,
private readonly PluginUpdateService $updateService,
) {}

public function __invoke(PluginUpdateRequest $request): JsonResponse
public function __invoke(Request $request): JsonResponse
{
$pluginsData = json_decode($request->plugins, true);
// Bag's Laravel autoconversion doesn't work right, so do it by hand.
$req = PluginUpdateCheckRequest::from($request);

$result = $this->pluginService->processPlugins(
plugins: $pluginsData['plugins'],
includeAll: $request->boolean('all'),
);
$result = $this->updateService->checkForUpdates($req);
$req->all or $result = $result->withoutNoUpdate(); // we already generated the list, so just drop it

$response = [
'plugins' => new PluginUpdateCollection($result['updates']),
'translations' => [],
];

// Only include no_update when 'all' parameter is true
if ($request->boolean('all')) {
$response['no_update'] = new PluginUpdateCollection($result['no_updates']);
}

return response()->json($response);
return response()->json($result);
}
}

View file

@ -4,14 +4,14 @@ namespace App\Http\Controllers\API\WpOrg\Themes;

use App\Exceptions\NotFoundException;
use App\Http\Controllers\Controller;
use App\Http\Resources\ThemeCollection;
use App\Http\Resources\ThemeResource;
use App\Services\Themes\FeatureListService;
use App\Services\Themes\QueryThemesService;
use App\Services\Themes\ThemeHotTagsService;
use App\Services\Themes\ThemeInformationService;
use App\Values\WpOrg\Themes\QueryThemesRequest;
use App\Values\WpOrg\Themes\QueryThemesResponse;
use App\Values\WpOrg\Themes\ThemeInformationRequest;
use App\Values\WpOrg\Themes\ThemeResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@ -49,7 +49,7 @@ class ThemeController extends Controller

private function doQueryThemes(Request $request): JsonResponse|Response
{
$req = QueryThemesRequest::fromRequest($request);
$req = QueryThemesRequest::from($request);
$themes = $this->queryThemes->queryThemes($req);
return $this->sendResponse($themes);
}
@ -57,7 +57,8 @@ class ThemeController extends Controller
private function doThemeInformation(Request $request): JsonResponse|Response
{
// NOTE: upstream requires slug query parameter to be request[slug], just slug is not recognized
$response = $this->themeInfo->info(ThemeInformationRequest::fromRequest($request));
$req = ThemeInformationRequest::fromRequest($request);
$response = $this->themeInfo->info($req);
return $this->sendResponse($response);
}

@ -85,10 +86,10 @@ class ThemeController extends Controller
/**
* Send response based on API version.
*
* @param array<string,mixed>|ThemeCollection $response
* @param array<string,mixed>|QueryThemesResponse|ThemeResponse $response
*/
private function sendResponse(
array|ThemeCollection|ThemeResource $response,
array|QueryThemesResponse|ThemeResponse $response,
int $statusCode = 200,
): JsonResponse|Response {
$version = request()->route('version');

View file

@ -13,21 +13,18 @@ use Illuminate\Validation\ValidationException;

class ThemeUpdatesController extends Controller
{
/**
* @return JsonResponse
*/
public function __invoke(Request $request): JsonResponse|Response
{
try {
$updateRequest = ThemeUpdateCheckRequest::fromRequest($request);
$req = ThemeUpdateCheckRequest::from($request);

$themes = Theme::query()
->whereIn('slug', array_keys($updateRequest->themes))
->whereIn('slug', array_keys($req->themes))
->get()
->partition(function ($theme) use ($updateRequest) {
return version_compare($theme->version, $updateRequest->themes[$theme->slug]['Version'], '>');
});
return $this->sendResponse(ThemeUpdateCheckResponse::fromData($themes[0], $themes[1]));
->partition(fn($theme) => version_compare($theme->version, $req->themes[$theme->slug]['Version'], '>'));

return $this->sendResponse(ThemeUpdateCheckResponse::fromResults($themes[0], $themes[1]));

} catch (ValidationException $e) {
// Handle validation errors and return a custom response
$firstErrorMessage = collect($e->errors())->flatten()->first();

View file

@ -1,47 +0,0 @@
<?php

namespace App\Http\Requests\Plugins;

use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;

class PluginInformationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'slug' => ['required', 'string'],
];
}

/**
* Get the plugin slug from the request
*/
public function getSlug(): ?string
{
return $this->query('slug');
}

/**
* Handle a failed validation attempt.
*/
protected function failedValidation(Validator $validator): void
{
throw new HttpResponseException(
response()->json([
'error' => 'Slug is required',
], 400),
);
}
}

View file

@ -1,54 +0,0 @@
<?php

namespace App\Http\Requests\Plugins;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;

use function Safe\json_decode;

class PluginUpdateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}

/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'plugins' => ['required', 'string', 'json'],
'translations' => ['required', 'string'],
'locale' => ['required', 'string'],
'all' => ['sometimes', 'string', 'in:true,false'],
];
}

/**
* Configure the validator instance.
*/
public function withValidator(Validator $validator): void
{
$validator->after(function ($validator) {
if ($validator->failed()) {
return;
}

$plugins = json_decode($this->plugins, true);
if (!isset($plugins['plugins']) || !is_array($plugins['plugins'])) {
$validator->errors()->add(
'plugins',
'The plugins JSON must contain a "plugins" array.',
);
}
});
}
}

View file

@ -1,45 +0,0 @@
<?php

namespace App\Http\Requests\Plugins;

use Illuminate\Foundation\Http\FormRequest;

class QueryPluginsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<int, mixed>>
*/
public function rules(): array
{
return [
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
'search' => ['sometimes', 'string'],
'tag' => ['sometimes', 'string'],
'author' => ['sometimes', 'string'],
'browse' => ['sometimes', 'string', 'in:new,updated,top-rated,popular'],
];
}

public function getPage(): int
{
return max(1, (int) $this->query('page', '1'));
}

public function getPerPage(): int
{
return (int) $this->query('per_page', '24');
}

public function getBrowse(): string
{
return $this->query('browse', 'popular');
}
}

View file

@ -1,41 +0,0 @@
<?php

namespace App\Http\Resources\Plugins;

use App\Models\WpOrg\Plugin;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;

abstract class BasePluginResource extends JsonResource
{
/**
* Get common plugin attributes that are shared across different contexts
*
* @return array<string, mixed>
*/
protected function getCommonAttributes(): array
{
$plugin = $this->resource;
assert($plugin instanceof Plugin);

return [
'name' => $plugin->name,
'slug' => $plugin->slug,
'version' => $plugin->version,
'requires' => $plugin->requires,
'tested' => $plugin->tested,
'requires_php' => $plugin->requires_php,
'download_link' => $plugin->download_link,
];
}

/**
* @param int[]|null $ratings
* @return Collection<string, int>
*/
protected function mapRatings(?array $ratings): Collection
{
return collect($ratings ?? [])
->mapWithKeys(fn($value, $key) => [(string) $key => $value]);
}
}

View file

@ -1,27 +0,0 @@
<?php

namespace App\Http\Resources\Plugins;

use App\Models\WpOrg\ClosedPlugin;
use Illuminate\Http\Request;

class ClosedPluginResource extends BasePluginResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$plugin = $this->resource;
assert($plugin instanceof ClosedPlugin);

return [
'error' => 'closed',
'name' => $plugin->name,
'slug' => $plugin->slug,
'description' => $plugin->description,
'closed' => true,
'closed_date' => $plugin->closed_date->format('Y-m-d'),
'reason' => $plugin->reason,
'reason_text' => $plugin->getReasonText(),
];
}
}

View file

@ -1,38 +0,0 @@
<?php

namespace App\Http\Resources\Plugins;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;

class PluginCollection extends ResourceCollection
{
private int $page;
private int $pages;
private int $results;

public function __construct($resource, int $page, int $pages, int $results)
{
parent::__construct($resource);
$this->page = $page;
$this->pages = $pages;
$this->results = $results;
}

/**
* Transform the resource collection into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'info' => [
'page' => $this->page,
'pages' => $this->pages,
'results' => $this->results,
],
'plugins' => $this->collection,
];
}
}

View file

@ -1,73 +0,0 @@
<?php

namespace App\Http\Resources\Plugins;

use App\Models\WpOrg\Plugin;
use DateTimeInterface;
use Illuminate\Http\Request;

class PluginResource extends BasePluginResource
{
public const LAST_UPDATED_DATE_FORMAT = 'Y-m-d h:ia T'; // .org's goofy format: "2024-09-27 9:53pm GMT"

/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$plugin = $this->resource;
assert($plugin instanceof Plugin);

$data = array_merge($this->getCommonAttributes(), [
'author' => $plugin->author,
'author_profile' => $plugin->author_profile,
'rating' => $plugin->rating,
'num_ratings' => $plugin->num_ratings,
'ratings' => $this->mapRatings($plugin->ratings),
'support_threads' => $plugin->support_threads,
'support_threads_resolved' => $plugin->support_threads_resolved,
'active_installs' => $plugin->active_installs,
'last_updated' => self::formatLastUpdated($plugin->last_updated),
'added' => $plugin->added?->format('Y-m-d'),
'homepage' => $plugin->homepage,
'tags' => $plugin->tagsArray(),
'donate_link' => $plugin->donate_link,
'requires_plugins' => $plugin->requires_plugins,
]);

return match ($request->query('action')) {
'query_plugins' => array_merge($data, [
'downloaded' => $plugin->downloaded,
'short_description' => $plugin->short_description,
'description' => $plugin->description,
'icons' => $plugin->icons,
]),
'plugin_information' => array_merge($data, [
'sections' => $plugin->sections,
'versions' => $plugin->versions,
'contributors' => $plugin->contributors,
'screenshots' => $plugin->screenshots,
'support_url' => $plugin->support_url,
'upgrade_notice' => $plugin->upgrade_notice,
'business_model' => $plugin->business_model,
'repository_url' => $plugin->repository_url,
'commercial_support_url' => $plugin->commercial_support_url,
'banners' => $plugin->banners,
'preview_link' => $plugin->preview_link,
]),
default => $data,
};
}

private static function formatLastUpdated(?DateTimeInterface $lastUpdated): ?string
{
if ($lastUpdated === null) {
return null;
}
$out = $lastUpdated->format(self::LAST_UPDATED_DATE_FORMAT);
// Unfortunately this seems to render GMT as "GMT+0000" for some reason, so strip that out
return \Safe\preg_replace('/\+\d+$/', '', $out);
}
}

View file

@ -1,19 +0,0 @@
<?php

namespace App\Http\Resources\Plugins;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;

class PluginUpdateCollection extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return $this->collection->all();
}
}

View file

@ -1,33 +0,0 @@
<?php

namespace App\Http\Resources\Plugins;

use Illuminate\Http\Request;

class PluginUpdateResource extends BasePluginResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => "w.org/plugins/{$this->resource['slug']}",
'slug' => $this->resource['slug'],
'plugin' => $this->resource['plugin'],
'new_version' => $this->resource['new_version'],
'url' => "https://wordpress.org/plugins/{$this->resource['slug']}/",
'package' => $this->resource['package'],
'icons' => $this->resource['icons'],
'banners' => $this->resource['banners'],
'banners_rtl' => $this->resource['banners_rtl'] ?? [],
'requires' => $this->resource['requires'],
'tested' => $this->resource['tested'],
'requires_php' => $this->resource['requires_php'],
'requires_plugins' => $this->resource['requires_plugins'] ?? [],
'compatibility' => $this->resource['compatibility'] ?? [],
];
}
}

View file

@ -1,38 +0,0 @@
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;

class ThemeCollection extends ResourceCollection
{
private int $page;
private int $pages;
private int $results;

public function __construct($resource, int $page, int $pages, int $results)
{
parent::__construct($resource);
$this->page = $page;
$this->pages = $pages;
$this->results = $results;
}

/**
* Transform the resource collection into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'info' => [
'page' => $this->page,
'pages' => $this->pages,
'results' => $this->results,
],
'themes' => $this->collection,
];
}
}

View file

@ -1,118 +0,0 @@
<?php

namespace App\Http\Resources;

use App\Models\WpOrg\Author;
use App\Models\WpOrg\Theme;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class ThemeResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array{
* name: string,
* slug: string,
* version: string,
* preview_url: string,
* author: Author,
* screenshot_url: string,
* ratings: array{1:int, 2:int, 3:int, 4:int, 5:int},
* rating: int,
* num_ratings: int,
* reviews_url: string,
* downloaded: int,
* active_installs: int,
* last_updated: CarbonImmutable,
* last_updated_time: CarbonImmutable,
* creation_time: CarbonImmutable,
* homepage: string,
* sections: array<string, string>,
* download_link: string,
* tags: array<string, string>,
* versions: array<string, string>,
* requires: array<string, string>,
* requires_php: string,
* is_commercial: bool,
* external_support_url: string|bool,
* is_community: bool,
* external_repository_url: string
* }
*/
public function toArray(Request $request): array
{
$resource = $this->resource;
assert($resource instanceof Theme);
$author = $resource->author->toArray();
unset($author['id']);

$tags = $resource->tagsArray();
ksort($tags);

$screenshotBase = "https://wp-themes.com/wp-content/themes/{$resource->slug}/screenshot";
return [
'name' => $resource->name,
'slug' => $resource->slug,
'version' => $resource->version,
'preview_url' => $resource->preview_url,
'author' => $this->whenField('extended_author', $author, $resource->author->user_nicename),
'description' => $this->whenField('description', fn() => $resource->description),
'screenshot_url' => $this->whenField('screenshot_url', fn() => $resource->screenshot_url),
// TODO: support screenshots metadata once I can track it down
// 'screenshot_count' => $this->whenField('screenshot_count', fn() => max($resource->screenshot_count ?? 1, 1)),
// 'screenshots' => $this->whenField('screenshots', function () use ($screenshotBase) {
// $screenshotCount = max($resource->screenshot_count ?? 1, 1);
// return collect(range(1, $screenshotCount))->map(fn($i) => "{$screenshotBase}-{$i}.png");
// }),
'ratings' => $this->whenField('ratings', fn() => (object) $resource->ratings), // need the object cast when all keys are numeric
'rating' => $this->whenField('rating', fn() => $resource->rating),
'num_ratings' => $this->whenField('rating', fn() => $resource->num_ratings),
'reviews_url' => $this->whenField('reviews_url', $resource->reviews_url),
'downloaded' => $this->whenField('downloaded', fn() => $resource->downloaded),
'active_installs' => $this->whenField('active_installs', $resource->active_installs),
'last_updated' => $this->whenField('last_updated', fn() => $resource->last_updated->format('Y-m-d')),
'last_updated_time' => $this->whenField('last_updated', fn() => $resource->last_updated->format('Y-m-d H:i:s')),
'creation_time' => $this->whenField('creation_time', fn() => $resource->creation_time->format('Y-m-d H:i:s')),
'homepage' => $this->whenField('homepage', fn() => "https://wordpress.org/themes/{$resource->slug}/"),
'sections' => $this->whenField('sections', fn() => $resource->sections),
'download_link' => $this->whenField('downloadlink', fn() => $resource->download_link ?? ''),
'tags' => $this->whenField('tags', fn() => $tags),
'versions' => $this->whenField('versions', fn() => $resource->versions),
// TODO: support parent
// 'parent' => $this->whenField('parent', function () use ($resource) {
// $parent = $resource->parent_theme;
// return $parent ? [
// 'slug' => $parent->slug,
// 'name' => $parent->name,
// 'homepage' => "https://wordpress.org/themes/{$parent->slug}/",
// ] : new MissingValue();
// }),
'requires' => $this->whenField('requires', $resource->requires),
'requires_php' => $this->whenField('requires_php', $resource->requires_php),
'is_commercial' => $this->whenField('is_commercial', fn() => $resource->is_commercial),
'external_support_url' => $this->whenField('external_support_url', fn() => $resource->is_commercial ? $resource->external_support_url : false),
'is_community' => $this->whenField('is_community', fn() => $resource->is_community),
'external_repository_url' => $this->whenField('external_repository_url', fn() => $resource->is_community ? $resource->external_repository_url : ''),
];
}

/**
* When the given field is included, the value is returned.
* Otherwise, the default value is returned.
* @param mixed $value
* @param mixed|null $default
*/
private function whenField(string $fieldName, $value, $default = null): mixed
{
$include = false;
$includedFields = $this->additional['fields'] ?? [];
$include = $includedFields[$fieldName] ?? false;
if (func_num_args() === 3) {
return $this->when($include, $value, $default);
}
return $this->when($include, $value);
}
}

View file

@ -10,24 +10,18 @@ class PluginHotTagsService
/**
* Gets the top tags by plugin count
*
* @return array<string, array{
* name: string,
* slug: string,
* count: int,
* }> */
* @return array<string, array{ name: string, slug: string, count: int }>
*/
public function getHotTags(int $count = -1): array
{
$hotTags = PluginTag::withCount('plugins')
$hotTags = PluginTag::query()
->withCount('plugins')
->orderBy('plugins_count', 'desc')
->limit($count >= 0 ? $count : 100)
->get(['slug', 'name', 'plugins_count'])
->map(function ($tag) {
return [
'name' => (string) $tag->name,
'slug' => (string) $tag->slug,
'count' => (int) $tag->plugins_count,
];
});
return PluginHotTagsResponse::fromCollection($hotTags)->toArray();
->get();

return PluginHotTagsResponse::collect($hotTags)
->mapWithKeys(fn(PluginHotTagsResponse $tag) => [$tag->slug => $tag])
->toArray();
}
}

View file

@ -3,62 +3,33 @@
namespace App\Services\Plugins;

use App\Models\WpOrg\Plugin;
use Illuminate\Support\Collection;
use App\Values\WpOrg\Plugins\PluginUpdateCheckRequest;
use App\Values\WpOrg\Plugins\PluginUpdateCheckResponse;
use App\Values\WpOrg\Plugins\PluginUpdateData;

class PluginUpdateService
{
/**
* Process the plugins and check for updates
*
* @param array<string, array{Version?: string}> $plugins
* @return array{
* updates: Collection<string, array<string, mixed>>,
* no_updates: Collection<string, array<string, mixed>>
* }
*/
public function processPlugins(array $plugins, bool $includeAll): array
public function checkForUpdates(PluginUpdateCheckRequest $req): PluginUpdateCheckResponse
{
$updates = collect();
$noUpdates = collect();
$bySlug = collect($req->plugins)
->mapWithKeys(
fn($pluginData, $pluginFile) => [$this->extractSlug($pluginFile) => [$pluginFile, $pluginData]],
);

foreach ($plugins as $pluginFile => $pluginData) {
$plugin = $this->findPlugin($pluginFile, $pluginData);
$isUpdated = fn($plugin) => version_compare($plugin->version, $bySlug[$plugin->slug][1]['Version'] ?? '', '>');

if (!$plugin) {
continue;
}
$mkUpdate = function ($plugin) use ($bySlug) {
$file = $bySlug[$plugin->slug][0];
return [$file => PluginUpdateData::from($plugin)->with(plugin: $file)];
};

$updateData = $this->formatPluginData($plugin, $pluginFile);
[$updates, $no_updates] = Plugin::query()
->whereIn('slug', $bySlug->keys())
->get()
->partition($isUpdated)
->map(fn($collection) => $collection->mapWithKeys($mkUpdate));

if (version_compare($plugin->version, $pluginData['Version'] ?? '', '>')) {
$updates->put($pluginFile, $updateData);
} elseif ($includeAll) {
// Only collect no_updates when includeAll is true
$noUpdates->put($pluginFile, $updateData);
}
}

return [
'updates' => $updates,
'no_updates' => $noUpdates,
];
}

/**
* Find a plugin by its file path and data
*
* @param array{Version?: string} $pluginData
*/
private function findPlugin(string $pluginFile, array $pluginData): ?Plugin
{
$slug = $this->extractSlug($pluginFile);
if (!$slug || empty($pluginData['Version'])) {
return null;
}

return Plugin::query()
->where('slug', $slug)
->first();
return PluginUpdateCheckResponse::from(plugins: $updates, no_update: $no_updates, translations: collect([]));
}

/**
@ -70,29 +41,4 @@ class PluginUpdateService
? explode('/', $pluginFile)[0]
: pathinfo($pluginFile, PATHINFO_FILENAME);
}

/**
* Format plugin data for the response
*
* @return array<string, mixed>
*/
private function formatPluginData(Plugin $plugin, string $pluginFile): array
{
return [
'id' => "w.org/plugins/{$plugin->slug}",
'slug' => $plugin->slug,
'plugin' => $pluginFile,
'new_version' => $plugin->version,
'url' => "https://wordpress.org/plugins/{$plugin->slug}/",
'package' => $plugin->download_link,
'icons' => $plugin->icons,
'banners' => $plugin->banners,
'banners_rtl' => [],
'requires' => $plugin->requires,
'tested' => $plugin->tested,
'requires_php' => $plugin->requires_php,
'requires_plugins' => $plugin->requires_plugins,
'compatibility' => $plugin->compatibility,
];
}
}

View file

@ -4,29 +4,25 @@ namespace App\Services\Plugins;

use App\Models\WpOrg\Plugin;
use App\Utils\Regex;
use App\Values\WpOrg\Plugins\PluginResponse;
use App\Values\WpOrg\Plugins\QueryPluginsRequest;
use App\Values\WpOrg\Plugins\QueryPluginsResponse;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;

class QueryPluginsService
{
/**
* Query plugins with filters and pagination
*
* @return array{
* plugins: Collection<int, Plugin>,
* page: int,
* totalPages: int,
* total: int
* }
*/
public function queryPlugins(
int $page,
int $perPage,
?string $search = null,
?string $tag = null, // TODO: make this work with more than one tag, the way Themes do
?string $author = null,
string $browse = 'popular',
): array {
public function queryPlugins(QueryPluginsRequest $req): QueryPluginsResponse
{
$page = $req->page;
$perPage = $req->per_page;
$browse = $req->browse ?: 'popular';
$search = $req->search;
$tag = $req->tags[0] ?? null; // TODO: multiple tags support
$author = $req->author;

$search = self::normalizeSearchString($search);
$tag = self::normalizeSearchString($tag);
$author = self::normalizeSearchString($author);
@ -44,14 +40,13 @@ class QueryPluginsService
->offset(($page - 1) * $perPage)
->limit($perPage)
->get()
->unique('slug');
->unique('slug')
->map(fn($plugin) => PluginResponse::from($plugin)->asQueryPluginsResponse());

return [
return QueryPluginsResponse::from([
'plugins' => $plugins,
'page' => $page,
'totalPages' => $totalPages,
'total' => $total,
];
'info' => ['page' => $page, 'pages' => $totalPages, 'results' => $total],
]);
}

/** @param Builder<Plugin> $query */

View file

@ -2,16 +2,16 @@

namespace App\Services\Themes;

use App\Http\Resources\ThemeCollection;
use App\Http\Resources\ThemeResource;
use App\Models\WpOrg\Theme;
use App\Utils\Regex;
use App\Values\WpOrg\Themes\QueryThemesRequest;
use App\Values\WpOrg\Themes\QueryThemesResponse;
use App\Values\WpOrg\Themes\ThemeResponse;
use Illuminate\Database\Eloquent\Builder;

class QueryThemesService
{
public function queryThemes(QueryThemesRequest $req): ThemeCollection
public function queryThemes(QueryThemesRequest $req): QueryThemesResponse
{
$page = $req->page;
$perPage = $req->per_page;
@ -36,11 +36,12 @@ class QueryThemesService
->with('author')
->get();

$collection = collect($themes)
->unique('slug')
->map(fn($theme) => (new ThemeResource($theme))->additional(['fields' => $req->fields]));
$collection = ThemeResponse::collect($themes)->map(fn($theme) => $theme->withFields($req->fields ?? []));

return new ThemeCollection($collection, $page, (int) ceil($total / $perPage), $total);
return QueryThemesResponse::from(
themes: $collection,
info: ['page' => $page, 'pages' => (int)ceil($total / $perPage), 'results' => $total],
);
}

/** @param Builder<Theme> $query */

View file

@ -10,24 +10,18 @@ class ThemeHotTagsService
/**
* Gets the top tags by theme count
*
* @return array<string, array{
* name: string,
* slug: string,
* count: int,
* }> */
* @return array<string, array{name: string, slug: string, count: int}>
*/
public function getHotTags(int $count = -1): array
{
$hotTags = ThemeTag::withCount('themes')
$hotTags = ThemeTag::query()
->withCount('themes')
->orderBy('themes_count', 'desc')
->limit($count >= 0 ? $count : 100)
->get(['slug', 'name', 'themes_count'])
->map(function ($tag) {
return [
'name' => (string) $tag->name,
'slug' => (string) $tag->slug,
'count' => (int) $tag->themes_count,
];
});
return ThemeHotTagsResponse::fromCollection($hotTags)->toArray();
->get();

return ThemeHotTagsResponse::collect($hotTags)
->mapWithKeys(fn(ThemeHotTagsResponse $tag) => [$tag->slug => $tag])
->toArray();
}
}

View file

@ -3,15 +3,15 @@
namespace App\Services\Themes;

use App\Exceptions\NotFoundException;
use App\Http\Resources\ThemeResource;
use App\Models\WpOrg\Theme;
use App\Values\WpOrg\Themes\ThemeInformationRequest;
use App\Values\WpOrg\Themes\ThemeResponse;

class ThemeInformationService
{
public function info(ThemeInformationRequest $request): ThemeResource
public function info(ThemeInformationRequest $req): ThemeResponse
{
$theme = Theme::query()->where('slug', $request->slug)->first() or throw new NotFoundException("Theme not found");
return (new ThemeResource($theme))->additional(['fields' => $request->fields]);
$theme = Theme::query()->where('slug', $req->slug)->first() or throw new NotFoundException("Theme not found");
return ThemeResponse::from($theme)->withFields($req->fields ?? []);
}
}

View file

@ -2,16 +2,34 @@

namespace App\Values\WpOrg;

use App\Models\WpOrg\Author as AuthorModel;
use Bag\Attributes\Transforms;
use Bag\Bag;

readonly class Author extends Bag
{
public function __construct(
public string $user_nicename,
public string $profile,
public string $avatar,
public string $display_name,
public string $author,
public string $author_url,
public string|null $profile,
public string|null $avatar,
public string|null $display_name,
public string|null $author,
public string|null $author_url,
) {}

// I wish I didn't have to write this, but alas it serializes $model to json inside $user_nicename otherwise 🤦

/** @return array<string, mixed> */
#[Transforms(AuthorModel::class)]
public static function fromModel(AuthorModel $model): array
{
return [
'user_nicename' => $model->user_nicename,
'profile' => $model->profile,
'avatar' => $model->avatar,
'display_name' => $model->display_name,
'author' => $model->author,
'author_url' => $model->author_url,
];
}
}

View file

@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace App\Values\WpOrg\Plugins;

use App\Models\WpOrg\ClosedPlugin;
use Bag\Attributes\Transforms;
use Bag\Bag;

readonly class ClosedPluginResponse extends Bag
{
public function __construct(
public string $name,
public string $slug,
public string $description,
public string $closed_date,
public string $reason,
public string $reason_text,
public string $error = 'closed',
public bool $closed = true,
) {}

/** @return array<string, mixed> */
#[Transforms(ClosedPlugin::class)]
public static function fromClosedPlugin(ClosedPlugin $plugin): array
{
return [
'name' => $plugin->name,
'slug' => $plugin->slug,
'description' => $plugin->description,
'closed_date' => $plugin->closed_date->format('Y-m-d'),
'reason' => $plugin->reason,
'reason_text' => $plugin->getReasonText(),
];
}
}

View file

@ -2,8 +2,9 @@

namespace App\Values\WpOrg\Plugins;

use App\Models\WpOrg\PluginTag;
use Bag\Attributes\Transforms;
use Bag\Bag;
use Illuminate\Support\Collection;

readonly class PluginHotTagsResponse extends Bag
{
@ -13,21 +14,10 @@ readonly class PluginHotTagsResponse extends Bag
public int $count,
) {}

/**
* Static method to create an instance from a Plugin model.
*
* @param Collection<int,covariant array{
* slug: string,
* name: string,
* count: int,
* }> $pluginTags
* @return Collection<string, covariant PluginHotTagsResponse>
*/
public static function fromCollection(Collection $pluginTags): Collection
/** @return array{slug: string, name: string, count: int} */
#[Transforms(PluginTag::class)]
public static function fromPluginTag(PluginTag $tag): array
{
return $pluginTags->mapWithKeys(fn($plugin)
=> [
$plugin['slug'] => self::from($plugin),
]);
return ['slug' => $tag->slug, 'name' => $tag->name, 'count' => $tag->plugins_count];
}
}

View file

@ -0,0 +1,25 @@
<?php

namespace App\Values\WpOrg\Plugins;

use Bag\Attributes\StripExtraParameters;
use Bag\Attributes\Transforms;
use Bag\Bag;
use Illuminate\Http\Request;

// Far simpler than ThemeInformationRequest, it takes a slug and that's it
#[StripExtraParameters]
readonly class PluginInformationRequest extends Bag
{
public const ACTION = 'plugin_information';

public function __construct(public string $slug) {}

/** @return array<string, mixed> */
#[Transforms(Request::class)]
public static function fromRequest(Request $request): array
{
// Bag throws 500 (RuntimeException) for missing fields, this throws a friendlier 422
return $request->validate(['slug' => 'required']);
}
}

View file

@ -0,0 +1,162 @@
<?php

declare(strict_types=1);

namespace App\Values\WpOrg\Plugins;

use App\Models\WpOrg\Plugin;
use Bag\Attributes\Transforms;
use Bag\Bag;
use Bag\Values\Optional;
use DateTimeInterface;

readonly class PluginResponse extends Bag
{
public const LAST_UPDATED_DATE_FORMAT = 'Y-m-d h:ia T'; // .org's goofy format: "2024-09-27 9:53pm GMT"

/**
* @param array<array-key, mixed> $banners
* @param array<array-key, array{src: string, caption: string}> $screenshots
* @param array<string, mixed> $contributors
* @param array<string, string> $versions
* @param array<string, string> $sections
* @param array{"1":int, "2":int, "3":int, "4":int, "5":int} $ratings
* @param list<string> $requires_plugins
* @param array<string, string> $icons
* @param array<string, string> $upgrade_notice
* @param array<string, string> $tags
*/
public function __construct(
public string $name,
public string $slug,
public string $version,
public string|null $requires,
public string|null $tested,
public string|null $requires_php,
public string $download_link,
public string $author,
public string|null $author_profile,
public int $rating,
public int $num_ratings,
public array $ratings,
public int $support_threads,
public int $support_threads_resolved,
public int $active_installs,
public string|null $last_updated,
public string|null $added,
public string|null $homepage,
public array $tags,
public string|null $donate_link,
public array $requires_plugins,
//
// // query_plugins only
public Optional|string|null $downloaded,
public Optional|string|null $short_description,
public Optional|string|null $description,
public Optional|array $icons,
//
// // plugin_information only
public Optional|array $sections,
public Optional|array $versions,
public Optional|array $contributors,
public Optional|array $screenshots,
public Optional|string|null $support_url,
public Optional|array|null $upgrade_notice,
public Optional|string|null $business_model,
public Optional|string|null $repository_url,
public Optional|string|null $commercial_support_url,
public Optional|array $banners,
public Optional|string|null $preview_link,
) {}

/** @return array<string, mixed> */
#[Transforms(Plugin::class)]
public static function fromPlugin(Plugin $plugin): array
{
$none = new Optional();

return [
// common
'name' => $plugin->name,
'slug' => $plugin->slug,
'version' => $plugin->version,
'requires' => $plugin->requires,
'tested' => $plugin->tested,
'requires_php' => $plugin->requires_php,
'download_link' => $plugin->download_link,
'author' => $plugin->author,
'author_profile' => $plugin->author_profile,
'rating' => $plugin->rating,
'num_ratings' => $plugin->num_ratings,
'ratings' => $plugin->ratings,
'support_threads' => $plugin->support_threads,
'support_threads_resolved' => $plugin->support_threads_resolved,
'active_installs' => $plugin->active_installs,
'last_updated' => self::formatLastUpdated($plugin->last_updated),
'added' => $plugin->added?->format('Y-m-d'),
'homepage' => $plugin->homepage,
'tags' => $plugin->tagsArray(),
'donate_link' => $plugin->donate_link,
'requires_plugins' => $plugin->requires_plugins,

// query_plugins only
'downloaded' => $plugin->downloaded,
'short_description' => $plugin->short_description,
'description' => $plugin->description,
'icons' => $plugin->icons,

// plugin_information only
'sections' => $plugin->sections,
'versions' => $plugin->versions,
'contributors' => $plugin->contributors,
'screenshots' => $plugin->screenshots,
'support_url' => $plugin->support_url,
'upgrade_notice' => $plugin->upgrade_notice ?: $none,
'business_model' => $plugin->business_model,
'repository_url' => $plugin->repository_url,
'commercial_support_url' => $plugin->commercial_support_url,
'banners' => $plugin->banners,
'preview_link' => $plugin->preview_link,

];
}

public function asQueryPluginsResponse(): static
{
$none = new Optional();
return $this->with([
'sections' => $none,
'versions' => $none,
'contributors' => $none,
'screenshots' => $none,
'support_url' => $none,
'upgrade_notice' => $none,
'business_model' => $none,
'repository_url' => $none,
'commercial_support_url' => $none,
'banners' => $none,
'preview_link' => $none,
]);
}

public function asPluginInformationResponse(): static
{
$none = new Optional();
return $this->with([
'downloaded' => $none,
'short_description' => $none,
'description' => $none,
'icons' => $none,
]);
}

private static function formatLastUpdated(?DateTimeInterface $lastUpdated): ?string
{
if ($lastUpdated === null) {
return null;
}
$out = $lastUpdated->format(self::LAST_UPDATED_DATE_FORMAT);
// Unfortunately this seems to render GMT as "GMT+0000" for some reason, so strip that out
return \Safe\preg_replace('/\+\d+$/', '', $out);
}
}

View file

@ -0,0 +1,45 @@
<?php

namespace App\Values\WpOrg\Plugins;

use Bag\Attributes\Transforms;
use Bag\Bag;
use Illuminate\Http\Request;

use function Safe\json_decode;

/**
* @phpstan-type TranslationMetadata array{
* POT-Creation-Date: string,
* PO-Revision-Date: string,
* Project-Id-Version: string,
* X-Generator: string
* }
*/
readonly class PluginUpdateCheckRequest extends Bag
{
/**
* @param array<string, array{"Version": string}> $plugins
* @param array<string, array<string, TranslationMetadata>> $translations
* @param list<string> $locale
*/
public function __construct(
public array $plugins,
public array $translations,
public array $locale,
public bool $all = false,
) {}

/** @return array<string, mixed> */
#[Transforms(Request::class)]
public static function fromRequest(Request $request): array
{
$decode = fn($key) => json_decode($request->post($key), true);
return [
'plugins' => $decode('plugins')['plugins'],
'locale' => $decode('locale'),
'translations' => $decode('translations'),
'all' => $request->boolean('all'),
];
}
}

View file

@ -0,0 +1,27 @@
<?php

namespace App\Values\WpOrg\Plugins;

use Bag\Bag;
use Bag\Values\Optional;
use Illuminate\Support\Collection;

readonly class PluginUpdateCheckResponse extends Bag
{
/**
* @param Collection<string, PluginUpdateData> $plugins
* @param Optional|Collection<string, PluginUpdateData> $no_update
* @param Collection<array-key, mixed> $translations
*/
public function __construct(
public Collection $plugins,
public Optional|Collection $no_update,
public Collection $translations,
) {}

// not the best name for the method but that's what we get for a negative-named property. $no_tea anyone?
public function withoutNoUpdate(): self
{
return $this->with(no_update: new Optional());
}
}

View file

@ -0,0 +1,60 @@
<?php

namespace App\Values\WpOrg\Plugins;

use App\Models\WpOrg\Plugin;
use Bag\Attributes\Transforms;
use Bag\Bag;
use Bag\Values\Optional;

readonly class PluginUpdateData extends Bag
{
/**
* @param Optional|list<string> $requires_plugins
* @param Optional|array<string, mixed> $compatibility
* @param Optional|array<string, mixed> $icons
* @param Optional|array<string, mixed> $banners
* @param Optional|array<string, mixed> $banners_rtl
*/
public function __construct(
public string $id,
public string $slug,
public string $plugin,
public string $url,
public string $package,
public string|null $requires,
public string|null $tested,
public string|null $requires_php,
public Optional|array $requires_plugins,
public Optional|array $compatibility,
public Optional|array $icons,
public Optional|array $banners,
public Optional|array $banners_rtl,
public Optional|string $new_version,
public Optional|string $upgrade_notice,
) {}

/** @return array<string, mixed> */
#[Transforms(Plugin::class)]
public static function fromPlugin(Plugin $plugin): array
{
$slug = $plugin->slug;
return [
'id' => "w.org/plugins/$slug",
'slug' => $slug,
'plugin' => "$slug/$slug.php", // gets rewritten to the "real" filename later. hacky, but it works for this.
'new_version' => $plugin->version,
'url' => "https://wordpress.org/plugins/$slug/",
'package' => $plugin->download_link,
'icons' => $plugin->icons,
'banners' => $plugin->banners,
'banners_rtl' => [],
'requires' => $plugin->requires,
'tested' => $plugin->tested,
'requires_php' => $plugin->requires_php,
'requires_plugins' => $plugin->requires_plugins,
'compatibility' => $plugin->compatibility,
// TODO: upgrade_notice (maybe in metadata somewhere?)
];
}
}

View file

@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

namespace App\Values\WpOrg\Plugins;

use Bag\Attributes\StripExtraParameters;
use Bag\Attributes\Transforms;
use Bag\Bag;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;

// Completely isomorphic to QueryThemesRequest, except $theme is replaced with $plugin. Hmm.
// I'd look into refactoring it, but it's not like .org is going to add a new resource type anytime soon.
// We can clean things up in the 2.0 API.
#[StripExtraParameters]
readonly class QueryPluginsRequest extends Bag
{
/** @param list<string>|null $tags */
public function __construct(
public ?string $search = null, // text to search
public ?array $tags = null, // tag or set of tags
public ?string $plugin = null, // slug of a specific plugin
public ?string $author = null, // wp.org username of author
public ?string $browse = null, // one of popular|top-rated|updated|new
public mixed $fields = null,
public int $page = 1,
public int $per_page = 24,
) {}

/** @return array<string, mixed> */
#[Transforms(Request::class)]
public static function fromRequest(Request $request): array
{
$query = $request->query();

$query['tags'] = (array)Arr::pull($query, 'tag', []);

// $defaultFields = [
// 'description' => true,
// 'rating' => true,
// 'homepage' => true,
// 'template' => true,
// ];
// $query['fields'] = self::getFields($request, $defaultFields);
return $query;
}
}

View file

@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace App\Values\WpOrg\Plugins;

use App\Values\WpOrg\PageInfo;
use Bag\Bag;
use Bag\Collection;

readonly class QueryPluginsResponse extends Bag
{
public function __construct(
public Collection $plugins,
public PageInfo $info,
) {}

}

View file

@ -3,8 +3,10 @@
namespace App\Values\WpOrg\Themes;

use Bag\Attributes\StripExtraParameters;
use Bag\Attributes\Transforms;
use Bag\Bag;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;

#[StripExtraParameters]
readonly class QueryThemesRequest extends Bag
@ -28,17 +30,13 @@ readonly class QueryThemesRequest extends Bag
public int $per_page = 24,
) {}

public static function fromRequest(Request $request): self
/** @return array<string, mixed> */
#[Transforms(Request::class)]
public static function fromRequest(Request $request): array
{
$req = $request->query();
$query = $request->query();

// 'tag' is the query parameter, but we store it on the 'tags' field
// we could probably do this with a mapping and cast instead, but this works too,
// and we have to do custom processing on fields anyway.
if (is_array($req) && array_key_exists('tag', $req)) {
$req['tags'] = is_array($req['tag']) ? $req['tag'] : [$req['tag']];
unset($req['tag']);
}
$query['tags'] = (array)Arr::pull($query, 'tag', []);

$defaultFields = [
'description' => true,
@ -47,7 +45,7 @@ readonly class QueryThemesRequest extends Bag
'template' => true,
];

$req['fields'] = self::getFields($request, $defaultFields);
return static::from($req);
$query['fields'] = self::getFields($request, $defaultFields);
return $query;
}
}

View file

@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace App\Values\WpOrg\Themes;

use App\Values\WpOrg\PageInfo;
use Bag\Bag;
use Bag\Collection;

readonly class QueryThemesResponse extends Bag
{
public function __construct(
public Collection $themes,
public PageInfo $info,
) {}

}

View file

@ -9,8 +9,8 @@ trait ThemeFields
public const allFields = [
'description' => false,
'downloaded' => false,
'downloadlink' => false,
'last_updated' => false,
'download_link' => false,
'last_updated_time' => false,
'creation_time' => false,
'parent' => false,
'rating' => false,

View file

@ -2,8 +2,9 @@

namespace App\Values\WpOrg\Themes;

use App\Models\WpOrg\ThemeTag;
use Bag\Attributes\Transforms;
use Bag\Bag;
use Illuminate\Support\Collection;

readonly class ThemeHotTagsResponse extends Bag
{
@ -13,21 +14,10 @@ readonly class ThemeHotTagsResponse extends Bag
public int $count,
) {}

/**
* Static method to create an instance from a Theme model.
*
* @param Collection<int,covariant array{
* slug: string,
* name: string,
* count: int,
* }> $themeTags
* @return Collection<string, covariant ThemeHotTagsResponse>
*/
public static function fromCollection(Collection $themeTags): Collection
/** @return array{slug: string, name: string, count: int} */
#[Transforms(ThemeTag::class)]
public static function fromThemeTag(ThemeTag $tag): array
{
return $themeTags->mapWithKeys(fn($theme)
=> [
$theme['slug'] => self::from($theme),
]);
return ['slug' => $tag->slug, 'name' => $tag->name, 'count' => $tag->themes_count];
}
}

View file

@ -30,8 +30,9 @@ readonly class ThemeInformationRequest extends Bag
'sections' => true,
'rating' => true,
'downloaded' => true,
'downloadlink' => true,
'download_link' => true,
'last_updated' => true,
'last_updated_time' => true,
'homepage' => true,
'tags' => true,
'template' => true,
@ -43,6 +44,7 @@ readonly class ThemeInformationRequest extends Bag
}

$req['fields'] = static::getFields($request, $defaultFields);
$req['last_updated_time'] = $req['last_updated'] ?? true;

return static::from($req);
}

View file

@ -0,0 +1,141 @@
<?php

declare(strict_types=1);

namespace App\Values\WpOrg\Themes;

use App\Models\WpOrg\Theme;
use App\Values\WpOrg\Author;
use Bag\Attributes\Hidden;
use Bag\Attributes\Transforms;
use Bag\Bag;
use Bag\Values\Optional;
use Illuminate\Support\Arr;

readonly class ThemeResponse extends Bag
{
/**
* @param Optional|array{'1': int, '2': int, '3': int, '4': int, '5': int, } $ratings
* @param Optional|array<string, mixed> $sections
* @param Optional|array<string, mixed> $tags
* @param Optional|array<string, mixed> $versions
* @param Optional|array<string, mixed> $requires
* @param Optional|array<string, mixed> $screenshots
* @param Optional|array<string, mixed> $photon_screenshots
* @param Optional|array<string, mixed> $trac_tickets
*/
public function __construct(
public string $name,
public string $slug,
public string $version,
public string $preview_url,
public Optional|Author|string $author,
public Optional|string $description,
public Optional|string $screenshot_url,
public Optional|array $ratings, // TODO: ensure this casts to array correctly
public Optional|int $rating,
public Optional|int $num_ratings,
public Optional|string $reviews_url,
public Optional|int $downloaded,
public Optional|int $active_installs,
public Optional|string $last_updated,
public Optional|string $last_updated_time,
public Optional|string $creation_time,
public Optional|string $homepage,
public Optional|array $sections,
public Optional|string $download_link,
public Optional|array $tags,
public Optional|array $versions,
public Optional|array $requires,
public Optional|string $requires_php,
public Optional|bool $is_commercial,
public Optional|string $external_support_url,
public Optional|bool $is_community,
public Optional|string $external_repository_url,
#[Hidden]
public Optional|Author $extended_author,

// Always empty for now
public Optional|string $parent,
public Optional|int $screenshot_count,
public Optional|array $screenshots,
public Optional|string $theme_url,
public Optional|array $photon_screenshots,
public Optional|array $trac_tickets,
public Optional|string $upload_date,
) {}

/**
* @return array<string, mixed>
* @noinspection ProperNullCoalescingOperatorUsageInspection (it's fine here)
*/
#[Transforms(Theme::class)]
public static function fromTheme(Theme $theme): array
{
// Note we fill in all fields, then strip out any not-requested Optional. such silliness is compatibility.

$none = new Optional();
return [
'name' => $theme->name,
'slug' => $theme->slug,
'version' => $theme->version,
'preview_url' => $theme->preview_url,
'author' => $theme->author ?? $none,
// gets converted to $theme->author->user_nicename unless extended_author=true
'description' => $theme->description ?? $none,
'screenshot_url' => $theme->screenshot_url ?? $none,
'ratings' => $theme->ratings ?? $none,
'rating' => $theme->rating ?? $none,
'num_ratings' => $theme->num_ratings ?? $none,
'reviews_url' => $theme->reviews_url ?? $none,
'downloaded' => $theme->downloaded ?? $none,
'active_installs' => $theme->active_installs ?? $none,
'last_updated' => $theme->last_updated->format('Y-m-d') ?? $none,
'last_updated_time' => $theme->last_updated->format('Y-m-d H:i:s') ?? $none,
'creation_time' => $theme->creation_time->format('Y-m-d H:i:s') ?? $none,
'homepage' => "https://wordpress.org/themes/{$theme->slug}/",
'sections' => $theme->sections ?? $none,
'download_link' => $theme->download_link ?? $none,
'tags' => $theme->tagsArray(),
'versions' => $theme->versions ?? $none,
'requires' => $theme->requires ?? $none,
'requires_php' => $theme->requires_php ?? $none,
'is_commercial' => $theme->is_commercial ?? $none,
'external_support_url' => $theme->external_support_url,
'is_community' => $theme->is_community ?? $none,
'external_repository_url' => $theme->external_repository_url,

// hidden
'extended_author' => $theme->author,

// eventual support
'parent' => $none,
'screenshot_count' => $none,
'screenshots' => $none,
'theme_url' => $none,
'photon_screenshots' => $none,
'trac_tickets' => $none,
'upload_date' => $none,
];
}

/** @param array<string, bool> $fields */
public function withFields(array $fields): static
{
$none = new Optional();
$extendedAuthor = Arr::pull($fields, 'extended_author', false);

$omit = collect($fields)
->filter(fn($val, $key) => !$val)
->mapWithKeys(fn($val, $key) => [$key => $none])
->toArray();

$self = $this->with($omit);

if (!$extendedAuthor) {
$self = $self->with(['author' => $self->author->user_nicename]);
}

return $self;
}
}

View file

@ -2,53 +2,46 @@

namespace App\Values\WpOrg\Themes;

use Bag\Attributes\Transforms;
use Bag\Bag;
use Illuminate\Http\Request;

use function Safe\json_decode;

readonly class ThemeUpdateCheckRequest extends Bag
{
/**
* @phpstan-type TranslationMetadata array{
* POT-Creation-Date: string, // Creation date of the POT file
* PO-Revision-Date: string, // Revision date of the PO file
* Project-Id-Version: string, // Project version info
* X-Generator: string // Generator software info
* }
*/

/**
* @param string $active // Active theme slug
* @param array<string,array{
* "Version": string,
* }> $themes // Array of theme slugs and their current versions
* @param array<string,array<string,array{
* POT-Creation-Date: string,
* PO-Revision-Date: string,
* Project-Id-Version: string,
* X-Generator: string
* }>> $translations
* @param string[] $locale // Array of locale strings
* }
*/
readonly class ThemeUpdateCheckRequest extends Bag
{
/**
* @param string $active slug of currently active theme
* @param array<string, array{"Version": string}> $themes
* @param array<string, array<string, TranslationMetadata>> $translations
* @param list<string> $locale
*/
public function __construct(
public ?string $active = null, // text to search
public ?array $themes = null,
public ?array $translations = null,
public ?array $locale = null,
public string $active,
public array $themes,
public array $translations,
public array $locale,
) {}

public static function fromRequest(Request $request): self
/** @return array<string, mixed> */
#[Transforms(Request::class)]
public static function fromRequest(Request $request): array
{
$themes = $request->post('themes');
$locale = $request->post('locale');
$translations = $request->post('translations');
$themeData = json_decode($themes, true);
return static::from([
'active' => $themeData['active'],
'themes' => $themeData['themes'],
'locale' => json_decode($locale, true),
'translations' => json_decode($translations, true),
]);
$decode = fn($key) => json_decode($request->post($key), true);
$themes = $decode('themes');
return [
'active' => $themes['active'],
'themes' => $themes['themes'],
'locale' => $decode('locale'),
'translations' => $decode('translations'),
];
}
}

View file

@ -11,23 +11,24 @@ readonly class ThemeUpdateCheckResponse extends Bag
/**
* @param Collection<string, ThemeUpdateData> $themes
* @param Collection<string, ThemeUpdateData> $no_update
* @param Collection<array-key, mixed> $translations
*/
public function __construct(
public Collection $themes,
public Collection $no_update,
public mixed $translations,
public Collection $translations,
) {}

/**
* @param Collection<int,Theme> $themes
* @param Collection<int,Theme> $noUpdate
* @param iterable<array-key, Theme> $themes
* @param iterable<array-key, Theme> $no_update
*/
public static function fromData(Collection $themes, Collection $noUpdate): self
public static function fromResults(iterable $themes, iterable $no_update): self
{
return new self(
themes: ThemeUpdateData::fromModelCollection($themes),
no_update: ThemeUpdateData::fromModelCollection($noUpdate),
translations: [],
themes: ThemeUpdateData::collect($themes)->keyBy('theme'),
no_update: ThemeUpdateData::collect($no_update)->keyBy('theme'),
translations: collect(), // TODO
);
}
}

View file

@ -1,37 +0,0 @@
<?php

namespace App\Values\WpOrg\Themes;

use Bag\Bag;
use Illuminate\Http\Request;

use function Safe\json_decode;

readonly class ThemeUpdateCheckTranslationCollection extends Bag
{
/**
* @param ?array<string,mixed> $themes
* @param ?array<string,mixed> $translations
* @param ?string[] $locale
*/
public function __construct(
public ?string $active = null, // text to search
public ?array $themes = null,
public ?array $translations = null,
public ?array $locale = null,
) {}

public static function fromRequest(Request $request): self
{
$themes = $request->post('themes');
$locale = $request->post('locale');
$translations = $request->post('translations');
$themeData = json_decode($themes, true);
return static::from([
'active' => $themeData['active'],
'themes' => $themeData['themes'],
'locale' => json_decode($locale, true),
'translations' => json_decode($translations, true),
]);
}
}

View file

@ -3,8 +3,8 @@
namespace App\Values\WpOrg\Themes;

use App\Models\WpOrg\Theme;
use Bag\Attributes\Transforms;
use Bag\Bag;
use Illuminate\Support\Collection;

readonly class ThemeUpdateData extends Bag
{
@ -18,25 +18,18 @@ readonly class ThemeUpdateData extends Bag
public ?string $requires_php,
) {}

public static function fromModel(Theme $theme): self
/** @return array<string, mixed> */
#[Transforms(Theme::class)]
public static function fromTheme(Theme $theme): array
{
return new self(
name: $theme->name,
theme: $theme->slug,
new_version: $theme->version,
url: $theme->download_link,
package: $theme->download_link,
requires: $theme->requires,
requires_php: $theme->requires_php,
);
}

/**
* @param Collection<int,Theme> $themes
* @return Collection<string,ThemeUpdateData>
*/
public static function fromModelCollection(Collection $themes): Collection
{
return $themes->mapWithKeys(fn($theme) => [$theme->slug => self::fromModel($theme)]);
return [
'name' => $theme->name,
'theme' => $theme->slug,
'new_version' => $theme->version,
'url' => $theme->download_link,
'package' => $theme->download_link,
'requires' => $theme->requires,
'requires_php' => $theme->requires_php,
];
}
}

View file

@ -19,11 +19,10 @@ function query_plugin_uri(array $params = []): string
return "/plugins/info/1.2?" . http_build_query(['action' => 'query_plugibs', ...$params]);
}

it('returns 400 when slug is missing', function () {
it('returns 422 when slug is missing', function () {
$this
->getJson('/plugins/info/1.2?action=plugin_information')
->assertStatus(400)
->assertJson(['error' => 'Slug is required']);
->assertStatus(422);
});

it('returns 404 when plugin does not exist', function () {

View file

@ -28,8 +28,8 @@ it('returns plugin updates from minimal input', function () {
->post('/plugins/update-check/1.1', [
'plugins' => json_encode([
'plugins' => [
'frobnicator' => ['Version' => '1.0.2'], // out of date
'transmogrifier' => ['Version' => '0.5'], // up to date
'frobnicator/frobber.php' => ['Version' => '1.0.2'], // out of date
'transmogrifier.php' => ['Version' => '0.5'], // up to date
],
]),
'translations' => json_encode([]),
@ -38,8 +38,8 @@ it('returns plugin updates from minimal input', function () {
->assertStatus(200)
->assertJson([
'plugins' => [
'frobnicator' => [
'plugin' => 'frobnicator',
'frobnicator/frobber.php' => [
'plugin' => 'frobnicator/frobber.php',
'slug' => 'frobnicator',
'new_version' => '1.2.3',
],
@ -82,8 +82,8 @@ it('includes no_update when all=true', function () {
->post('/plugins/update-check/1.1?all=true', [
'plugins' => json_encode([
'plugins' => [
'frobnicator' => ['Version' => '1.0.2'], // out of date
'transmogrifier' => ['Version' => '0.5'], // up to date
'frobnicator/frobber.php' => ['Version' => '1.0.2'], // out of date
'transmogrifier.php' => ['Version' => '0.5'], // up to date
],
]),
'translations' => json_encode([]),
@ -92,8 +92,8 @@ it('includes no_update when all=true', function () {
->assertStatus(200)
->assertJson([
'plugins' => [
'frobnicator' => [
'plugin' => 'frobnicator',
'frobnicator/frobber.php' => [
'plugin' => 'frobnicator/frobber.php',
'slug' => 'frobnicator',
'new_version' => '1.2.3',
],
@ -102,7 +102,7 @@ it('includes no_update when all=true', function () {
->assertJsonMissingPath('plugins.transmogrifier')
->assertExactJsonStructure([
'plugins' => [
'frobnicator' => [
'frobnicator/frobber.php' => [
'banners' => ['high', 'low'],
'banners_rtl',
'compatibility' => [
@ -123,7 +123,7 @@ it('includes no_update when all=true', function () {
],
],
'no_update' => [
'transmogrifier' => [
'transmogrifier.php' => [
'banners' => ['high', 'low'],
'banners_rtl',
'compatibility' => [
@ -146,76 +146,3 @@ it('includes no_update when all=true', function () {
'translations',
]);
});


// '{\n
// "no_update": {\n
// "transmogrifier": {\n
// "banners": {\n
// "high": "https://via.placeholder.com/1544x500.png/0022cc?text=molestias",\n
// "low": "https://via.placeholder.com/772x250.png/001199?text=minima"\n
// },\n
// "banners_rtl": [],\n
// "compatibility": {\n
// "php": {\n
// "minimum": "7.4",\n
// "recommended": "8.2"\n
// },\n
// "wordpress": {\n
// "maximum": "8.2.99",\n
// "minimum": "2.16.32",\n
// "tested": "8.44.14"\n
// }\n
// },\n
// "icons": {\n
// "1x": "https://via.placeholder.com/128x128.png/006699?text=laboriosam",\n
// "2x": "https://via.placeholder.com/256x256.png/00ee11?text=ut"\n
// },\n
// "id": "w.org/plugins/transmogrifier",\n
// "new_version": "0.5",\n
// "package": "http://www.vonrueden.com/iusto-voluptatem-delectus-tempora-placeat-tempora-exercitationem-ducimus-blanditiis.html",\n
// "plugin": "transmogrifier",\n
// "requires": "3.22.80",\n
// "requires_php": "8.2",\n
// "requires_plugins": [],\n
// "slug": "transmogrifier",\n
// "tested": "WordPress 4.3.39",\n
// "url": "https://wordpress.org/plugins/transmogrifier/"\n
// }\n
// },\n
// "plugins": {\n
// "frobnicator": {\n
// "banners": {\n
// "high": "https://via.placeholder.com/1544x500.png/00ee22?text=laudantium",\n
// "low": "https://via.placeholder.com/772x250.png/0077cc?text=soluta"\n
// },\n
// "banners_rtl": [],\n
// "compatibility": {\n
// "php": {\n
// "minimum": "8.1",\n
// "recommended": "8.0"\n
// },\n
// "wordpress": {\n
// "maximum": "9.46.6",\n
// "minimum": "1.4.31",\n
// "tested": "9.91.94"\n
// }\n
// },\n
// "icons": {\n
// "1x": "https://via.placeholder.com/128x128.png/00ffff?text=minima",\n
// "2x": "https://via.placeholder.com/256x256.png/0077bb?text=quia"\n
// },\n
// "id": "w.org/plugins/frobnicator",\n
// "new_version": "1.2.3",\n
// "package": "http://shields.com/",\n
// "plugin": "frobnicator",\n
// "requires": "2.64.58",\n
// "requires_php": "7.3",\n
// "requires_plugins": [],\n
// "slug": "frobnicator",\n
// "tested": "WordPress 6.4.16",\n
// "url": "https://wordpress.org/plugins/frobnicator/"\n
// }\n
// },\n
// "translations": []\n
// }'

View file

@ -111,7 +111,7 @@ it('returns theme_information (v1.2)', function () {
'download_link' => 'https://api.aspiredev.org/download/my-theme',
'downloaded' => 1000,
'external_repository_url' => 'https://test.com',
'external_support_url' => false,
'external_support_url' => "",
'homepage' => 'https://wordpress.org/themes/my-theme/',
'is_commercial' => false,
'is_community' => true,
@ -121,7 +121,6 @@ it('returns theme_information (v1.2)', function () {
'num_ratings' => 6,
'preview_url' => 'https://wp-themes.com/my-theme',
'rating' => 5,
'requires' => null,
'requires_php' => '5.6',
'reviews_url' => 'https://wp-themes.com/my-theme/reviews',
'screenshot_url' => 'https://wp-themes.com/my-theme/screenshot.png',
@ -141,7 +140,7 @@ it('returns theme query results (v1.1)', function () {
$this
->get('/themes/info/1.1?action=query_themes')
->assertStatus(200)
->assertExactJson([
->assertJson([
'info' => ['page' => 1, 'pages' => 1, 'results' => 1],
'themes' => [
[
@ -164,7 +163,7 @@ it('returns theme query results (v1.2)', function () {
$this
->get('/themes/info/1.2?action=query_themes')
->assertStatus(200)
->assertExactJson([
->assertJson([
'info' => ['page' => 1, 'pages' => 1, 'results' => 1],
'themes' => [
[
@ -186,7 +185,6 @@ it('returns theme query results (v1.2)', function () {
'num_ratings' => 6,
'preview_url' => 'https://wp-themes.com/my-theme',
'rating' => 5,
'requires' => null,
'requires_php' => '5.6',
'screenshot_url' => 'https://wp-themes.com/my-theme/screenshot.png',
'slug' => 'my-theme',
@ -197,6 +195,49 @@ it('returns theme query results (v1.2)', function () {

});

it('returns theme query results for tag (v1.2)', function () {
$this
->get('/themes/info/1.2?action=query_themes&tag=black')
->assertStatus(200)
->assertJson([
'info' => ['page' => 1, 'pages' => 1, 'results' => 1],
'themes' => [
[
'author' => [
'author' => 'Tmeister',
'author_url' => 'https://wp-themes.com/author/tmeister',
'avatar' => 'https://avatars.wp.org/tmeister',
'display_name' => 'Tmeister',
'profile' => 'https://profiles.wp.org/tmeister',
'user_nicename' => 'tmeister',
],
'description' => 'My Theme',
'external_repository_url' => 'https://test.com',
'external_support_url' => '',
'homepage' => 'https://wordpress.org/themes/my-theme/',
'is_commercial' => false,
'is_community' => true,
'name' => 'My Theme',
'num_ratings' => 6,
'preview_url' => 'https://wp-themes.com/my-theme',
'rating' => 5,
'requires_php' => '5.6',
'screenshot_url' => 'https://wp-themes.com/my-theme/screenshot.png',
'slug' => 'my-theme',
'version' => '1.2.1',
],
],
]);

$this
->get('/themes/info/1.2?action=query_themes&tag=orange')
->assertStatus(200)
->assertExactJson([
'info' => ['page' => 1, 'pages' => 0, 'results' => 0], // page 1 of 0 is a bit odd but it is correct
'themes' => [],
]);
});

it('returns hot tags results (v1.1)', function () {
$this
->get('/themes/info/1.1?action=hot_tags')