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/JsonLdParser.php b/src/JsonLdParser.php new file mode 100644 index 0000000..4a4d8b4 --- /dev/null +++ b/src/JsonLdParser.php @@ -0,0 +1,64 @@ + + * 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; + +/** + * Parses JSON-LD responses from Solid/CSS servers. + * + * CSS returns expanded JSON-LD by default for Accept: application/ld+json, + * so a full JSON-LD processor is not needed — json_decode is sufficient. + * Relative @id values should be resolved using IriHelper. + */ +final class JsonLdParser +{ + /** + * Parses a JSON-LD string into an array of node arrays. + * + * Handles both single objects and arrays of objects. + * + * @return list> + */ + public static function parse(string $jsonLd): array + { + $decoded = json_decode($jsonLd, true, 512, \JSON_THROW_ON_ERROR); + + // If it's a single object (has @id or @type), wrap in array + if (isset($decoded['@id']) || isset($decoded['@type'])) { + return [$decoded]; + } + + // If it's an array of objects (expanded form) + if (array_is_list($decoded)) { + return $decoded; + } + + return [$decoded]; + } + + /** + * Finds a node by @id in parsed JSON-LD. + * + * @param list> $nodes + * + * @return array|null + */ + public static function findById(array $nodes, string $id): ?array + { + foreach ($nodes as $node) { + if (($node['@id'] ?? null) === $id) { + return $node; + } + } + + return null; + } +} diff --git a/src/ResourceMetadata.php b/src/ResourceMetadata.php new file mode 100644 index 0000000..fdb7d84 --- /dev/null +++ b/src/ResourceMetadata.php @@ -0,0 +1,80 @@ + + * 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 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 function isContainer(): bool + { + return 'http://www.w3.org/ns/ldp#BasicContainer' === $this->ldpType + || 'http://www.w3.org/ns/ldp#Container' === $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; + foreach ($responseHeaders['link'] ?? [] as $linkHeader) { + foreach (explode(',', $linkHeader) as $link) { + $link = trim($link); + if (preg_match('/<([^>]+)>;\s*rel="type"/', $link, $matches)) { + if (str_contains($matches[1], 'ldp#')) { + $ldpType = $matches[1]; + } + } + if (preg_match('/<([^>]+)>;\s*rel="acl"/', $link, $matches)) { + $aclUrl = $matches[1]; + } + } + } + + $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/JsonLdParserTest.php b/tests/JsonLdParserTest.php new file mode 100644 index 0000000..a16dc13 --- /dev/null +++ b/tests/JsonLdParserTest.php @@ -0,0 +1,86 @@ + + * 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\JsonLdParser; +use PHPUnit\Framework\TestCase; + +class JsonLdParserTest extends TestCase +{ + public function testParseSingleObject(): void + { + $jsonLd = json_encode([ + '@id' => 'http://example.com/thing', + '@type' => ['http://schema.org/Thing'], + 'http://schema.org/name' => [['@value' => 'Test']], + ]); + + $result = JsonLdParser::parse($jsonLd); + + $this->assertCount(1, $result); + $this->assertSame('http://example.com/thing', $result[0]['@id']); + } + + public function testParseExpandedArray(): void + { + $jsonLd = json_encode([ + [ + '@id' => 'http://example.com/a', + '@type' => ['http://schema.org/Thing'], + ], + [ + '@id' => 'http://example.com/b', + ], + ]); + + $result = JsonLdParser::parse($jsonLd); + + $this->assertCount(2, $result); + $this->assertSame('http://example.com/a', $result[0]['@id']); + $this->assertSame('http://example.com/b', $result[1]['@id']); + } + + public function testParseLdpContainer(): 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' => 'file.txt'], + ['@id' => 'sub/', '@type' => ['http://www.w3.org/ns/ldp#BasicContainer']], + ], + ]); + + $result = JsonLdParser::parse($jsonLd); + + $this->assertCount(1, $result); + $contains = $result[0]['http://www.w3.org/ns/ldp#contains']; + $this->assertCount(2, $contains); + $this->assertSame('file.txt', $contains[0]['@id']); + $this->assertSame('sub/', $contains[1]['@id']); + } + + public function testFindById(): void + { + $nodes = [ + ['@id' => 'http://example.com/a', 'name' => 'A'], + ['@id' => 'http://example.com/b', 'name' => 'B'], + ]; + + $found = JsonLdParser::findById($nodes, 'http://example.com/b'); + + $this->assertNotNull($found); + $this->assertSame('B', $found['name']); + + $this->assertNull(JsonLdParser::findById($nodes, 'http://example.com/c')); + } +} 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()); + } +}