Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/AbstractResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Atoolo\Resource;

/**
* Represents a resource with basic metadata (location, ID, name, language).
*
* This is the minimal domain object that contributors will use
* to decide which features to provide for a resource view.
*/
class AbstractResource
{
public function __construct(
public readonly string $location,
public readonly string $id,
public readonly string $name,
public readonly ResourceLanguage $lang,
) {}

public function toLocation(): ResourceLocation
{
return ResourceLocation::of($this->location, $this->lang);
}
}
28 changes: 28 additions & 0 deletions src/Exception/MissingFeatureException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Atoolo\Resource\Exception;

use Atoolo\Resource\ResourceLocation;
use Atoolo\Resource\ResourceView;

/**
* This exception is used when a resource feature is requested that
* does not exist in a given resource view.
*/
class MissingFeatureException extends \LogicException
{
public function __construct(
public readonly string $feature,
public readonly ResourceView $resourceView,
int $code = 0,
?\Throwable $previous = null,
) {
parent::__construct(
"Feature $feature not available.",
$code,
$previous,
);
}
}
22 changes: 12 additions & 10 deletions src/Resource.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,21 @@
* In the Atoolo context, resources are aggregated data from
* IES (Sitepark's content management system).
*/
class Resource
class Resource extends AbstractResource
{
public function __construct(
public readonly string $location,
public readonly string $id,
public readonly string $name,
string $location,
string $id,
string $name,
public readonly string $objectType,
public readonly ResourceLanguage $lang,
ResourceLanguage $lang,
public readonly DataBag $data,
) {}

public function toLocation(): ResourceLocation
{
return ResourceLocation::of($this->location, $this->lang);
) {
parent::__construct(
$location,
$id,
$name,
$lang,
);
}
}
12 changes: 12 additions & 0 deletions src/ResourceFeature.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Atoolo\Resource;

/**
* Marker interface for features that can be attached to a ResourceView.
*
* Each feature encapsulates a specific aspect or capability of a resource.
*/
interface ResourceFeature {}
122 changes: 122 additions & 0 deletions src/ResourceView.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

declare(strict_types=1);

namespace Atoolo\Resource;

use Atoolo\Resource\Exception\MissingFeatureException;
use Atoolo\Resource\ResourceFeature;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;

/**
* A view of a resource, composed of features provided by contributors.
*
* Features are created lazily via factories and cached once resolved.
*/
final class ResourceView implements LoggerAwareInterface
{
use LoggerAwareTrait;

/**
* @var array<class-string<ResourceFeature>, ResourceFeature>
*/
private array $resolved = [];

/**
* @param array<class-string<ResourceFeature>, callable():ResourceFeature> $factories
*/
public function __construct(
private array $factories,
) {}

/**
* Returns the feature of the given type, constructing it if necessary.
*
* @template T of ResourceFeature
* @param class-string<T> $feature Fully-qualified class name of the feature.
* @return T
* @throws MissingFeatureException if the feature is not registered.
* @throws \Throwable if feature construction fails.
*/
public function get(string $feature): ResourceFeature
{
if (!isset($this->resolved[$feature])) {
if (!isset($this->factories[$feature])) {
throw new MissingFeatureException($feature, $this);
}
$this->resolved[$feature] = ($this->factories[$feature])();
}
/** @var T $resolved */
$resolved = $this->resolved[$feature];
return $resolved;
}

/**
* Attempts to return a feature. If unavailable, logs a warning and returns null.
*
* @template T of ResourceFeature
* @param class-string<T> $feature
* @return T|null
*/
public function tryGet(string $feature): ?ResourceFeature
{
try {
return $this->get($feature);
} catch (\Throwable $th) {
$this->logger?->warning('failed to get resource feature', [
'feature' => $feature,
'error' => $th,
]);
}
return null;
}

/**
* Checks if the view contains a factory for the given feature type.
*
* @template T of ResourceFeature
* @param class-string<T> $feature
*/
public function has(string $feature): bool
{
return isset($this->factories[$feature]);
}

/**
* Returns true if the view has at least one of the given features.
*
* @template T of ResourceFeature
* @param class-string<T>[] $features
*/
public function hasAny(array $features): bool
{
foreach ($features as $feature) {
if ($this->has($feature)) {
return true;
}
}
return false;
}

/**
* Instantiates and resolves all registered features eagerly.
* Useful for early failure detection or warm-up.
*/
public function preloadAll(): void
{
foreach ($this->factories as $feature => $callback) {
if (isset($this->resolved[$feature])) {
continue;
}
try {
$this->resolved[$feature] = $callback();
} catch (\Throwable $th) {
$this->logger?->warning('failed to preload resource feature', [
'feature' => $feature,
'error' => $th,
]);
}
}
}
}
20 changes: 20 additions & 0 deletions src/ResourceViewBatchContributor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Atoolo\Resource;

/**
* Contributes features to multiple ResourceViews at once.
*
* This allows batch contributors to optimize expensive operations
* (e.g., database or API lookups) by handling multiple resources together.
*/
interface ResourceViewBatchContributor
{
/**
* @param AbstractResource[] $resources
* @param array<string,ResourceViewBuilder> $builders keyed by resource ID.
*/
public function batchContribute(array $resources, array $builders): void;
}
40 changes: 40 additions & 0 deletions src/ResourceViewBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace Atoolo\Resource;

use Atoolo\Resource\ResourceFeature;

final class ResourceViewBuilder
{
/**
* @var array<class-string<ResourceFeature>, array{priority:int, factory:(callable():ResourceFeature)}>
*/
private array $bag = [];

/**
* @template T of ResourceFeature
* @param class-string<T> $feature
* @param ResourceFeature|(callable():T) $value
*/
public function add(string $feature, ResourceFeature|callable $value, int $priority = 0): void
{
$cur = $this->bag[$feature] ?? null;
$factory = $value instanceof ResourceFeature
? fn() => $value
: $value;
if ($cur === null || $priority > $cur['priority']) {
$this->bag[$feature] = ['priority' => $priority, 'factory' => $factory];
}
}

public function build(): ResourceView
{
$features = [];
foreach ($this->bag as $class => $entry) {
$features[$class] = $entry['factory'];
}
return new ResourceView($features);
}
}
21 changes: 21 additions & 0 deletions src/ResourceViewContributor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Atoolo\Resource;

/**
* Contributes features to a ResourceView for supported resources.
*/
interface ResourceViewContributor
{
/**
* Checks whether this contributor supports a given resource.
*/
public function supports(AbstractResource $r): bool;

/**
* Adds features to the given ResourceViewBuilder for a supported resource.
*/
public function contribute(AbstractResource $r, ResourceViewBuilder $b): void;
}
68 changes: 68 additions & 0 deletions src/ResourceViewFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace Atoolo\Resource;

/**
* Factory for creating ResourceViews using registered contributors.
*/
final class ResourceViewFactory
{
/**
* @param iterable<ResourceViewContributor> $contributors
*/
public function __construct(private iterable $contributors) {}

/**
* Creates a ResourceView for a given resource.
*/
public function create(AbstractResource $resource): ResourceView
{
$builder = new ResourceViewBuilder();
foreach ($this->contributors as $contributor) {
if ($contributor->supports($resource)) {
$contributor->contribute($resource, $builder);
}
}
return $builder->build();
}

/**
* Builds ResourceViews for multiple resources in one pass.
*
* Contributors that implement ResourceViewBatchContributor
* can optimize batch creation
*
* @param AbstractResource[] $resources
* @return array<string,ResourceView> keyed by resource ID
*/
public function createBatch(array $resources): array
{
$builders = [];
foreach ($resources as $r) {
$builders[$r->id] = new ResourceViewBuilder();
}
foreach ($this->contributors as $contributor) {
$supportedResources = [];
$buildersForSupportedResources = [];
foreach ($resources as $r) {
if ($contributor->supports($r)) {
$supportedResources[] = $r;
$buildersForSupportedResources[$r->id] = $builders[$r->id];
}
}
if (empty($supportedResources)) {
continue;
}
if ($contributor instanceof ResourceViewBatchContributor) {
$contributor->batchContribute($supportedResources, $buildersForSupportedResources);
} else {
foreach ($supportedResources as $r) {
$contributor->contribute($r, $buildersForSupportedResources[$r->id]);
}
}
}
return array_map(fn($b) => $b->build(), $builders);
}
}
Loading