Skip to content

Commit 99a3c4a

Browse files
soyukaclaude
andcommitted
feat: add LDP container listing with ml/json-ld expansion
Adds getContainerContents() to SolidClient, which GETs a container with Accept: application/ld+json and uses ML\JsonLD\JsonLD::expand() with the request URL as base to parse ldp:contains entries. The JSON-LD processor resolves relative @id values (common in CSS responses) per the JSON-LD spec, returning fully qualified URLs. Returns typed ContainerEntry value objects distinguishing containers from resources via @type or trailing slash. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e7898c8 commit 99a3c4a

File tree

3 files changed

+165
-0
lines changed

3 files changed

+165
-0
lines changed

src/ContainerEntry.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Solid Client PHP project.
5+
* (c) Kévin Dunglas <kevin@dunglas.fr>
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
declare(strict_types=1);
11+
12+
namespace Dunglas\PhpSolidClient;
13+
14+
final class ContainerEntry
15+
{
16+
/**
17+
* @param list<string> $types RDF types of this entry
18+
*/
19+
public function __construct(
20+
public readonly string $url,
21+
public readonly bool $isContainer,
22+
public readonly array $types = [],
23+
) {
24+
}
25+
}

src/SolidClient.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Dunglas\PhpSolidClient;
1313

1414
use EasyRdf\Graph;
15+
use ML\JsonLD\JsonLD;
1516
use Symfony\Contracts\HttpClient\HttpClientInterface;
1617
use Symfony\Contracts\HttpClient\ResponseInterface;
1718

@@ -96,6 +97,44 @@ public function patch(string $url, string $data, string $contentType = 'applicat
9697
return $this->request('PATCH', $url, $options);
9798
}
9899

100+
/**
101+
* Lists the contents of an LDP container by parsing ldp:contains from JSON-LD.
102+
*
103+
* Uses ml/json-ld to expand the response with the request URL as base,
104+
* which resolves any relative @id values per the JSON-LD spec.
105+
*
106+
* @return list<ContainerEntry>
107+
*/
108+
public function getContainerContents(string $url, array $options = []): array
109+
{
110+
$options['headers']['Accept'] = 'application/ld+json';
111+
$response = $this->get($url, $options);
112+
113+
$decoded = json_decode($response->getContent(), false, 512, \JSON_THROW_ON_ERROR);
114+
/** @var list<\stdClass> $expanded */
115+
$expanded = JsonLD::expand($decoded, ['base' => $url]);
116+
117+
$entries = [];
118+
foreach ($expanded as $node) {
119+
$contains = $node->{'http://www.w3.org/ns/ldp#contains'} ?? [];
120+
121+
foreach ($contains as $entry) {
122+
$entryUrl = $entry->{'@id'} ?? null;
123+
if (null === $entryUrl) {
124+
continue;
125+
}
126+
$types = $entry->{'@type'} ?? [];
127+
$isContainer = \in_array('http://www.w3.org/ns/ldp#BasicContainer', $types, true)
128+
|| \in_array('http://www.w3.org/ns/ldp#Container', $types, true)
129+
|| str_ends_with($entryUrl, '/');
130+
131+
$entries[] = new ContainerEntry($entryUrl, $isContainer, $types);
132+
}
133+
}
134+
135+
return $entries;
136+
}
137+
99138
public function getResourceMetadata(string $url, array $options = []): ResourceMetadata
100139
{
101140
$response = $this->head($url, $options);

tests/ContainerOperationsTest.php

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Solid Client PHP project.
5+
* (c) Kévin Dunglas <kevin@dunglas.fr>
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
declare(strict_types=1);
11+
12+
namespace Dunglas\PhpSolidClient\Tests;
13+
14+
use Dunglas\PhpSolidClient\ContainerEntry;
15+
use Dunglas\PhpSolidClient\SolidClient;
16+
use PHPUnit\Framework\TestCase;
17+
use Symfony\Component\HttpClient\MockHttpClient;
18+
use Symfony\Component\HttpClient\Response\MockResponse;
19+
20+
class ContainerOperationsTest extends TestCase
21+
{
22+
public function testGetResourceMetadata(): void
23+
{
24+
$response = new MockResponse('', [
25+
'http_code' => 200,
26+
'response_headers' => [
27+
'Content-Type' => 'text/turtle',
28+
'Content-Length' => '512',
29+
'Link' => '<http://www.w3.org/ns/ldp#Resource>; rel="type"',
30+
'WAC-Allow' => 'user="read write",public="read"',
31+
],
32+
]);
33+
$httpClient = new MockHttpClient($response);
34+
$client = new SolidClient($httpClient);
35+
36+
$metadata = $client->getResourceMetadata('http://pod.example/resource');
37+
38+
$this->assertSame('text/turtle', $metadata->contentType);
39+
$this->assertSame(512, $metadata->contentLength);
40+
$this->assertSame('http://www.w3.org/ns/ldp#Resource', $metadata->ldpType);
41+
$this->assertFalse($metadata->isContainer());
42+
$this->assertSame(['read', 'write'], $metadata->wacAllow['user']);
43+
$this->assertSame(['read'], $metadata->wacAllow['public']);
44+
}
45+
46+
public function testGetContainerContents(): void
47+
{
48+
$jsonLd = json_encode([
49+
'@id' => 'http://pod.example/container/',
50+
'@type' => ['http://www.w3.org/ns/ldp#BasicContainer'],
51+
'http://www.w3.org/ns/ldp#contains' => [
52+
['@id' => 'http://pod.example/container/file.txt'],
53+
[
54+
'@id' => 'http://pod.example/container/sub/',
55+
'@type' => ['http://www.w3.org/ns/ldp#BasicContainer'],
56+
],
57+
],
58+
]);
59+
60+
$response = new MockResponse($jsonLd, [
61+
'http_code' => 200,
62+
'response_headers' => ['Content-Type' => 'application/ld+json'],
63+
]);
64+
$httpClient = new MockHttpClient($response);
65+
$client = new SolidClient($httpClient);
66+
67+
$entries = $client->getContainerContents('http://pod.example/container/');
68+
69+
$this->assertCount(2, $entries);
70+
$this->assertInstanceOf(ContainerEntry::class, $entries[0]);
71+
$this->assertSame('http://pod.example/container/file.txt', $entries[0]->url);
72+
$this->assertFalse($entries[0]->isContainer);
73+
$this->assertSame('http://pod.example/container/sub/', $entries[1]->url);
74+
$this->assertTrue($entries[1]->isContainer);
75+
}
76+
77+
public function testGetContainerContentsWithRelativeIds(): void
78+
{
79+
$jsonLd = json_encode([
80+
'@id' => './',
81+
'@type' => ['http://www.w3.org/ns/ldp#BasicContainer'],
82+
'http://www.w3.org/ns/ldp#contains' => [
83+
['@id' => 'file.txt'],
84+
['@id' => 'sub/', '@type' => ['http://www.w3.org/ns/ldp#BasicContainer']],
85+
],
86+
]);
87+
88+
$response = new MockResponse($jsonLd, [
89+
'http_code' => 200,
90+
'response_headers' => ['Content-Type' => 'application/ld+json'],
91+
]);
92+
$httpClient = new MockHttpClient($response);
93+
$client = new SolidClient($httpClient);
94+
95+
$entries = $client->getContainerContents('http://pod.example/data/');
96+
97+
$this->assertCount(2, $entries);
98+
$this->assertSame('http://pod.example/data/file.txt', $entries[0]->url);
99+
$this->assertSame('http://pod.example/data/sub/', $entries[1]->url);
100+
}
101+
}

0 commit comments

Comments
 (0)