Skip to content

Commit 34512cf

Browse files
soyukaclaude
andcommitted
feat: add ResourceMetadata value object and getResourceMetadata()
Parses HEAD response headers into a structured ResourceMetadata object with content type, length, last modified, LDP type, WAC-Allow permissions, and ACL URL from Link headers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d63fe47 commit 34512cf

File tree

4 files changed

+179
-19
lines changed

4 files changed

+179
-19
lines changed

composer.json

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@
1818
}
1919
],
2020
"require": {
21-
"php": ">=8.1",
21+
"php": ">=8.2",
2222
"jumbojett/openid-connect-php": "^0.9.10",
23-
"symfony/http-client": "^6.0",
23+
"symfony/http-client": "^7.4",
24+
"symfony/web-link": "^7.4",
2425
"easyrdf/easyrdf": "^1.1",
2526
"ml/json-ld": "^1.2",
2627
"web-token/jwt-core": "^3.0",
@@ -32,22 +33,22 @@
3233
"web-token/jwt-signature-algorithm-rsa": "^3.0"
3334
},
3435
"require-dev": {
35-
"symfony/form": "^6.0",
36-
"symfony/framework-bundle": "^6.0",
37-
"symfony/http-foundation": "^6.0",
38-
"symfony/validator": "^6.0",
39-
"symfony/dependency-injection": "^6.0",
40-
"symfony/routing": "^6.0",
41-
"symfony/security-bundle": "^6.0",
42-
"symfony/twig-bundle": "^6.0",
43-
"symfony/debug-bundle": "^6.0",
44-
"symfony/web-profiler-bundle": "^6.0",
45-
"symfony/console": "^6.0",
46-
"symfony/stopwatch": "^6.0",
47-
"symfony/phpunit-bridge": "^6.0",
48-
"symfony/browser-kit": "^6.0",
49-
"symfony/css-selector": "^6.0",
50-
"symfony/security-core": "^6.0",
51-
"vimeo/psalm": "^5.12"
36+
"symfony/form": "^7.4",
37+
"symfony/framework-bundle": "^7.4",
38+
"symfony/http-foundation": "^7.4",
39+
"symfony/validator": "^7.4",
40+
"symfony/dependency-injection": "^7.4",
41+
"symfony/routing": "^7.4",
42+
"symfony/security-bundle": "^7.4",
43+
"symfony/twig-bundle": "^7.4",
44+
"symfony/debug-bundle": "^7.4",
45+
"symfony/web-profiler-bundle": "^7.4",
46+
"symfony/console": "^7.4",
47+
"symfony/stopwatch": "^7.4",
48+
"symfony/phpunit-bridge": "^7.4",
49+
"symfony/browser-kit": "^7.4",
50+
"symfony/css-selector": "^7.4",
51+
"symfony/security-core": "^7.4",
52+
"vimeo/psalm": "^5.12 || ^6.0"
5253
}
5354
}

src/ResourceMetadata.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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+
use Symfony\Component\WebLink\HttpHeaderParser;
15+
16+
final class ResourceMetadata
17+
{
18+
/**
19+
* @param array<string, list<string>> $wacAllow parsed WAC-Allow header (e.g. ['user' => ['read', 'write'], 'public' => ['read']])
20+
*/
21+
public function __construct(
22+
public readonly ?string $contentType = null,
23+
public readonly ?int $contentLength = null,
24+
public readonly ?\DateTimeImmutable $lastModified = null,
25+
public readonly ?string $ldpType = null,
26+
public readonly array $wacAllow = [],
27+
public readonly ?string $aclUrl = null,
28+
) {
29+
}
30+
31+
public static function isContainerType(string $type): bool
32+
{
33+
return 'http://www.w3.org/ns/ldp#BasicContainer' === $type
34+
|| 'http://www.w3.org/ns/ldp#Container' === $type;
35+
}
36+
37+
public function isContainer(): bool
38+
{
39+
return null !== $this->ldpType && self::isContainerType($this->ldpType);
40+
}
41+
42+
/**
43+
* @param array<string, list<string>> $responseHeaders normalized response headers
44+
*/
45+
public static function fromResponseHeaders(array $responseHeaders): self
46+
{
47+
$contentType = isset($responseHeaders['content-type'][0])
48+
? explode(';', $responseHeaders['content-type'][0], 2)[0]
49+
: null;
50+
51+
$contentLength = isset($responseHeaders['content-length'][0])
52+
? (int) $responseHeaders['content-length'][0]
53+
: null;
54+
55+
$lastModified = isset($responseHeaders['last-modified'][0])
56+
? \DateTimeImmutable::createFromFormat(\DateTimeInterface::RFC7231, $responseHeaders['last-modified'][0]) ?: null
57+
: null;
58+
59+
$ldpType = null;
60+
$aclUrl = null;
61+
$linkProvider = (new HttpHeaderParser())->parse($responseHeaders['link'] ?? []);
62+
foreach ($linkProvider->getLinks() as $link) {
63+
$rels = $link->getRels();
64+
if (\in_array('type', $rels, true) && str_contains($link->getHref(), 'ldp#')) {
65+
$ldpType = $link->getHref();
66+
}
67+
if (\in_array('acl', $rels, true)) {
68+
$aclUrl = $link->getHref();
69+
}
70+
}
71+
72+
$wacAllow = [];
73+
if (isset($responseHeaders['wac-allow'][0])) {
74+
// Format: user="read write append control",public="read"
75+
if (preg_match_all('/(\w+)="([^"]*)"/', $responseHeaders['wac-allow'][0], $matches, \PREG_SET_ORDER)) {
76+
foreach ($matches as $match) {
77+
$wacAllow[$match[1]] = array_filter(explode(' ', $match[2]));
78+
}
79+
}
80+
}
81+
82+
return new self($contentType, $contentLength, $lastModified, $ldpType, $wacAllow, $aclUrl);
83+
}
84+
}

src/SolidClient.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,13 @@ public function patch(string $url, string $data, string $contentType = 'applicat
9696
return $this->request('PATCH', $url, $options);
9797
}
9898

99+
public function getResourceMetadata(string $url, array $options = []): ResourceMetadata
100+
{
101+
$response = $this->head($url, $options);
102+
103+
return ResourceMetadata::fromResponseHeaders($response->getHeaders(false));
104+
}
105+
99106
public function request(string $method, string $url, array $options = []): ResponseInterface
100107
{
101108
if ($accessToken = $this->oidcClient?->getAccessToken()) {

tests/ResourceMetadataTest.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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\ResourceMetadata;
15+
use PHPUnit\Framework\TestCase;
16+
17+
class ResourceMetadataTest extends TestCase
18+
{
19+
public function testFromResponseHeaders(): void
20+
{
21+
$headers = [
22+
'content-type' => ['text/turtle; charset=utf-8'],
23+
'content-length' => ['4567'],
24+
'last-modified' => ['Mon, 23 Feb 2026 12:00:00 GMT'],
25+
'link' => [
26+
'<http://www.w3.org/ns/ldp#BasicContainer>; rel="type"',
27+
'<http://pod.example/resource.acl>; rel="acl"',
28+
],
29+
'wac-allow' => ['user="read write append control",public="read"'],
30+
];
31+
32+
$metadata = ResourceMetadata::fromResponseHeaders($headers);
33+
34+
$this->assertSame('text/turtle', $metadata->contentType);
35+
$this->assertSame(4567, $metadata->contentLength);
36+
$this->assertInstanceOf(\DateTimeImmutable::class, $metadata->lastModified);
37+
$this->assertSame('http://www.w3.org/ns/ldp#BasicContainer', $metadata->ldpType);
38+
$this->assertTrue($metadata->isContainer());
39+
$this->assertSame('http://pod.example/resource.acl', $metadata->aclUrl);
40+
$this->assertSame(['read', 'write', 'append', 'control'], $metadata->wacAllow['user']);
41+
$this->assertSame(['read'], $metadata->wacAllow['public']);
42+
}
43+
44+
public function testResourceNotContainer(): void
45+
{
46+
$headers = [
47+
'content-type' => ['application/ld+json'],
48+
'link' => ['<http://www.w3.org/ns/ldp#Resource>; rel="type"'],
49+
];
50+
51+
$metadata = ResourceMetadata::fromResponseHeaders($headers);
52+
53+
$this->assertFalse($metadata->isContainer());
54+
$this->assertSame('http://www.w3.org/ns/ldp#Resource', $metadata->ldpType);
55+
}
56+
57+
public function testEmptyHeaders(): void
58+
{
59+
$metadata = ResourceMetadata::fromResponseHeaders([]);
60+
61+
$this->assertNull($metadata->contentType);
62+
$this->assertNull($metadata->contentLength);
63+
$this->assertNull($metadata->lastModified);
64+
$this->assertNull($metadata->ldpType);
65+
$this->assertNull($metadata->aclUrl);
66+
$this->assertSame([], $metadata->wacAllow);
67+
}
68+
}

0 commit comments

Comments
 (0)