Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions lib/AppHost/Bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ class Bootstrap {
private const GENERIC_STORE_CONTROLLER = 'OCA\\OpenRegister\\AppHost\\Controller\\GenericStoreController';
private const GENERIC_STORE_SERVICE = 'OCA\\OpenRegister\\AppHost\\Service\\GenericStoreService';
private const GENERIC_STORE_INSTALLER = 'OCA\\OpenRegister\\AppHost\\Store\\GenericStoreInstaller';

/**
* The catalogue serving a store that exchanges configuration.
*/
private const FEDERATED_STORE_CATALOG = 'OCA\\OpenRegister\\AppHost\\Store\\FederatedStoreCatalog';
private const GENERIC_SETTINGS_SERVICE = 'OCA\\OpenRegister\\AppHost\\Service\\AppHostSettingsService';
private const GENERIC_ACTION_AUTH_SERVICE = 'OCA\\OpenRegister\\AppHost\\Service\\GenericActionAuthService';
private const GENERIC_INITIALIZE_SETTINGS = 'OCA\\OpenRegister\\AppHost\\Repair\\GenericInitializeSettings';
Expand Down Expand Up @@ -289,6 +294,7 @@ private static function registerControllers(IRegistrationContext $context, strin
manifestLoader: $c->get(self::OBSERVABILITY_MANIFEST_LOADER),
storeService: $c->get(self::GENERIC_STORE_SERVICE),
installer: $c->get(self::GENERIC_STORE_INSTALLER),
catalog: $c->get(self::FEDERATED_STORE_CATALOG),
userSession: $c->get('OCP\\IUserSession'),
groupManager: $c->get('OCP\\IGroupManager'),
logger: $c->get('Psr\\Log\\LoggerInterface')
Expand Down
84 changes: 76 additions & 8 deletions lib/AppHost/Controller/GenericStoreController.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
use OCA\OpenRegister\AppHost\Observability\ManifestLoader;
use OCA\OpenRegister\AppHost\Service\GenericStoreService;
use OCA\OpenRegister\AppHost\Service\StoreDescriptor;
use OCA\OpenRegister\AppHost\Store\FederatedStoreCatalog;
use OCA\OpenRegister\AppHost\Store\GenericStoreInstaller;
use OCA\OpenRegister\AppHost\Store\StoreManifest;
use OCP\AppFramework\Controller;
Expand All @@ -63,6 +64,13 @@
*
* @psalm-suppress UnusedClass
*
* @SuppressWarnings(PHPMD.CouplingBetweenObjects) One controller serves every
* AppHost app across BOTH store paths, so it holds the objects client, the
* objects installer and the configuration catalogue at once. Splitting it per
* path would put the route alias in two places, and an alias that resolves to
* a class the router cannot find is a dispatch-time 500 rather than a test
* failure. The coupling is the price of one alias.
*
* @spec openspec/specs/apphost-store-plane/spec.md#requirement-a-leaf-app-must-declare-its-store-rather-than-implement-one
*/
class GenericStoreController extends Controller {
Expand All @@ -83,6 +91,7 @@ class GenericStoreController extends Controller {
* @param ManifestLoader $manifestLoader Loads the leaf app's manifest.
* @param GenericStoreService $storeService Guarded remote discovery.
* @param GenericStoreInstaller $installer Declarative component install.
* @param FederatedStoreCatalog $catalog Configuration browse and install.
* @param IUserSession $userSession Current session.
* @param IGroupManager $groupManager Admin check for install.
* @param LoggerInterface $logger PSR logger.
Expand All @@ -93,6 +102,7 @@ public function __construct(
private readonly ManifestLoader $manifestLoader,
private readonly GenericStoreService $storeService,
private readonly GenericStoreInstaller $installer,
private readonly FederatedStoreCatalog $catalog,
private readonly IUserSession $userSession,
private readonly IGroupManager $groupManager,
private readonly LoggerInterface $logger,
Expand Down Expand Up @@ -150,11 +160,8 @@ public function search(): JSONResponse {
}

try {
$result = $this->storeService->search(
descriptor: $this->descriptor(store: $store),
query: $query,
kind: $kind
);
$descriptor = $this->descriptor(store: $store);
$result = $this->searchFor(descriptor: $descriptor, query: $query, kind: $kind);
} catch (Throwable $e) {
// Detail to the log, generic outcome to the browser: a registry's
// internals are not the caller's business.
Expand All @@ -171,11 +178,20 @@ public function search(): JSONResponse {
// `kinds` rides back with the cards so the page can offer the filters the
// APP declared, rather than a copy kept in its page config. Empty when
// the app names none, and the page falls back to the shared vocabulary.
// A federated store's cards are discriminated by TYPE, so the declared
// type ids are the honest filter set when the app names no kinds of its
// own. Falling through to the shared kind vocabulary would offer chips
// (`adapter`, `agent-template`) that match nothing on this page.
$kinds = $store->kinds;
if ($kinds === [] && $store->isFederated() === true) {
$kinds = $store->declaredTypes();
}

return new JSONResponse(
data: [
'outcome' => $result['outcome'],
'cards' => $result['cards'],
'kinds' => $store->kinds,
'kinds' => $kinds,
],
statusCode: Http::STATUS_OK
);
Expand Down Expand Up @@ -227,8 +243,10 @@ public function install(string $slug): JSONResponse {
);
}

$descriptor = $this->descriptor(store: $store);

try {
$item = $this->storeService->resolve(descriptor: $this->descriptor(store: $store), slug: $slug);
$item = $this->resolveFor(descriptor: $descriptor, slug: $slug);
} catch (Throwable $e) {
$this->logger->error(
message: sprintf('[AppHost\\Store] resolve failed for %s: %s', $this->appName, $e->getMessage()),
Expand All @@ -244,12 +262,61 @@ public function install(string $slug): JSONResponse {
);
}

// A configuration bundle is applied by the type that owns it, so that a
// set arrives as registers, schemas, flows and objects rather than as
// rows of whichever schema the plane happened to allow.
if ($descriptor->isFederated() === true) {
return new JSONResponse(data: $this->catalog->install(ref: $item), statusCode: Http::STATUS_OK);
}

return new JSONResponse(
data: $this->installer->install(store: $store, item: $item),
statusCode: Http::STATUS_OK
);
}//end install()

/**
* Search whichever catalogue this app declared.
*
* An app that declares shareable types exchanges CONFIGURATION, so its
* catalogue is what publishers have published, not one remote instance's
* rows. Selected by declaration, never by probing: an app that declares
* none makes no discovery call at all.
*
* @param StoreDescriptor $descriptor The calling app's store parameters.
* @param string|null $query Optional free-text search term.
* @param string|null $kind Optional kind filter.
*
* @return array{outcome: string, cards: array<int, array<string, mixed>>}
*
* @spec openspec/changes/store-over-federated-config/specs/apphost-store-plane/spec.md#scenario-an-app-that-declares-no-types-keeps-the-object-store
*/
private function searchFor(StoreDescriptor $descriptor, ?string $query, ?string $kind): array {
if ($descriptor->isFederated() === true) {
return $this->catalog->search(descriptor: $descriptor, query: $query, kind: $kind);
}

return $this->storeService->search(descriptor: $descriptor, query: $query, kind: $kind);
}//end searchFor()

/**
* Resolve a slug against whichever catalogue this app declared.
*
* @param StoreDescriptor $descriptor The calling app's store parameters.
* @param string $slug The item slug.
*
* @return array<string, mixed>|null The resolved item, or null when unresolved.
*
* @spec openspec/changes/store-over-federated-config/specs/apphost-store-plane/spec.md#requirement-a-configuration-install-must-run-through-its-owning-type
*/
private function resolveFor(StoreDescriptor $descriptor, string $slug): ?array {
if ($descriptor->isFederated() === true) {
return $this->catalog->resolve(descriptor: $descriptor, slug: $slug);
}

return $this->storeService->resolve(descriptor: $descriptor, slug: $slug);
}//end resolveFor()

/**
* The calling app's declared store configuration.
*
Expand All @@ -274,7 +341,8 @@ private function descriptor(StoreManifest $store): StoreDescriptor {
appId: $this->appName,
schema: $store->schema,
defaultRegister: $store->register,
cardFields: $store->cardFields
cardFields: $store->cardFields,
types: $store->declaredTypes()
);
}//end descriptor()
}//end class
17 changes: 17 additions & 0 deletions lib/AppHost/Service/StoreDescriptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ final class StoreDescriptor {
* normalisation; a missing property yields an empty
* string rather than a missing key, so the frontend
* never has to null-check a card.
* @param array<int, string> $types Shareable configuration type ids the calling app declared.
* A non-empty list selects federated discovery, where an item
* is a configuration set, a flow or a schema that marked
* itself shareable. An empty list keeps the remote objects
* API, so an app that has not moved is untouched.
*
* @return void
*/
Expand All @@ -62,6 +67,18 @@ public function __construct(
'category' => 'category',
'version' => 'version',
],
public readonly array $types = [],
) {
}//end __construct()

/**
* Whether this descriptor selects federated configuration discovery.
*
* @return bool True when the app declared at least one shareable type.
*
* @spec openspec/changes/store-over-federated-config/specs/apphost-store-plane/spec.md#requirement-a-store-descriptor-must-carry-every-per-app-parameter
*/
public function isFederated(): bool {
return $this->types !== [];
}//end isFederated()
}//end class
Loading
Loading