diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 33c065c..b871f2c 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -74,10 +74,8 @@ jobs: fail-fast: false matrix: php: - - '8.0' # from 2020-11 to 2022-11 (2023-11) - - '8.1' # from 2021-11 to 2023-11 (2025-12) - - '8.2' # from 2022-12 to 2024-12 (2026-12) - '8.3' # from 2023-11 to 2025-12 (2027-12) + - '8.4' steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 @@ -118,10 +116,8 @@ jobs: fail-fast: false matrix: php: - - '8.0' # from 2020-11 to 2022-11 (2023-11) - - '8.1' # from 2021-11 to 2023-11 (2025-12) - - '8.2' # from 2022-12 to 2024-12 (2026-12) - '8.3' # from 2023-11 to 2025-12 (2027-12) + - '8.4' steps: - uses: actions/checkout@v4 - uses: docker://pipelinecomponents/php-codesniffer diff --git a/composer.json b/composer.json index c2d7e80..fda3470 100644 --- a/composer.json +++ b/composer.json @@ -18,12 +18,11 @@ "license": "MIT", "name": "pdsinterop/solid-crud", "require": { - "php": "^8.0", + "php": "^8.3", "ext-mbstring": "*", - "laminas/laminas-diactoros": "^2.14", - "league/flysystem": "^1.0", - "mjrider/flysystem-factory": "^0.7", - "pdsinterop/flysystem-rdf": "^0.6.1", + "laminas/laminas-diactoros": "^3", + "league/flysystem": "^3", + "pdsinterop/flysystem-rdf": "dev-main", "pietercolpaert/hardf": "^0.3", "psr/http-factory": "^1.0", "psr/http-message": "^1.0" diff --git a/src/Server.php b/src/Server.php index bab4982..5a87a12 100644 --- a/src/Server.php +++ b/src/Server.php @@ -8,7 +8,8 @@ use Laminas\Diactoros\ServerRequest; use League\Flysystem\FileExistsException; use League\Flysystem\FileNotFoundException; -use League\Flysystem\FilesystemInterface as Filesystem; +use League\Flysystem\FilesystemOperator as Filesystem; +use League\Flysystem\FilesystemAdapter; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Throwable; @@ -61,6 +62,8 @@ class Server private $requestedPath; /** @var Filesystem */ private $filesystem; + /** @var FilesystemAdapter */ + private $adapter; /** @var Graph */ private $graph; /** @var SolidNotificationsInterface */ @@ -105,12 +108,13 @@ final public function setNotifications(SolidNotificationsInterface $notification //////////////////////////////// PUBLIC API \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ // @TODO: The Graph should be injected by the caller - final public function __construct(Filesystem $filesystem, Response $response, ?Graph $graph = null) + final public function __construct(Filesystem $filesystem, FilesystemAdapter $adapter, Response $response, ?Graph $graph = null) { $this->basePath = ''; $this->baseUrl = ''; $this->lockedPath = false; $this->filesystem = $filesystem; + $this->adapter = $adapter; $this->graph = $graph ?? new Graph(); $this->response = $response; // @TODO: Mention \EasyRdf_Namespace::set('lm', 'https://purl.org/pdsinterop/link-metadata#'); @@ -208,9 +212,9 @@ private function handle(string $method, string $path, $contents, $request): Resp } break; case 'POST': - $pathExists = $filesystem->has($path); + $pathExists = $filesystem->fileExists($path); if ($pathExists) { - $mimetype = $filesystem->getMimetype($path); + $mimetype = $filesystem->mimeType($path); } if ($path === "/") { $pathExists = true; @@ -268,7 +272,7 @@ private function handle(string $method, string $path, $contents, $request): Resp $response = $this->handleCreateDirectoryRequest($response, $path); break; default: - if ($filesystem->has($path) === true) { + if ($filesystem->fileExists($path) === true) { $response = $this->handleUpdateRequest($response, $path, $contents); } else { $response = $this->handleCreateRequest($response, $path, $contents); @@ -289,7 +293,7 @@ private function handleSparqlUpdate(Response $response, string $path, $contents) $filesystem = $this->filesystem; $graph = $this->getGraph(); - if ($filesystem->has($path) === false) { + if ($filesystem->fileExists($path) === false) { $data = ''; } else { // read ttl data @@ -349,12 +353,12 @@ private function handleSparqlUpdate(Response $response, string $path, $contents) $output = $graph->serialise("turtle"); // FIXME: Use enums from namespace Pdsinterop\Rdf\Enum\Format? // write ttl data - if ($filesystem->has($path) === true) { - $success = $filesystem->update($path, $output); - } else { - $success = $filesystem->write($path, $output); + try { + $filesystem->write($path, $output, []); + $success = true; + } catch (\Exception $e) { + $success = false; } - $response = $response->withStatus($success ? 201 : 500); if ($success) { @@ -446,7 +450,7 @@ private function handleN3Update(Response $response, string $path, $contents): Re $graph = $this->getGraph(); $n3Graph = $this->getGraph(); - if ($filesystem->has($path) === false) { + if ($filesystem->fileExists($path) === false) { $data = ''; } else { // read ttl data @@ -497,12 +501,12 @@ private function handleN3Update(Response $response, string $path, $contents): Re $output = $graph->serialise("turtle"); // FIXME: Use enums from namespace Pdsinterop\Rdf\Enum\Format? // write ttl data - if ($filesystem->has($path) === true) { - $success = $filesystem->update($path, $output); - } else { - $success = $filesystem->write($path, $output); + try { + $filesystem->write($path, $output, []); + $success = true; + } catch (\Exception $e) { + $success = false; } - $response = $response->withStatus($success ? 201 : 500); if ($success) { @@ -524,26 +528,27 @@ private function handleN3Update(Response $response, string $path, $contents): Re private function handleCreateRequest(Response $response, string $path, $contents): Response { $filesystem = $this->filesystem; - - if ($filesystem->has($path) === true) { + if ($filesystem->fileExists($path) === true) { $message = vsprintf(self::ERROR_PUT_EXISTING_RESOURCE, [$path]); $response->getBody()->write($message); $response = $response->withStatus(400); } else { $success = false; - set_error_handler(static function ($severity, $message, $filename, $line) { throw new \ErrorException($message, 0, $severity, $filename, $line); }); try { - $success = $filesystem->write($path, $contents); + $filesystem->write($path, $contents, []); + $success = true; } catch (FileExistsException $e) { + $success = false; $message = vsprintf(self::ERROR_PUT_EXISTING_RESOURCE, [$path]); $response->getBody()->write($message); return $response->withStatus(400); } catch (Throwable $exception) { + $success = false; /*/ An error occurred in the underlying flysystem adapter /*/ $message = vsprintf('Could not write to path %s: %s', [$path, $exception->getMessage()]); $response->getBody()->write($message); @@ -552,7 +557,6 @@ private function handleCreateRequest(Response $response, string $path, $contents } finally { restore_error_handler(); } - if ($success) { $this->removeLinkFromMetaFileFor($path); $response = $response->withHeader("Location", $this->baseUrl . $path); @@ -582,12 +586,17 @@ private function parentPath($path) private function handleCreateDirectoryRequest(Response $response, string $path): Response { $filesystem = $this->filesystem; - if ($filesystem->has($path) === true) { + if ($filesystem->fileExists($path) === true) { $message = vsprintf(self::ERROR_PUT_EXISTING_RESOURCE, [$path]); $response->getBody()->write($message); $response = $response->withStatus(400); } else { - $success = $filesystem->createDir($path); + try { + $filesystem->createDirectory($path); + $success = true; + } catch (\Exception $e) { + $success = false; + } $response = $response->withStatus($success ? 201 : 500); if ($success) { $this->removeLinkFromMetaFileFor($path); @@ -617,8 +626,8 @@ private function handleDeleteRequest(Response $response, string $path, $contents { $filesystem = $this->filesystem; - if ($filesystem->has($path)) { - $mimetype = $filesystem->getMimetype($path); + if ($filesystem->fileExists($path)) { + $mimetype = $filesystem->mimeType($path); if ($mimetype === self::MIME_TYPE_DIRECTORY) { $directoryContents = $filesystem->listContents($path, true); @@ -627,7 +636,12 @@ private function handleDeleteRequest(Response $response, string $path, $contents $message = vsprintf(self::ERROR_CAN_NOT_DELETE_NON_EMPTY_CONTAINER, [$path]); $response->getBody()->write($message); } else { - $success = $filesystem->deleteDir($path); + try { + $filesystem->deleteDirectory($path); + $success = true; + } catch (\Exception $e) { + $success = false; + } if ($success) { $this->sendNotificationUpdate($path, self::NOTIFICATION_TYPE_DELETE); } @@ -635,7 +649,13 @@ private function handleDeleteRequest(Response $response, string $path, $contents $status = $success ? 204 : 500; } } else { - $success = $filesystem->delete($path); + try { + $filesystem->delete($path); + $success = true; + } catch (\Exception $e) { + $success = false; + } + if ($success) { $this->sendNotificationUpdate($path, self::NOTIFICATION_TYPE_DELETE); } @@ -656,12 +676,17 @@ private function handleUpdateRequest(Response $response, string $path, string $c { $filesystem = $this->filesystem; - if ($filesystem->has($path) === false) { + if ($filesystem->fileExists($path) === false) { $message = vsprintf(self::ERROR_PUT_NON_EXISTING_RESOURCE, [$path]); $response->getBody()->write($message); $response = $response->withStatus(400); } else { - $success = $filesystem->update($path, $contents); + try { + $filesystem->write($path, $contents, []); + $success = true; + } catch (\Exception $e) { + $success = false; + } $response = $response->withStatus($success ? 201 : 500); if ($success) { $this->removeLinkFromMetaFileFor($path); @@ -707,12 +732,13 @@ private function handleReadRequest(Response $response, string $path, $contents, '; rel="type"', ]); $response = $response->withStatus(200); - } elseif(($filesystem->has($path) === false) && (($path == ".meta") || ($path == "/.meta"))) { + } elseif(($filesystem->fileExists($path) === false) && (($path == ".meta") || ($path == "/.meta"))) { $contents = ''; $response->getBody()->write($contents); $response = $response->withHeader("Content-type", "text/turtle"); $response = $response->withStatus(200); - } elseif ($filesystem->has($path) === false && $this->hasDescribedBy($path) === false) { + } elseif ($filesystem->fileExists($path) === false) { // FIXME: Check with @potherca how this is supposed to work. Removed the hasDescribedBy for now. + // && $this->hasDescribedBy($path) === false) { /*/ The file does not exist and no link-metadata is present /*/ $message = vsprintf(self::ERROR_PATH_DOES_NOT_EXIST, [$path]); $response->getBody()->write($message); @@ -722,33 +748,37 @@ private function handleReadRequest(Response $response, string $path, $contents, if ($linkMetadataResponse !== null) { /*/ Link-metadata is present, return the altered response /*/ $response = $linkMetadataResponse; - } elseif ($filesystem->getMimetype($path) === self::MIME_TYPE_DIRECTORY) { + } elseif ($filesystem->mimeType($path) === self::MIME_TYPE_DIRECTORY) { $contents = $this->listDirectoryAsTurtle($path); $response->getBody()->write($contents); $response = $response->withHeader("Content-type", "text/turtle")->withStatus(200); - } elseif ($filesystem->asMime($mime)->has($path)) { + } elseif ((true | $this->adapter->setMimeFormat($mime)) && $filesystem->fileExists($path)) { /*/ The file does exist and no link-metadata is present /*/ $response = $this->addLinkRelationHeaders($response, $path, $mime); if (preg_match('/\.(acl|meta|ttl)$/', $path)) { $mimetype = "text/turtle"; // FIXME: teach flysystem that .acl/.meta/.ttl means text/turtle } else { - $mimetype = $filesystem->asMime($mime)->getMimetype($path); + $this->adapter->setMimeFormat($mime); + $mimetype = $filesystem->mimeType($path); } - $fileMimeType = $filesystem->asMime('')->getMimetype($path); + $this->adapter->setMimeFormat(''); + $fileMimeType = $filesystem->mimeType($path); if (in_array($fileMimeType, $acceptedMimeTypes)) { - $contents = $filesystem->asMime('')->read($path); + $this->adapter->setMimeFormat(''); + $contents = $filesystem->read($path); $mimetype = $fileMimeType; } else { - $contents = $filesystem->asMime($mime)->read($path); + $this->adapter->setMimeFormat($mime); + $contents = $filesystem->read($path); } if ($contents !== false) { $response->getBody()->write($contents); $response = $response->withHeader("Content-type", $mimetype)->withStatus(200); } else { - // FIXME: we should not get here if the file does not exist, but here we are. It looks like $filesystem->has("/.meta") always returns true even if the file does not exist; + // FIXME: we should not get here if the file does not exist, but here we are. It looks like $filesystem->fileExists("/.meta") always returns true even if the file does not exist; if ($path == "/.meta") { $contents = ''; $response->getBody()->write($contents); @@ -920,14 +950,15 @@ private function getMetadata(string $path, $mime) : array static $metadataCache = []; if (isset($metadataCache[$path]) === false) { - $filesystem = $this->filesystem; - try { if ($mime) { - $metadata = $filesystem->asMime($mime)->getMetadata($path); + $this->adapter->setMimeFormat($mime); + $fileAttributes = $this->adapter->fileSize($path); + $this->adapter->setMimeFormat(''); } else { - $metadata = $filesystem->getMetadata($path); + $fileAttributes = $this->adapter->fileSize($path); } + $metadata = $fileAttributes->extraMetaData(); } catch (FileNotFoundException $e) { $metadata = []; } @@ -945,6 +976,9 @@ private function hasAcl(string $path, $mime = null): bool private function hasDescribedBy(string $path, $mime = null): bool { + if ($this->adapter->fileExists($path) === false) { + return false; + } return $this->getDescribedByPath($path, $mime) !== ''; } @@ -1005,7 +1039,7 @@ private function parseLinkedMetadata(string $path) $linkMeta = []; try { - $describedByPath = $this->filesystem->getMetadata($path)['describedby'] ?? ''; + $describedByPath = $this->adapter->fileSize($path)->extraMetaData()['describedby'] ?? ''; $describedByContents = $this->filesystem->read($describedByPath); } catch (FileNotFoundException $e) { // If, for whatever reason, the file is not present after all, the resource should still be returned (or a 404) @@ -1090,7 +1124,7 @@ private function findPath(array $rdfPaths, string $path) foreach ($rdfPaths as $rdfPath) { if ( strrpos($path, $rdfPath) === 0 - && $this->filesystem->has($rdfPath) + && $this->filesystem->fileExists($rdfPath) ) { // @FIXME: We have no way of knowing if the file is a directory or a file. // This means that, unless we make a trialing slash `/` required, @@ -1151,7 +1185,8 @@ private function removeLinkFromMetaFileFor($path): bool if ($changed) { $changedContents = $graph->serialise('turtle'); try { - $result = $this->filesystem->update($describedByPath, $changedContents); + $this->filesystem->write($describedByPath, $changedContents, []); + $result = true; } catch (FileNotFoundException $exception) { // $result is already false; } diff --git a/tests/unit/ServerTest.php b/tests/unit/ServerTest.php index df0b2a7..9bed1b4 100644 --- a/tests/unit/ServerTest.php +++ b/tests/unit/ServerTest.php @@ -9,7 +9,9 @@ use EasyRdf\Graph; use Laminas\Diactoros\Response; use Laminas\Diactoros\ServerRequest; -use League\Flysystem\FilesystemInterface; +use League\Flysystem\FilesystemAdapter; +use League\Flysystem\FilesystemOperator; +use League\Flysystem\FileAttributes; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -44,24 +46,40 @@ public function testInstatiationWithoutFileSystem() new Server(); } - /** @testdox Server should complain when instantiated without Response */ - public function testInstatiationWithoutResponse() + /** @testdox Server should complain when instantiated without Adapter */ + public function testInstatiationWithoutAdapter() { $this->expectException(ArgumentCountError::class); $this->expectExceptionMessageMatches('/Too few arguments .+ 1 passed/'); - $mockFileSystem = $this->getMockBuilder(FilesystemInterface::class)->getMock(); + $mockFileSystem = $this->getMockBuilder(FilesystemOperator::class)->getMock(); new Server($mockFileSystem); } + /** @testdox Server should be instantiated when constructed without Response */ + public function testInstatiationWithoutResponse() + { + $this->expectException(ArgumentCountError::class); + $this->expectExceptionMessageMatches('/Too few arguments .+ 2 passed/'); + + $mockFileSystem = $this->getMockBuilder(FilesystemOperator::class)->getMock(); + $mockAdapter = $this->getMockBuilder(FilesystemAdapter::class)->getMock(); + + $actual = new Server($mockFileSystem, $mockAdapter); + $expected = Server::class; + + $this->assertInstanceOf($expected, $actual); + } + /** @testdox Server should be instantiated when constructed without Graph */ public function testInstatiationWithoutGraph() { - $mockFileSystem = $this->getMockBuilder(FilesystemInterface::class)->getMock(); + $mockFileSystem = $this->getMockBuilder(FilesystemOperator::class)->getMock(); + $mockAdapter = $this->getMockBuilder(FilesystemAdapter::class)->getMock(); $mockResponse = $this->getMockBuilder(ResponseInterface::class)->getMock(); - $actual = new Server($mockFileSystem, $mockResponse); + $actual = new Server($mockFileSystem, $mockAdapter, $mockResponse); $expected = Server::class; $this->assertInstanceOf($expected, $actual); @@ -70,11 +88,12 @@ public function testInstatiationWithoutGraph() /** @testdox Server should be instantiated when constructed with Graph */ public function testInstatiationWithGraph() { - $mockFileSystem = $this->getMockBuilder(FilesystemInterface::class)->getMock(); + $mockFileSystem = $this->getMockBuilder(FilesystemOperator::class)->getMock(); + $mockAdapter = $this->getMockBuilder(FilesystemAdapter::class)->getMock(); $mockResponse = $this->getMockBuilder(ResponseInterface::class)->getMock(); $mockGraph = $this->getMockBuilder(Graph::class)->getMock(); - $actual = new Server($mockFileSystem, $mockResponse, $mockGraph); + $actual = new Server($mockFileSystem, $mockAdapter, $mockResponse, $mockGraph); $expected = Server::class; $this->assertInstanceOf($expected, $actual); @@ -88,11 +107,12 @@ public function testInstatiationWithGraph() public function testRespondToRequestWithoutRequest() { // Arrange - $mockFileSystem = $this->getMockBuilder(FilesystemInterface::class)->getMock(); + $mockFileSystem = $this->getMockBuilder(FilesystemOperator::class)->getMock(); + $mockAdapter = $this->getMockBuilder(FilesystemAdapter::class)->getMock(); $mockResponse = $this->getMockBuilder(ResponseInterface::class)->getMock(); $mockGraph = $this->getMockBuilder(Graph::class)->getMock(); - $server = new Server($mockFileSystem, $mockResponse, $mockGraph); + $server = new Server($mockFileSystem, $mockAdapter, $mockResponse, $mockGraph); // Assert $this->expectException(ArgumentCountError::class); @@ -112,13 +132,14 @@ public function testRespondToRequestWithoutRequest() public function testRespondToRequestWithUnsupportedHttpMethod($httpMethod) { // Arrange - $mockFileSystem = $this->getMockBuilder(FilesystemInterface::class)->getMock(); + $mockFileSystem = $this->getMockBuilder(FilesystemOperator::class)->getMock(); + $mockAdapter = $this->getMockBuilder(FilesystemAdapter::class)->getMock(); $mockGraph = $this->getMockBuilder(Graph::class)->getMock(); $request = $this->createRequest($httpMethod); $mockResponse = new Response(); - $server = new Server($mockFileSystem, $mockResponse, $mockGraph); + $server = new Server($mockFileSystem, $mockAdapter, $mockResponse, $mockGraph); // Assert $this->expectException(Exception::class); @@ -138,8 +159,11 @@ public function testRespondToRequestWithUnsupportedHttpMethod($httpMethod) public function testRespondToPOSTCreateRequest($slug, $mimetype, $expected) { // Arrange - $mockFileSystem = $this->getMockBuilder(FilesystemInterface::class)->getMock(); + $mockFileSystem = $this->getMockBuilder(FilesystemOperator::class)->getMock(); + $mockAdapter = $this->getMockBuilder(FilesystemAdapter::class)->getMock(); $mockGraph = $this->getMockBuilder(Graph::class)->getMock(); + $mockAttributes = $this->getMockBuilder(FileAttributes::class)->setConstructorArgs(['/'])->getMock(); + $request = $this->createRequest('POST', [ 'Content-Type' => $mimetype, 'Link' => '', @@ -147,24 +171,34 @@ public function testRespondToPOSTCreateRequest($slug, $mimetype, $expected) ]); $mockFileSystem - ->method('has') + ->method('fileExists') + ->willReturnCallback(function($path) { + if ($path === self::MOCK_PATH) { + return true; + } + return false; + }); + + $mockAdapter + ->method('fileSize') + ->withAnyParameters() + ->willReturn($mockAttributes); + + $mockAttributes + ->method('extraMetaData') ->withAnyParameters() - ->willReturnMap([ - [self::MOCK_PATH, true], + ->willReturn([ +// 'describedby' => self::MOCK_PATH . ".meta", +// 'acl' => self::MOCK_PATH . ".acl" ]); $mockFileSystem - ->method('getMimetype') + ->method('mimeType') ->with(self::MOCK_PATH) ->willReturn(Server::MIME_TYPE_DIRECTORY); - $mockFileSystem - ->method('write') - ->withAnyParameters() - ->willReturn(true); - // Act - $server = new Server($mockFileSystem, new Response(), $mockGraph); + $server = new Server($mockFileSystem, $mockAdapter, new Response(), $mockGraph); $response = $server->respondToRequest($request); // Assert