diff --git a/composer.json b/composer.json index c7ba642..1627d02 100644 --- a/composer.json +++ b/composer.json @@ -18,9 +18,10 @@ } ], "require": { - "php": ">=8.1", + "php": ">=8.2", "jumbojett/openid-connect-php": "^0.9.10", - "symfony/http-client": "^6.0", + "symfony/http-client": "^7.4", + "symfony/web-link": "^7.4", "easyrdf/easyrdf": "^1.1", "ml/json-ld": "^1.2", "web-token/jwt-core": "^3.0", @@ -32,22 +33,22 @@ "web-token/jwt-signature-algorithm-rsa": "^3.0" }, "require-dev": { - "symfony/form": "^6.0", - "symfony/framework-bundle": "^6.0", - "symfony/http-foundation": "^6.0", - "symfony/validator": "^6.0", - "symfony/dependency-injection": "^6.0", - "symfony/routing": "^6.0", - "symfony/security-bundle": "^6.0", - "symfony/twig-bundle": "^6.0", - "symfony/debug-bundle": "^6.0", - "symfony/web-profiler-bundle": "^6.0", - "symfony/console": "^6.0", - "symfony/stopwatch": "^6.0", - "symfony/phpunit-bridge": "^6.0", - "symfony/browser-kit": "^6.0", - "symfony/css-selector": "^6.0", - "symfony/security-core": "^6.0", - "vimeo/psalm": "^5.12" + "symfony/form": "^7.4", + "symfony/framework-bundle": "^7.4", + "symfony/http-foundation": "^7.4", + "symfony/validator": "^7.4", + "symfony/dependency-injection": "^7.4", + "symfony/routing": "^7.4", + "symfony/security-bundle": "^7.4", + "symfony/twig-bundle": "^7.4", + "symfony/debug-bundle": "^7.4", + "symfony/web-profiler-bundle": "^7.4", + "symfony/console": "^7.4", + "symfony/stopwatch": "^7.4", + "symfony/phpunit-bridge": "^7.4", + "symfony/browser-kit": "^7.4", + "symfony/css-selector": "^7.4", + "symfony/security-core": "^7.4", + "vimeo/psalm": "^5.12 || ^6.0" } } diff --git a/src/Bundle/Form/SolidLoginType.php b/src/Bundle/Form/SolidLoginType.php index 7272c94..fb4e0cd 100644 --- a/src/Bundle/Form/SolidLoginType.php +++ b/src/Bundle/Form/SolidLoginType.php @@ -55,7 +55,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ - 'constraints' => [new Callback(function (array $data, ExecutionContextInterface $context): void { + 'constraints' => [new Callback(static function (array $data, ExecutionContextInterface $context): void { $webId = $data['webid'] ?? ''; $op = $data['op'] ?? ''; diff --git a/src/ResourceMetadata.php b/src/ResourceMetadata.php new file mode 100644 index 0000000..c9ab192 --- /dev/null +++ b/src/ResourceMetadata.php @@ -0,0 +1,84 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Dunglas\PhpSolidClient; + +use Symfony\Component\WebLink\HttpHeaderParser; + +final class ResourceMetadata +{ + /** + * @param array> $wacAllow parsed WAC-Allow header (e.g. ['user' => ['read', 'write'], 'public' => ['read']]) + */ + public function __construct( + public readonly ?string $contentType = null, + public readonly ?int $contentLength = null, + public readonly ?\DateTimeImmutable $lastModified = null, + public readonly ?string $ldpType = null, + public readonly array $wacAllow = [], + public readonly ?string $aclUrl = null, + ) { + } + + public static function isContainerType(string $type): bool + { + return 'http://www.w3.org/ns/ldp#BasicContainer' === $type + || 'http://www.w3.org/ns/ldp#Container' === $type; + } + + public function isContainer(): bool + { + return null !== $this->ldpType && self::isContainerType($this->ldpType); + } + + /** + * @param array> $responseHeaders normalized response headers + */ + public static function fromResponseHeaders(array $responseHeaders): self + { + $contentType = isset($responseHeaders['content-type'][0]) + ? explode(';', $responseHeaders['content-type'][0], 2)[0] + : null; + + $contentLength = isset($responseHeaders['content-length'][0]) + ? (int) $responseHeaders['content-length'][0] + : null; + + $lastModified = isset($responseHeaders['last-modified'][0]) + ? \DateTimeImmutable::createFromFormat(\DateTimeInterface::RFC7231, $responseHeaders['last-modified'][0]) ?: null + : null; + + $ldpType = null; + $aclUrl = null; + $linkProvider = (new HttpHeaderParser())->parse($responseHeaders['link'] ?? []); + foreach ($linkProvider->getLinks() as $link) { + $rels = $link->getRels(); + if (\in_array('type', $rels, true) && str_contains($link->getHref(), 'ldp#')) { + $ldpType = $link->getHref(); + } + if (\in_array('acl', $rels, true)) { + $aclUrl = $link->getHref(); + } + } + + $wacAllow = []; + if (isset($responseHeaders['wac-allow'][0])) { + // Format: user="read write append control",public="read" + if (preg_match_all('/(\w+)="([^"]*)"/', $responseHeaders['wac-allow'][0], $matches, \PREG_SET_ORDER)) { + foreach ($matches as $match) { + $wacAllow[$match[1]] = array_filter(explode(' ', $match[2])); + } + } + } + + return new self($contentType, $contentLength, $lastModified, $ldpType, $wacAllow, $aclUrl); + } +} diff --git a/src/SolidClient.php b/src/SolidClient.php index 7e61e0e..08d31a8 100644 --- a/src/SolidClient.php +++ b/src/SolidClient.php @@ -31,7 +31,7 @@ public function __construct( ) { } - public function createContainer(string $parentUrl, string $name, string $data = null): ResponseInterface + public function createContainer(string $parentUrl, string $name, ?string $data = null): ResponseInterface { return $this->post($parentUrl, $data, $name, true); } @@ -41,7 +41,7 @@ public function createContainer(string $parentUrl, string $name, string $data = * * @see https://github.com/solid/solid-web-client/blob/main/src/client.js#L231= */ - public function post(string $url, string $data = null, string $slug = null, bool $isContainer = false, array $options = []): ResponseInterface + public function post(string $url, ?string $data = null, ?string $slug = null, bool $isContainer = false, array $options = []): ResponseInterface { if ($isContainer || !isset($options['headers']['Content-Type'])) { $options['headers']['Content-Type'] = self::DEFAULT_MIME_TYPE; @@ -53,16 +53,56 @@ public function post(string $url, string $data = null, string $slug = null, bool $options['headers']['Slug'] = $slug; } - $options['headers']['Link'] = sprintf('<%s>; rel="type"', $isContainer ? self::LDP_BASIC_CONTAINER : self::LDP_RESOURCE); + $options['headers']['Link'] = \sprintf('<%s>; rel="type"', $isContainer ? self::LDP_BASIC_CONTAINER : self::LDP_RESOURCE); return $this->request('POST', $url, $options); } + public function put(string $url, ?string $data = null, bool $isContainer = false, array $options = []): ResponseInterface + { + if (!isset($options['headers']['Content-Type'])) { + $options['headers']['Content-Type'] = self::DEFAULT_MIME_TYPE; + } + if (null !== $data) { + $options['body'] = $data; + } + if ($isContainer) { + $options['headers']['Link'] = \sprintf('<%s>; rel="type"', self::LDP_BASIC_CONTAINER); + } + + return $this->request('PUT', $url, $options); + } + public function get(string $url, array $options = []): ResponseInterface { return $this->request('GET', $url, $options); } + public function head(string $url, array $options = []): ResponseInterface + { + return $this->request('HEAD', $url, $options); + } + + public function delete(string $url, array $options = []): ResponseInterface + { + return $this->request('DELETE', $url, $options); + } + + public function patch(string $url, string $data, string $contentType = 'application/sparql-update', array $options = []): ResponseInterface + { + $options['headers']['Content-Type'] = $contentType; + $options['body'] = $data; + + return $this->request('PATCH', $url, $options); + } + + public function getResourceMetadata(string $url, array $options = []): ResourceMetadata + { + $response = $this->head($url, $options); + + return ResourceMetadata::fromResponseHeaders($response->getHeaders(false)); + } + public function request(string $method, string $url, array $options = []): ResponseInterface { if ($accessToken = $this->oidcClient?->getAccessToken()) { @@ -88,7 +128,7 @@ public function getOidcIssuer(string $webId, array $options = []): string { $graph = $this->getProfile($webId, $options); - $issuer = $graph->get($webId, sprintf('<%s>', self::OIDC_ISSUER))?->getUri(); + $issuer = $graph->get($webId, \sprintf('<%s>', self::OIDC_ISSUER))?->getUri(); if (!\is_string($issuer)) { throw new Exception('Unable to find the OIDC issuer associated with this WebID', 1); } diff --git a/src/SolidClientFactory.php b/src/SolidClientFactory.php index fcfe629..7d21591 100644 --- a/src/SolidClientFactory.php +++ b/src/SolidClientFactory.php @@ -22,7 +22,7 @@ public function __construct(private readonly HttpClientInterface $httpClient) { } - public function create(OidcClient $oidcClient = null): SolidClient + public function create(?OidcClient $oidcClient = null): SolidClient { return new SolidClient($this->httpClient, $oidcClient); } diff --git a/tests/ResourceMetadataTest.php b/tests/ResourceMetadataTest.php new file mode 100644 index 0000000..0042459 --- /dev/null +++ b/tests/ResourceMetadataTest.php @@ -0,0 +1,68 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Dunglas\PhpSolidClient\Tests; + +use Dunglas\PhpSolidClient\ResourceMetadata; +use PHPUnit\Framework\TestCase; + +class ResourceMetadataTest extends TestCase +{ + public function testFromResponseHeaders(): void + { + $headers = [ + 'content-type' => ['text/turtle; charset=utf-8'], + 'content-length' => ['4567'], + 'last-modified' => ['Mon, 23 Feb 2026 12:00:00 GMT'], + 'link' => [ + '; rel="type"', + '; rel="acl"', + ], + 'wac-allow' => ['user="read write append control",public="read"'], + ]; + + $metadata = ResourceMetadata::fromResponseHeaders($headers); + + $this->assertSame('text/turtle', $metadata->contentType); + $this->assertSame(4567, $metadata->contentLength); + $this->assertInstanceOf(\DateTimeImmutable::class, $metadata->lastModified); + $this->assertSame('http://www.w3.org/ns/ldp#BasicContainer', $metadata->ldpType); + $this->assertTrue($metadata->isContainer()); + $this->assertSame('http://pod.example/resource.acl', $metadata->aclUrl); + $this->assertSame(['read', 'write', 'append', 'control'], $metadata->wacAllow['user']); + $this->assertSame(['read'], $metadata->wacAllow['public']); + } + + public function testResourceNotContainer(): void + { + $headers = [ + 'content-type' => ['application/ld+json'], + 'link' => ['; rel="type"'], + ]; + + $metadata = ResourceMetadata::fromResponseHeaders($headers); + + $this->assertFalse($metadata->isContainer()); + $this->assertSame('http://www.w3.org/ns/ldp#Resource', $metadata->ldpType); + } + + public function testEmptyHeaders(): void + { + $metadata = ResourceMetadata::fromResponseHeaders([]); + + $this->assertNull($metadata->contentType); + $this->assertNull($metadata->contentLength); + $this->assertNull($metadata->lastModified); + $this->assertNull($metadata->ldpType); + $this->assertNull($metadata->aclUrl); + $this->assertSame([], $metadata->wacAllow); + } +} diff --git a/tests/SolidClientTest.php b/tests/SolidClientTest.php new file mode 100644 index 0000000..62e4b9f --- /dev/null +++ b/tests/SolidClientTest.php @@ -0,0 +1,156 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Dunglas\PhpSolidClient\Tests; + +use Dunglas\PhpSolidClient\SolidClient; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; + +class SolidClientTest extends TestCase +{ + private static function findHeader(array $headers, string $name): ?string + { + $prefix = $name.': '; + foreach ($headers as $header) { + if (str_starts_with($header, $prefix)) { + return substr($header, \strlen($prefix)); + } + } + + return null; + } + + public function testPut(): void + { + $response = new MockResponse('', ['http_code' => 201]); + $httpClient = new MockHttpClient($response); + $client = new SolidClient($httpClient); + + $client->put('http://pod.example/resource', '<> a .'); + + $this->assertSame('PUT', $response->getRequestMethod()); + $this->assertSame('http://pod.example/resource', $response->getRequestUrl()); + $this->assertSame('<> a .', $response->getRequestOptions()['body']); + $this->assertSame('text/turtle', self::findHeader($response->getRequestOptions()['headers'], 'Content-Type')); + } + + public function testPutContainer(): void + { + $response = new MockResponse('', ['http_code' => 201]); + $httpClient = new MockHttpClient($response); + $client = new SolidClient($httpClient); + + $client->put('http://pod.example/container/', null, true); + + $this->assertSame('PUT', $response->getRequestMethod()); + $this->assertStringContainsString('ldp#BasicContainer', self::findHeader($response->getRequestOptions()['headers'], 'Link') ?? ''); + } + + public function testPutCustomContentType(): void + { + $response = new MockResponse('', ['http_code' => 201]); + $httpClient = new MockHttpClient($response); + $client = new SolidClient($httpClient); + + $client->put('http://pod.example/resource', '{}', false, [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertSame('application/ld+json', self::findHeader($response->getRequestOptions()['headers'], 'Content-Type')); + } + + public function testHead(): void + { + $response = new MockResponse('', [ + 'http_code' => 200, + 'response_headers' => [ + 'Content-Type' => 'text/turtle', + 'Content-Length' => '1234', + ], + ]); + $httpClient = new MockHttpClient($response); + $client = new SolidClient($httpClient); + + $client->head('http://pod.example/resource'); + + $this->assertSame('HEAD', $response->getRequestMethod()); + $this->assertSame('http://pod.example/resource', $response->getRequestUrl()); + } + + public function testDelete(): void + { + $response = new MockResponse('', ['http_code' => 200]); + $httpClient = new MockHttpClient($response); + $client = new SolidClient($httpClient); + + $client->delete('http://pod.example/resource'); + + $this->assertSame('DELETE', $response->getRequestMethod()); + $this->assertSame('http://pod.example/resource', $response->getRequestUrl()); + } + + public function testPatchSparqlUpdate(): void + { + $sparql = 'INSERT DATA { <> "Test" . }'; + $response = new MockResponse('', ['http_code' => 200]); + $httpClient = new MockHttpClient($response); + $client = new SolidClient($httpClient); + + $client->patch('http://pod.example/resource', $sparql); + + $this->assertSame('PATCH', $response->getRequestMethod()); + $this->assertSame($sparql, $response->getRequestOptions()['body']); + $this->assertSame('application/sparql-update', self::findHeader($response->getRequestOptions()['headers'], 'Content-Type')); + } + + public function testPatchN3(): void + { + $n3Patch = '@prefix solid: . _:patch a solid:InsertDeletePatch .'; + $response = new MockResponse('', ['http_code' => 200]); + $httpClient = new MockHttpClient($response); + $client = new SolidClient($httpClient); + + $client->patch('http://pod.example/resource', $n3Patch, 'text/n3'); + + $this->assertSame('text/n3', self::findHeader($response->getRequestOptions()['headers'], 'Content-Type')); + } + + public function testPost(): void + { + $response = new MockResponse('', [ + 'http_code' => 201, + 'response_headers' => ['Location' => 'http://pod.example/container/new-resource'], + ]); + $httpClient = new MockHttpClient($response); + $client = new SolidClient($httpClient); + + $client->post('http://pod.example/container/', '<> a .', 'new-resource'); + + $this->assertSame('POST', $response->getRequestMethod()); + $this->assertSame('new-resource', self::findHeader($response->getRequestOptions()['headers'], 'Slug')); + $this->assertStringContainsString('ldp#Resource', self::findHeader($response->getRequestOptions()['headers'], 'Link') ?? ''); + } + + public function testGet(): void + { + $body = '<> a .'; + $response = new MockResponse($body, ['http_code' => 200]); + $httpClient = new MockHttpClient($response); + $client = new SolidClient($httpClient); + + $result = $client->get('http://pod.example/resource'); + + $this->assertSame('GET', $response->getRequestMethod()); + $this->assertSame($body, $result->getContent()); + } +}