Skip to content

Commit 50795b4

Browse files
authored
Merge pull request #13 from pdsinterop/feature/solid-link-metadata
Add solid link-metadata responses
2 parents a9dd269 + 9dc4405 commit 50795b4

File tree

7 files changed

+253
-34
lines changed

7 files changed

+253
-34
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"laminas/laminas-diactoros": "^2.8",
2323
"league/flysystem": "^1.0",
2424
"mjrider/flysystem-factory": "^0.5",
25-
"pdsinterop/flysystem-rdf": "^0.3",
25+
"pdsinterop/flysystem-rdf": "^0.4",
2626
"psr/http-factory": "^1.0",
2727
"psr/http-message": "^1.0",
2828
"textalk/websocket": "^1.5"

src/Server.php

Lines changed: 190 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ class Server
1919

2020
public const ERROR_CAN_NOT_PARSE_FOR_PATCH = 'Could not parse the requested resource for patching';
2121
public const ERROR_CAN_NOT_DELETE_NON_EMPTY_CONTAINER = 'Only empty containers can be deleted, "%s" is not empty';
22+
public const ERROR_CAN_NOT_PARSE_METADATA = 'Could not parse metadata for %s';
23+
public const ERROR_CAN_NOT_REDIRECT_WITHOUT_URL = "Cannot create %s: no URL set";
2224
public const ERROR_MISSING_SPARQL_CONTENT_TYPE = 'Request is missing required Content-Type "application/sparql-update" or "application/sparql-update-single-match"';
25+
public const ERROR_MULTIPLE_LINK_METADATA_FOUND = 'More than one link-metadata found for %s';
2326
public const ERROR_NOT_IMPLEMENTED_SPARQL = 'SPARQL Not Implemented';
2427
public const ERROR_PATH_DOES_NOT_EXIST = 'Requested path "%s" does not exist';
2528
public const ERROR_PATH_EXISTS = 'Requested path "%s" already exists';
@@ -538,40 +541,43 @@ private function handleReadRequest(Response $response, string $path, $contents,
538541
$response->getBody()->write($contents);
539542
$response = $response->withHeader("Content-type", "text/turtle");
540543
$response = $response->withStatus(200);
541-
} elseif ($filesystem->has($path) === false) {
544+
} elseif ($filesystem->has($path) === false && $this->hasDescribedBy($path) === false) {
545+
/*/ The file does not exist and no link-metadata is present /*/
542546
$message = vsprintf(self::ERROR_PATH_DOES_NOT_EXIST, [$path]);
543547
$response->getBody()->write($message);
544548
$response = $response->withStatus(404);
545549
} else {
546-
$mimetype = $filesystem->getMimetype($path);
547-
if ($mimetype === self::MIME_TYPE_DIRECTORY) {
550+
$linkMetadataResponse = $this->handleLinkMetadata($response, $path);
551+
if ($linkMetadataResponse !== null) {
552+
/*/ Link-metadata is present, return the altered response /*/
553+
$response = $linkMetadataResponse;
554+
} elseif ($filesystem->getMimetype($path) === self::MIME_TYPE_DIRECTORY) {
548555
$contents = $this->listDirectoryAsTurtle($path);
549556
$response->getBody()->write($contents);
550-
$response = $response->withHeader("Content-type", "text/turtle");
551-
$response = $response->withStatus(200);
552-
} else {
553-
if ($filesystem->asMime($mime)->has($path)) {
554-
$contents = $filesystem->asMime($mime)->read($path);
557+
$response = $response->withHeader("Content-type", "text/turtle")->withStatus(200);
558+
} elseif ($filesystem->asMime($mime)->has($path)) {
559+
/*/ The file does exist and no link-metadata is present /*/
560+
$response = $this->addLinkRelationHeaders($response, $path, $mime);
561+
562+
if (preg_match('/.ttl$/', $path)) {
563+
$mimetype = "text/turtle"; // FIXME: teach flysystem that .ttl means text/turtle
564+
} elseif (preg_match('/.acl$/', $path)) {
565+
$mimetype = "text/turtle"; // FIXME: teach flysystem that .acl files also want text/turtle
566+
} else {
567+
$mimetype = $filesystem->asMime($mime)->getMimetype($path);
568+
}
555569

556-
$response = $this->addLinkRelationHeaders($response, $path, $mime);
570+
$contents = $filesystem->asMime($mime)->read($path);
557571

558-
if (preg_match('/.ttl$/', $path)) {
559-
$mimetype = "text/turtle"; // FIXME: teach flysystem that .ttl means text/turtle
560-
} elseif (preg_match('/.acl$/', $path)) {
561-
$mimetype = "text/turtle"; // FIXME: teach flysystem that .acl files also want text/turtle
562-
} else {
563-
$mimetype = $filesystem->asMime($mime)->getMimetype($path);
564-
}
565-
if ($contents !== false) {
566-
$response->getBody()->write($contents);
567-
$response = $response->withHeader("Content-type", $mimetype);
568-
$response = $response->withStatus(200);
569-
}
570-
} else {
571-
$message = vsprintf(self::ERROR_PATH_DOES_NOT_EXIST, [$path]);
572-
$response->getBody()->write($message);
573-
$response = $response->withStatus(404);
574-
}
572+
if ($contents !== false) {
573+
$response->getBody()->write($contents);
574+
$response = $response->withHeader("Content-type", $mimetype)->withStatus(200);
575+
}
576+
} else {
577+
/*/ The file does exist in another format and no link-metadata is present /*/
578+
$message = vsprintf(self::ERROR_PATH_DOES_NOT_EXIST, [$path]);
579+
$response->getBody()->write($message);
580+
$response = $response->withStatus(404);
575581
}
576582
}
577583

@@ -616,11 +622,25 @@ private function listDirectoryAsTurtle($path)
616622
$item['basename'] !== '.meta'
617623
&& in_array($item['extension'], ['acl', 'meta']) === false
618624
) {
619-
$filename = "<" . rawurlencode($item['basename']) . ">";
620-
$turtle[$filename] = array(
621-
"a" => array("ldp:Resource")
622-
);
623-
$turtle["<>"]['ldp:contains'][] = $filename;
625+
try {
626+
$linkMetadataResponse = $this->handleLinkMetadata(clone $this->response, $item['path']);
627+
} catch (Exception $e) {
628+
// If the link-metadata can not be retrieved for whatever reason, it should just be listed
629+
// The error will surface when the file itself is accessed
630+
$linkMetadataResponse = null;
631+
}
632+
633+
if (
634+
$linkMetadataResponse === null
635+
|| in_array($linkMetadataResponse->getStatusCode(), [404, 410]) === false
636+
) {
637+
/*/ Only files without link-metadata instruction, or with a redirect instruction may be shown /*/
638+
$filename = "<" . rawurlencode($item['basename']) . ">";
639+
$turtle[$filename] = array(
640+
"a" => array("ldp:Resource")
641+
);
642+
$turtle["<>"]['ldp:contains'][] = $filename;
643+
}
624644
}
625645
break;
626646
case "dir":
@@ -734,4 +754,143 @@ private function hasDescribedBy(string $path, $mime = null): bool
734754
{
735755
return $this->getDescribedByPath($path, $mime) !== '';
736756
}
757+
758+
// =========================================================================
759+
// @TODO: All link-metadata Response logic should probably be moved to a separate class.
760+
761+
private function handleLinkMetadata(Response $response, string $path)
762+
{
763+
$returnResponse = null;
764+
765+
if ($this->hasDescribedBy($path)) {
766+
$linkMeta = $this->parseLinkedMetadata($path);
767+
768+
if (isset($linkMeta['type'], $linkMeta['url'])) {
769+
$returnResponse = $this->buildLinkMetadataResponse($response, $linkMeta['type'], $linkMeta['url']);
770+
}
771+
}
772+
773+
return $returnResponse;
774+
}
775+
776+
private function buildLinkMetadataResponse(Response $response, $type, $url = null)
777+
{
778+
switch ($type) {
779+
case 'deleted':
780+
$returnResponse = $response->withStatus(404);
781+
break;
782+
783+
case 'forget':
784+
$returnResponse = $response->withStatus(410);
785+
break;
786+
787+
case 'redirectPermanent':
788+
if ($url === null) {
789+
throw Exception::create(self::ERROR_CAN_NOT_REDIRECT_WITHOUT_URL, [$type]);
790+
}
791+
$returnResponse = $response->withHeader('Location', $url)->withStatus(308);
792+
break;
793+
794+
case 'redirectTemporary':
795+
if ($url === null) {
796+
throw Exception::create(self::ERROR_CAN_NOT_REDIRECT_WITHOUT_URL, [$type]);
797+
}
798+
$returnResponse = $response->withHeader('Location', $url)->withStatus(307);
799+
break;
800+
801+
default:
802+
// No (known) Link Metadata present = follow regular logic
803+
$returnResponse = null;
804+
break;
805+
}
806+
807+
return $returnResponse;
808+
}
809+
810+
private function parseLinkedMetadata(string $path)
811+
{
812+
$linkMeta = [];
813+
814+
try {
815+
$describedByPath = $this->filesystem->getMetadata($path)['describedby'] ?? '';
816+
$describedByContents = $this->filesystem->read($describedByPath);
817+
} catch (FileNotFoundException $e) {
818+
// @CHECKME: If, for whatever reason, the file is not present after all... Do we care here?
819+
return $linkMeta;
820+
}
821+
822+
$graph = $this->getGraph();
823+
824+
try {
825+
$graph->parse($describedByContents);
826+
} catch (EasyRdf_Exception $exception) {
827+
throw Exception::create(self::ERROR_CAN_NOT_PARSE_METADATA, [$path]);
828+
}
829+
830+
$toRdfPhp = $graph->toRdfPhp();
831+
832+
$rdfPaths = array_keys($toRdfPhp);
833+
$foundPath = $this->findPath($rdfPaths, $path);
834+
835+
if (isset($toRdfPhp[$foundPath])) {
836+
$filteredRdfData = array_filter($toRdfPhp[$foundPath], static function ($key) {
837+
$uris = implode('|', [
838+
'pdsinterop.org/solid-link-metadata/links.ttl',
839+
'purl.org/pdsinterop/link-metadata',
840+
]);
841+
842+
return (bool) preg_match("#({$uris})#",
843+
$key);
844+
}, ARRAY_FILTER_USE_KEY);
845+
846+
if (count($filteredRdfData) > 1) {
847+
throw Exception::create(self::ERROR_MULTIPLE_LINK_METADATA_FOUND, [$path]);
848+
}
849+
850+
if (count($filteredRdfData) > 0) {
851+
$linkMetaType = array_key_first($filteredRdfData);
852+
$type = substr($linkMetaType, strrpos($linkMetaType, '#') + 1);
853+
854+
$linkMetaValue = reset($filteredRdfData);
855+
$value = array_pop($linkMetaValue);
856+
$url = $value['value'] ?? null;
857+
858+
if ($path !== $foundPath) {
859+
// Change the path from the request to the redirect (or not found) path
860+
$url = substr_replace($path,
861+
$url,
862+
strpos($path, $foundPath),
863+
strlen($foundPath));
864+
}
865+
866+
$linkMeta = [
867+
'type' => $type,
868+
'url' => $url,
869+
];
870+
}
871+
}
872+
873+
return $linkMeta;
874+
}
875+
876+
private function findPath(array $rdfPaths, string $path)
877+
{
878+
$path = ltrim($path, '/');
879+
880+
foreach ($rdfPaths as $rdfPath) {
881+
if (
882+
strrpos($path, $rdfPath) === 0
883+
&& $this->filesystem->has($rdfPath)
884+
) {
885+
// @FIXME: We have no way of knowing if the file is a directory or a file.
886+
// This means that, unless we make a trialing slash `/` required,
887+
// (using the example for `forget.ttl`) forget.ttl/foo.txt will
888+
// also work although semantically is should not
889+
$path = $rdfPath;
890+
break;
891+
}
892+
}
893+
894+
return $path;
895+
}
737896
}

tests/fixtures/.meta

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
@prefix dc: <http://purl.org/dc/terms/> .
2+
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
3+
@prefix lm: <https://purl.org/pdsinterop/link-metadata#> .
4+
5+
<existingFile.ttl>
6+
dc:title "Top-Level Test document" ;
7+
rdfs:comment "Dummy file for testing metadata file in same directory" .
8+
9+
<nested/parent/childFile.ttl>
10+
dc:title "Nested Test document" ;
11+
rdfs:comment "Dummy file for testing metadata file in a parent directory" .
12+
13+
<deleted.ttl>
14+
dc:title "Deleted Test document" ;
15+
rdfs:comment "Non-existent file for testing metadata that has been deleted" ;
16+
lm:deleted <> .
17+
18+
<forget.ttl>
19+
dc:title "Forget Test document" ;
20+
rdfs:comment "Non-existent file for testing metadata that should be forgotten" ;
21+
lm:forget <> .
22+
23+
<redirectPermanent.ttl>
24+
dc:title "Permanent Redirect Test document" ;
25+
rdfs:comment "Non-existent file for testing metadata that has been redirected permanently " ;
26+
lm:redirectPermanent <data/existingFile.ttl> .
27+
28+
<redirectTemporary.ttl>
29+
dc:title "Temporary Redirect Test document" ;
30+
rdfs:comment "Non-existent file for testing metadata that has been redirected temporarily" ;
31+
lm:redirectTemporary <data/nested/parent/childFile.ttl> .
32+
33+
<deleted/>
34+
dc:title "Deleted Test directory" ;
35+
rdfs:comment "Non-existent directory, all path under here should also be marked as deleted" ;
36+
lm:deleted <> .
37+
38+
<redirected/>
39+
dc:title "Redirect Test directory" ;
40+
rdfs:comment "Non-existent directory, all path under here should also be marked as redirected temporarily" ;
41+
lm:redirectTemporary <data/nested/> .
42+

tests/fixtures/deleted.ttl

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
@prefix dc: <http://purl.org/dc/terms/> .
2+
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
3+
4+
</>
5+
dc:title "This content should never be shown" ;
6+
rdfs:comment "Dummy file for testing link-metadata override" ;
7+
rdfs:comment "Instead of this content, you should be seeing a 404" ;
8+
rdfs:comment "In the container overview, the filename / path should also not be shown." .

tests/fixtures/file.ttl renamed to tests/fixtures/existingFile.ttl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33

44
</>
55
dc:title "Top-level Test document" ;
6-
rdfs:comment "Dummy file for testing metadata file in same directory" .
6+
rdfs:comment "Dummy file for testing metadata file in same directory" ;
7+
rdfs:comment "It is also used to be directed to by the non-existent file 'redirectPermanent.ttl'" .

tests/fixtures/nested/parent/child/file.ttl renamed to tests/fixtures/nested/parent/childFile.ttl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33

44
</>
55
dc:title "Nested Test document" ;
6-
rdfs:comment "Dummy file for testing metadata file in a parent directory" .
6+
rdfs:comment "Dummy file for testing metadata file in a parent directory" ;
7+
rdfs:comment "It is also used to be directed to by the non-existent file 'redirectTemporary.ttl'" .

tests/fixtures/redirectPermanent.ttl

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
@prefix dc: <http://purl.org/dc/terms/> .
2+
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
3+
4+
</>
5+
dc:title "This content should never be shown" ;
6+
rdfs:comment "Dummy file for testing link-metadata override" ;
7+
rdfs:comment "Instead of this content, you should be seeing the content of the file redirected to" ;
8+
rdfs:comment "In the container overview, the filename / path is allowed to be shown." .

0 commit comments

Comments
 (0)