From 9ef1c197b53126758f4fe7b6aed174e1fc7d1955 Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Sat, 13 Jun 2026 18:54:59 -0400 Subject: [PATCH] feat: add discovery templetes Signed-off-by: SebastianKrupinski --- appinfo/routes.php | 5 +- .../AdminConfigurationController.php | 2 + lib/Controller/AdminTemplateController.php | 101 +++++++ .../Version0010Date20260501000002.php | 4 +- lib/Service/ServicesTemplateService.php | 61 ++++ lib/Store/Local/ServicesTemplateStore.php | 43 ++- src/components/AdminServiceTemplates.vue | 278 ++++++++++++++++++ src/views/AdminSettings.vue | 4 +- .../database/ServicesTemplateStoreTest.php | 76 +++++ .../Service/ServicesTemplateServiceTest.php | 93 ++++++ 10 files changed, 659 insertions(+), 8 deletions(-) create mode 100644 lib/Controller/AdminTemplateController.php create mode 100644 src/components/AdminServiceTemplates.vue create mode 100644 tests/php/database/ServicesTemplateStoreTest.php create mode 100644 tests/php/unit/Service/ServicesTemplateServiceTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index 45dfd47..2fc8598 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -7,8 +7,7 @@ declare(strict_types=1); +// Routes are declared via #[FrontpageRoute] attributes on the controllers. return [ - 'routes' => [ - ['name' => 'AdminConfiguration#depositConfiguration', 'url' => '/admin-configuration', 'verb' => 'PUT'], - ] + 'routes' => [], ]; diff --git a/lib/Controller/AdminConfigurationController.php b/lib/Controller/AdminConfigurationController.php index a9a1f1d..073103f 100644 --- a/lib/Controller/AdminConfigurationController.php +++ b/lib/Controller/AdminConfigurationController.php @@ -11,6 +11,7 @@ use OCA\DAVC\Service\ConfigurationService; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\FrontpageRoute; use OCP\AppFramework\Http\DataResponse; use OCP\IRequest; @@ -33,6 +34,7 @@ public function __construct(string $appName, IRequest $request, ConfigurationSer * * @return DataResponse */ + #[FrontpageRoute(verb: 'POST', url: '/admin-configuration')] public function depositConfiguration(array $values): DataResponse { $this->ConfigurationService->depositSystem($values); diff --git a/lib/Controller/AdminTemplateController.php b/lib/Controller/AdminTemplateController.php new file mode 100644 index 0000000..d3aba89 --- /dev/null +++ b/lib/Controller/AdminTemplateController.php @@ -0,0 +1,101 @@ +templateService->list()); + } + + /** + * create a service discovery template + * + * @param string $domain email domain the template applies to + * @param array $connection connection settings + * + * @return DataResponse + */ + #[FrontpageRoute(verb: 'POST', url: '/admin/templates/create')] + public function create(string $domain, array $connection = []): DataResponse { + + $domain = trim($domain); + if (!Validator::fqdn($domain)) { + return new DataResponse('Invalid domain provided.', Http::STATUS_BAD_REQUEST); + } + if (!$this->templateService->create($domain, $connection)) { + return new DataResponse('Failed to create service template.', Http::STATUS_INTERNAL_SERVER_ERROR); + } + return new DataResponse($this->templateService->list()); + } + + /** + * modify a service discovery template + * + * @param string $id service template id + * @param string $domain email domain the template applies to + * @param array $connection connection settings + * + * @return DataResponse + */ + #[FrontpageRoute(verb: 'POST', url: '/admin/templates/modify')] + public function modify(string $id, string $domain, array $connection = []): DataResponse { + + $domain = trim($domain); + if ($id === '') { + return new DataResponse('Invalid template id.', Http::STATUS_BAD_REQUEST); + } + if (!Validator::fqdn($domain)) { + return new DataResponse('Invalid domain provided.', Http::STATUS_BAD_REQUEST); + } + $this->templateService->modify($id, $domain, $connection); + return new DataResponse($this->templateService->list()); + } + + /** + * delete a service discovery template + * + * @param string $id service template id + * + * @return DataResponse + */ + #[FrontpageRoute(verb: 'POST', url: '/admin/templates/delete')] + public function destroy(string $id): DataResponse { + + if ($id === '') { + return new DataResponse('Invalid template id.', Http::STATUS_BAD_REQUEST); + } + $this->templateService->delete($id); + return new DataResponse($this->templateService->list()); + } +} diff --git a/lib/Migration/Version0010Date20260501000002.php b/lib/Migration/Version0010Date20260501000002.php index 2aad5a7..dd95f2a 100644 --- a/lib/Migration/Version0010Date20260501000002.php +++ b/lib/Migration/Version0010Date20260501000002.php @@ -43,8 +43,8 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt 'length' => 255, 'notnull' => true ]); - // connection - $table->addColumn('connection', Types::BLOB, [ + // connection (JSON document) + $table->addColumn('connection', Types::TEXT, [ 'length' => 16777215, // 16MB 'notnull' => true ]); diff --git a/lib/Service/ServicesTemplateService.php b/lib/Service/ServicesTemplateService.php index 69dcebb..bc5b56b 100644 --- a/lib/Service/ServicesTemplateService.php +++ b/lib/Service/ServicesTemplateService.php @@ -10,6 +10,7 @@ namespace OCA\DAVC\Service; use OCA\DAVC\Store\Local\ServicesTemplateStore; +use OCA\DAVC\Utile\UUID; class ServicesTemplateService { private ServicesTemplateStore $_Store; @@ -25,4 +26,64 @@ public function findByDomain(string $domain): array { return $this->_Store->fetchByDomain($domain); } + /** + * list all service templates with decoded connection settings + * + * @since Release 1.0.0 + * + * @return array + */ + public function list(): array { + + return array_map(static function (array $template): array { + $template['connection'] = json_decode((string)$template['connection'], true) ?: []; + return $template; + }, $this->_Store->list()); + } + + /** + * create a new service template + * + * @since Release 1.0.0 + * + * @param string $domain service domain + * @param array $connection connection settings + * + * @return bool + */ + public function create(string $domain, array $connection): bool { + + return $this->_Store->create(UUID::v4(), $domain, $connection); + } + + /** + * modify an existing service template + * + * @since Release 1.0.0 + * + * @param string $id service template id + * @param string $domain service domain + * @param array $connection connection settings + * + * @return bool + */ + public function modify(string $id, string $domain, array $connection): bool { + + return $this->_Store->modify($id, $domain, $connection); + } + + /** + * delete a service template + * + * @since Release 1.0.0 + * + * @param string $id service template id + * + * @return bool + */ + public function delete(string $id): bool { + + return $this->_Store->delete($id); + } + } diff --git a/lib/Store/Local/ServicesTemplateStore.php b/lib/Store/Local/ServicesTemplateStore.php index 9105758..8d93140 100644 --- a/lib/Store/Local/ServicesTemplateStore.php +++ b/lib/Store/Local/ServicesTemplateStore.php @@ -20,6 +20,25 @@ public function __construct(IDBConnection $store) { $this->_Store = $store; } + /** + * normalise blob columns to strings + * + * On PostgreSQL (and Oracle) text/blob columns can be returned as stream + * resources rather than strings, so decode them before returning rows. + * + * @param array $rows + * + * @return array + */ + private function decodeRows(array $rows): array { + foreach ($rows as &$row) { + if (isset($row['connection']) && is_resource($row['connection'])) { + $row['connection'] = stream_get_contents($row['connection']); + } + } + return $rows; + } + /** * retrieve service templates * @@ -36,12 +55,32 @@ public function fetchById(string $id): array { $cmd->executeQuery()->closeCursor(); // return result or null if (is_array($rs) && count($rs) > 0) { - return $rs; + return $this->decodeRows($rs); } else { return []; } } + /** + * retrieve all service templates from data store + * + * @since Release 1.0.0 + * + * @return array + */ + public function list(): array { + // construct data store command + $cmd = $this->_Store->getQueryBuilder(); + $cmd->select('*') + ->from($this->_EntityTable); + // execute command + $result = $cmd->executeQuery(); + $rs = $result->fetchAll(); + $result->closeCursor(); + // return result + return is_array($rs) ? $this->decodeRows($rs) : []; + } + /** * retrieve service templates for specific domain from data store * @@ -62,7 +101,7 @@ public function fetchByDomain(string $domain): array { $cmd->executeQuery()->closeCursor(); // return result or null if (is_array($rs) && count($rs) > 0) { - return $rs; + return $this->decodeRows($rs); } else { return []; } diff --git a/src/components/AdminServiceTemplates.vue b/src/components/AdminServiceTemplates.vue new file mode 100644 index 0000000..3cbc77e --- /dev/null +++ b/src/components/AdminServiceTemplates.vue @@ -0,0 +1,278 @@ + + + + + + + diff --git a/src/views/AdminSettings.vue b/src/views/AdminSettings.vue index 05db140..f21ed47 100644 --- a/src/views/AdminSettings.vue +++ b/src/views/AdminSettings.vue @@ -23,6 +23,7 @@ function showError(message: string) { import { NcButton, NcCheckboxRadioSwitch, NcSelect } from '@nextcloud/vue' import CheckIcon from 'vue-material-design-icons/Check.vue' +import AdminServiceTemplates from '../components/AdminServiceTemplates.vue' import DavIcon from '../icons/DavIcon.vue' // Types @@ -75,7 +76,7 @@ async function onSaveClick(): Promise { const url = generateUrl('/apps/integration_davc/admin-configuration') try { - const response: AxiosResponse = await axios.put(url, req) + const response: AxiosResponse = await axios.post(url, req) showSuccess(t('integration_davc', 'DAV admin configuration saved')) } catch (error) { const axiosError = error as AxiosError @@ -129,6 +130,7 @@ async function onSaveClick(): Promise { + diff --git a/tests/php/database/ServicesTemplateStoreTest.php b/tests/php/database/ServicesTemplateStoreTest.php new file mode 100644 index 0000000..3a3facf --- /dev/null +++ b/tests/php/database/ServicesTemplateStoreTest.php @@ -0,0 +1,76 @@ +db = Server::get(IDBConnection::class); + $this->store = new ServicesTemplateStore($this->db); + + $this->purgeTestData(); + } + + protected function tearDown(): void { + parent::tearDown(); + + $this->purgeTestData(); + } + + private function purgeTestData(): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete('davc_service_templates') + ->where($qb->expr()->in('id', $qb->createNamedParameter($this->ids, IQueryBuilder::PARAM_STR_ARRAY))) + ->executeStatement(); + } + + public function testCreateAndListAndFetchByDomain(): void { + $this->store->create('davc-test-tpl-1', 'one.example.test', ['location_host' => 'dav.one.example.test', 'location_protocol' => 'https']); + $this->store->create('davc-test-tpl-2', 'two.example.test', ['location_host' => 'dav.two.example.test']); + + $all = $this->store->list(); + $this->assertIsArray($all); + $ours = array_values(array_filter($all, fn (array $row): bool => in_array($row['id'], $this->ids, true))); + $this->assertCount(2, $ours); + + $byDomain = $this->store->fetchByDomain('one.example.test'); + $this->assertCount(1, $byDomain); + $connection = json_decode($byDomain[0]['connection'], true); + $this->assertSame('dav.one.example.test', $connection['location_host']); + } + + public function testModifyAndDelete(): void { + $this->store->create('davc-test-tpl-1', 'one.example.test', ['location_host' => 'dav.one.example.test']); + + $this->store->modify('davc-test-tpl-1', 'one.example.test', ['location_host' => 'changed.example.test']); + $rows = $this->store->fetchById('davc-test-tpl-1'); + $this->assertCount(1, $rows); + $connection = json_decode($rows[0]['connection'], true); + $this->assertSame('changed.example.test', $connection['location_host']); + + $this->store->delete('davc-test-tpl-1'); + $this->assertCount(0, $this->store->fetchById('davc-test-tpl-1')); + } +} diff --git a/tests/php/unit/Service/ServicesTemplateServiceTest.php b/tests/php/unit/Service/ServicesTemplateServiceTest.php new file mode 100644 index 0000000..f604f55 --- /dev/null +++ b/tests/php/unit/Service/ServicesTemplateServiceTest.php @@ -0,0 +1,93 @@ +store = $this->createMock(ServicesTemplateStore::class); + $this->service = new ServicesTemplateService($this->store); + } + + public function testFindByDomainDelegatesToStore(): void { + $rows = [['id' => 'a', 'domain' => 'example.com', 'connection' => '{}']]; + $this->store->expects($this->once()) + ->method('fetchByDomain') + ->with('example.com') + ->willReturn($rows); + + $this->assertSame($rows, $this->service->findByDomain('example.com')); + } + + public function testListDecodesConnectionJson(): void { + $this->store->method('list')->willReturn([ + ['id' => 'a', 'domain' => 'example.com', 'connection' => '{"location_host":"dav.example.com","location_protocol":"https"}'], + ]); + + $result = $this->service->list(); + + $this->assertSame('dav.example.com', $result[0]['connection']['location_host']); + $this->assertSame('https', $result[0]['connection']['location_protocol']); + } + + public function testListReturnsEmptyConnectionForInvalidJson(): void { + $this->store->method('list')->willReturn([ + ['id' => 'a', 'domain' => 'example.com', 'connection' => 'not-json'], + ]); + + $result = $this->service->list(); + + $this->assertSame([], $result[0]['connection']); + } + + public function testCreateGeneratesUuidAndDelegates(): void { + $connection = ['location_host' => 'dav.example.com']; + $this->store->expects($this->once()) + ->method('create') + ->with( + $this->callback(static fn (string $id): bool => UUID::is_valid($id)), + 'example.com', + $connection, + ) + ->willReturn(true); + + $this->assertTrue($this->service->create('example.com', $connection)); + } + + public function testModifyDelegatesToStore(): void { + $connection = ['location_host' => 'dav.example.com']; + $this->store->expects($this->once()) + ->method('modify') + ->with('tpl-1', 'example.com', $connection) + ->willReturn(true); + + $this->assertTrue($this->service->modify('tpl-1', 'example.com', $connection)); + } + + public function testDeleteDelegatesToStore(): void { + $this->store->expects($this->once()) + ->method('delete') + ->with('tpl-1') + ->willReturn(true); + + $this->assertTrue($this->service->delete('tpl-1')); + } +}