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/ContainerEntry.php b/src/ContainerEntry.php new file mode 100644 index 0000000..234ee20 --- /dev/null +++ b/src/ContainerEntry.php @@ -0,0 +1,25 @@ + + * 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; + +final class ContainerEntry +{ + /** + * @param list $types RDF types of this entry + */ + public function __construct( + public readonly string $url, + public readonly bool $isContainer, + public readonly array $types = [], + ) { + } +} 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..d1b505e 100644 --- a/src/SolidClient.php +++ b/src/SolidClient.php @@ -12,6 +12,8 @@ namespace Dunglas\PhpSolidClient; use EasyRdf\Graph; +use ML\JsonLD\JsonLD; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; @@ -31,7 +33,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 +43,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 +55,159 @@ 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)); + } + + /** + * Lists the contents of an LDP container by parsing ldp:contains from JSON-LD. + * + * @return list + */ + public function getContainerContents(string $url, array $options = []): array + { + $options['headers']['Accept'] = 'application/ld+json'; + $response = $this->get($url, $options); + $decoded = json_decode($response->getContent()); + $expanded = JsonLD::expand($decoded, ['base' => $url]); + + $entries = []; + foreach ($expanded as $node) { + $contains = $node->{'http://www.w3.org/ns/ldp#contains'} ?? []; + if ([] === $contains || !$contains) { + continue; + } + + foreach ($contains as $entry) { + $entryUrl = $entry->{'@id'} ?? null; + if (null === $entryUrl) { + continue; + } + $types = $entry->{'@type'} ?? []; + $isContainer = [] !== array_filter($types, ResourceMetadata::isContainerType(...)) + || str_ends_with($entryUrl, '/'); + + $entries[] = new ContainerEntry($entryUrl, $isContainer, $types); + } + } + + return $entries; + } + + /** + * Ensures that an LDP container exists at the given URL, creating it (and any missing parents) if necessary. + */ + public function ensureContainerExists(string $url, array $options = []): void + { + if (!str_ends_with($url, '/')) { + $url .= '/'; + } + + try { + $this->head($url, $options)->getHeaders(); + + return; + } catch (ClientExceptionInterface $e) { + if (404 !== $e->getResponse()->getStatusCode()) { + throw $e; + } + } + + // Ensure parent exists first + $parentUrl = self::getParentContainerUrl($url); + if (null !== $parentUrl && $parentUrl !== $url) { + $this->ensureContainerExists($parentUrl, $options); + } + + $this->put($url, null, true, $options); + } + + /** + * Recursively walks an LDP container tree, yielding ContainerEntry objects. + * + * @param int $maxDepth -1 for unlimited, 0 for current level only + * + * @return \Generator + */ + public function walkContainer(string $url, int $maxDepth = -1, array $options = []): \Generator + { + $entries = $this->getContainerContents($url, $options); + + foreach ($entries as $entry) { + yield $entry; + + if ($entry->isContainer && 0 !== $maxDepth) { + yield from $this->walkContainer( + $entry->url, + -1 === $maxDepth ? -1 : $maxDepth - 1, + $options, + ); + } + } + } + + private static function getParentContainerUrl(string $url): ?string + { + $trimmed = rtrim($url, '/'); + $lastSlash = strrpos($trimmed, '/'); + if (false === $lastSlash) { + return null; + } + + $parent = substr($trimmed, 0, $lastSlash + 1); + if (preg_match('#^https?://[^/]+/$#', $parent)) { + return null; + } + + return $parent; + } + public function request(string $method, string $url, array $options = []): ResponseInterface { if ($accessToken = $this->oidcClient?->getAccessToken()) { @@ -88,7 +233,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/ContainerOperationsTest.php b/tests/ContainerOperationsTest.php new file mode 100644 index 0000000..6feec26 --- /dev/null +++ b/tests/ContainerOperationsTest.php @@ -0,0 +1,211 @@ + + * 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\ContainerEntry; +use Dunglas\PhpSolidClient\SolidClient; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; + +class ContainerOperationsTest extends TestCase +{ + public function testGetResourceMetadata(): void + { + $response = new MockResponse('', [ + 'http_code' => 200, + 'response_headers' => [ + 'Content-Type' => 'text/turtle', + 'Content-Length' => '512', + 'Link' => '; rel="type"', + 'WAC-Allow' => 'user="read write",public="read"', + ], + ]); + $httpClient = new MockHttpClient($response); + $client = new SolidClient($httpClient); + + $metadata = $client->getResourceMetadata('http://pod.example/resource'); + + $this->assertSame('text/turtle', $metadata->contentType); + $this->assertSame(512, $metadata->contentLength); + $this->assertSame('http://www.w3.org/ns/ldp#Resource', $metadata->ldpType); + $this->assertFalse($metadata->isContainer()); + $this->assertSame(['read', 'write'], $metadata->wacAllow['user']); + $this->assertSame(['read'], $metadata->wacAllow['public']); + } + + public function testGetContainerContents(): void + { + $jsonLd = json_encode([ + '@id' => 'http://pod.example/container/', + '@type' => ['http://www.w3.org/ns/ldp#BasicContainer'], + 'http://www.w3.org/ns/ldp#contains' => [ + ['@id' => 'http://pod.example/container/file.txt'], + [ + '@id' => 'http://pod.example/container/sub/', + '@type' => ['http://www.w3.org/ns/ldp#BasicContainer'], + ], + ], + ]); + + $response = new MockResponse($jsonLd, [ + 'http_code' => 200, + 'response_headers' => ['Content-Type' => 'application/ld+json'], + ]); + $httpClient = new MockHttpClient($response); + $client = new SolidClient($httpClient); + + $entries = $client->getContainerContents('http://pod.example/container/'); + + $this->assertCount(2, $entries); + $this->assertInstanceOf(ContainerEntry::class, $entries[0]); + $this->assertSame('http://pod.example/container/file.txt', $entries[0]->url); + $this->assertFalse($entries[0]->isContainer); + $this->assertSame('http://pod.example/container/sub/', $entries[1]->url); + $this->assertTrue($entries[1]->isContainer); + } + + public function testGetContainerContentsWithRelativeIds(): void + { + $jsonLd = json_encode([ + '@id' => './', + '@type' => ['http://www.w3.org/ns/ldp#BasicContainer'], + 'http://www.w3.org/ns/ldp#contains' => [ + ['@id' => 'file.txt'], + ['@id' => 'sub/', '@type' => ['http://www.w3.org/ns/ldp#BasicContainer']], + ], + ]); + + $response = new MockResponse($jsonLd, [ + 'http_code' => 200, + 'response_headers' => ['Content-Type' => 'application/ld+json'], + ]); + $httpClient = new MockHttpClient($response); + $client = new SolidClient($httpClient); + + $entries = $client->getContainerContents('http://pod.example/data/'); + + $this->assertCount(2, $entries); + $this->assertSame('http://pod.example/data/file.txt', $entries[0]->url); + $this->assertSame('http://pod.example/data/sub/', $entries[1]->url); + } + + public function testEnsureContainerExistsAlreadyExists(): void + { + $responses = [ + new MockResponse('', ['http_code' => 200]), + ]; + $httpClient = new MockHttpClient($responses); + $client = new SolidClient($httpClient); + + $client->ensureContainerExists('http://pod.example/existing/'); + + // Only one HEAD request should have been made + $this->assertSame(1, $httpClient->getRequestsCount()); + } + + public function testEnsureContainerExistsCreates(): void + { + $requests = []; + $httpClient = new MockHttpClient(static function (string $method, string $url) use (&$requests): MockResponse { + $requests[] = [$method, $url]; + if ('HEAD' === $method && 'http://pod.example/parent/' === $url) { + return new MockResponse('', ['http_code' => 200]); + } + if ('HEAD' === $method) { + return new MockResponse('', ['http_code' => 404]); + } + if ('PUT' === $method) { + return new MockResponse('', ['http_code' => 201]); + } + + return new MockResponse('', ['http_code' => 500]); + }); + $client = new SolidClient($httpClient); + + $client->ensureContainerExists('http://pod.example/parent/child/'); + + // Should have HEAD child/ (404), HEAD parent/ (200), then PUT child/ + $methods = array_column($requests, 0); + $this->assertContains('PUT', $methods); + } + + public function testWalkContainer(): void + { + $rootJson = json_encode([ + '@id' => 'http://pod.example/root/', + '@type' => ['http://www.w3.org/ns/ldp#BasicContainer'], + 'http://www.w3.org/ns/ldp#contains' => [ + ['@id' => 'http://pod.example/root/file.txt'], + ['@id' => 'http://pod.example/root/sub/', '@type' => ['http://www.w3.org/ns/ldp#BasicContainer']], + ], + ]); + + $subJson = json_encode([ + '@id' => 'http://pod.example/root/sub/', + '@type' => ['http://www.w3.org/ns/ldp#BasicContainer'], + 'http://www.w3.org/ns/ldp#contains' => [ + ['@id' => 'http://pod.example/root/sub/nested.ttl'], + ], + ]); + + $httpClient = new MockHttpClient(static function (string $method, string $url) use ($rootJson, $subJson): MockResponse { + if ('http://pod.example/root/' === $url) { + return new MockResponse($rootJson, [ + 'http_code' => 200, + 'response_headers' => ['Content-Type' => 'application/ld+json'], + ]); + } + if ('http://pod.example/root/sub/' === $url) { + return new MockResponse($subJson, [ + 'http_code' => 200, + 'response_headers' => ['Content-Type' => 'application/ld+json'], + ]); + } + + return new MockResponse('', ['http_code' => 404]); + }); + $client = new SolidClient($httpClient); + + $entries = iterator_to_array($client->walkContainer('http://pod.example/root/'), false); + + $this->assertCount(3, $entries); + $this->assertSame('http://pod.example/root/file.txt', $entries[0]->url); + $this->assertSame('http://pod.example/root/sub/', $entries[1]->url); + $this->assertSame('http://pod.example/root/sub/nested.ttl', $entries[2]->url); + } + + public function testWalkContainerWithMaxDepth(): void + { + $rootJson = json_encode([ + '@id' => 'http://pod.example/root/', + '@type' => ['http://www.w3.org/ns/ldp#BasicContainer'], + 'http://www.w3.org/ns/ldp#contains' => [ + ['@id' => 'http://pod.example/root/sub/', '@type' => ['http://www.w3.org/ns/ldp#BasicContainer']], + ], + ]); + + $httpClient = new MockHttpClient(static function (string $method, string $url) use ($rootJson): MockResponse { + return new MockResponse($rootJson, [ + 'http_code' => 200, + 'response_headers' => ['Content-Type' => 'application/ld+json'], + ]); + }); + $client = new SolidClient($httpClient); + + $entries = iterator_to_array($client->walkContainer('http://pod.example/root/', 0), false); + + // maxDepth=0 should only return root level entries, no recursion + $this->assertCount(1, $entries); + $this->assertSame('http://pod.example/root/sub/', $entries[0]->url); + } +} 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()); + } +}