From 25ef70aa614877fd5220ce38714073772e1a8fcd Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 23 Feb 2026 17:09:34 +0100 Subject: [PATCH 1/4] feat: add PUT, HEAD, DELETE, and PATCH HTTP methods to SolidClient Implements the four missing LDP HTTP methods: - put(): idempotent resource creation/update at a known URI, with optional LDP BasicContainer Link header for container creation - head(): inspect resource metadata without fetching the body - delete(): remove resources or containers - patch(): partial RDF updates via SPARQL Update (default) or N3 Patch Also applies CS Fixer auto-fixes (explicit nullable types, \sprintf). Co-Authored-By: Claude Opus 4.6 --- src/Bundle/Form/SolidLoginType.php | 2 +- src/SolidClient.php | 41 +++++++- src/SolidClientFactory.php | 2 +- tests/SolidClientTest.php | 156 +++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 tests/SolidClientTest.php 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/SolidClient.php b/src/SolidClient.php index 7e61e0e..ac1faf1 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,49 @@ 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 request(string $method, string $url, array $options = []): ResponseInterface { if ($accessToken = $this->oidcClient?->getAccessToken()) { @@ -88,7 +121,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/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()); + } +} From e7898c87475ca1924b6bde3f7fe54546c68225fb Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 23 Feb 2026 17:10:06 +0100 Subject: [PATCH 2/4] feat: add ResourceMetadata value object from HEAD responses Adds ResourceMetadata, a typed value object parsed from HEAD response headers providing: contentType, contentLength, lastModified, ldpType (container vs resource from Link header), wacAllow (parsed permissions), and aclUrl (from Link rel="acl"). Adds getResourceMetadata() to SolidClient as a convenience wrapper. Co-Authored-By: Claude Opus 4.6 --- src/ResourceMetadata.php | 80 ++++++++++++++++++++++++++++++++++ src/SolidClient.php | 7 +++ tests/ResourceMetadataTest.php | 68 +++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 src/ResourceMetadata.php create mode 100644 tests/ResourceMetadataTest.php 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 ac1faf1..08d31a8 100644 --- a/src/SolidClient.php +++ b/src/SolidClient.php @@ -96,6 +96,13 @@ public function patch(string $url, string $data, string $contentType = 'applicat 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()) { 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); + } +} From 2af62e274389ec08a85536ac6a700e589b874fbd Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 23 Feb 2026 17:10:22 +0100 Subject: [PATCH 3/4] feat: add JSON-LD response parser for Solid/CSS responses CSS returns expanded JSON-LD by default for Accept: application/ld+json. JsonLdParser uses json_decode directly (not the ml/json-ld processor) since CSS responses are already in expanded form. Provides parse() for normalizing single objects and arrays, and findById() for node lookup. Co-Authored-By: Claude Opus 4.6 --- src/JsonLdParser.php | 64 ++++++++++++++++++++++++++++ tests/JsonLdParserTest.php | 86 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 src/JsonLdParser.php create mode 100644 tests/JsonLdParserTest.php 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/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')); + } +} From da72ffa529316a2d6f408f8e31ac1ccb8e9c4384 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 23 Feb 2026 17:10:29 +0100 Subject: [PATCH 4/4] feat: add RFC 3986 relative IRI resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IriHelper::resolve() implements RFC 3986 §5 for resolving relative references against a base URI. Needed because CSS JSON-LD responses may contain relative @id values that must be resolved against the request URL. Tested against the full RFC 3986 §5.4 normal examples. Co-Authored-By: Claude Opus 4.6 --- src/IriHelper.php | 150 ++++++++++++++++++++++++++++++++++++++++ tests/IriHelperTest.php | 75 ++++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 src/IriHelper.php create mode 100644 tests/IriHelperTest.php diff --git a/src/IriHelper.php b/src/IriHelper.php new file mode 100644 index 0000000..c759902 --- /dev/null +++ b/src/IriHelper.php @@ -0,0 +1,150 @@ + + * 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; + +/** + * RFC 3986 §5 relative IRI resolution. + */ +final class IriHelper +{ + /** + * Resolves a relative reference against a base URI per RFC 3986 §5.3. + */ + public static function resolve(string $base, string $reference): string + { + // If the reference is already absolute, return it + $r = self::parse($reference); + if (null !== $r['scheme']) { + return self::recompose( + $r['scheme'], + $r['authority'], + self::removeDotSegments($r['path']), + $r['query'], + $r['fragment'], + ); + } + + $b = self::parse($base); + + if (null !== $r['authority']) { + return self::recompose( + $b['scheme'], + $r['authority'], + self::removeDotSegments($r['path']), + $r['query'], + $r['fragment'], + ); + } + + if ('' === $r['path']) { + $path = $b['path']; + $query = $r['query'] ?? $b['query']; + } else { + if (str_starts_with($r['path'], '/')) { + $path = self::removeDotSegments($r['path']); + } else { + $path = self::merge($b, $r['path']); + $path = self::removeDotSegments($path); + } + $query = $r['query']; + } + + return self::recompose($b['scheme'], $b['authority'], $path, $query, $r['fragment']); + } + + /** + * @return array{scheme: ?string, authority: ?string, path: string, query: ?string, fragment: ?string} + */ + private static function parse(string $uri): array + { + // RFC 3986 Appendix B regex + preg_match('~^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?~', $uri, $m); + + return [ + 'scheme' => isset($m[2]) && '' !== $m[2] ? $m[2] : null, + 'authority' => isset($m[3]) && '' !== $m[3] ? $m[4] : null, + 'path' => $m[5] ?? '', + 'query' => isset($m[6]) && '' !== $m[6] ? $m[7] : null, + 'fragment' => isset($m[8]) && '' !== $m[8] ? $m[9] : null, + ]; + } + + /** + * @param array{scheme: ?string, authority: ?string, path: string, query: ?string, fragment: ?string} $base + */ + private static function merge(array $base, string $referencePath): string + { + if (null !== $base['authority'] && '' === $base['path']) { + return '/'.$referencePath; + } + + $lastSlash = strrpos($base['path'], '/'); + + return false !== $lastSlash + ? substr($base['path'], 0, $lastSlash + 1).$referencePath + : $referencePath; + } + + private static function removeDotSegments(string $path): string + { + $output = []; + $segments = explode('/', $path); + $absolute = str_starts_with($path, '/'); + $trailingSlash = false; + + foreach ($segments as $segment) { + if ('.' === $segment) { + $trailingSlash = true; + continue; + } + if ('..' === $segment) { + array_pop($output); + $trailingSlash = true; + continue; + } + $trailingSlash = false; + $output[] = $segment; + } + + $result = implode('/', $output); + if ($trailingSlash && !str_ends_with($result, '/')) { + $result .= '/'; + } + + // Preserve leading slash for absolute paths + if ($absolute && !str_starts_with($result, '/')) { + $result = '/'.$result; + } + + return $result; + } + + private static function recompose(?string $scheme, ?string $authority, string $path, ?string $query, ?string $fragment): string + { + $result = ''; + if (null !== $scheme) { + $result .= $scheme.':'; + } + if (null !== $authority) { + $result .= '//'.$authority; + } + $result .= $path; + if (null !== $query) { + $result .= '?'.$query; + } + if (null !== $fragment) { + $result .= '#'.$fragment; + } + + return $result; + } +} diff --git a/tests/IriHelperTest.php b/tests/IriHelperTest.php new file mode 100644 index 0000000..ca43f4a --- /dev/null +++ b/tests/IriHelperTest.php @@ -0,0 +1,75 @@ + + * 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\IriHelper; +use PHPUnit\Framework\TestCase; + +class IriHelperTest extends TestCase +{ + private const BASE = 'http://a/b/c/d;p?q'; + + /** + * RFC 3986 §5.4 — Normal examples. + * + * @dataProvider rfc3986NormalExamplesProvider + */ + public function testRfc3986NormalExamples(string $reference, string $expected): void + { + $this->assertSame($expected, IriHelper::resolve(self::BASE, $reference)); + } + + /** + * @return iterable + */ + public static function rfc3986NormalExamplesProvider(): iterable + { + yield 'g:h' => ['g:h', 'g:h']; + yield 'g' => ['g', 'http://a/b/c/g']; + yield './g' => ['./g', 'http://a/b/c/g']; + yield 'g/' => ['g/', 'http://a/b/c/g/']; + yield '/g' => ['/g', 'http://a/g']; + yield '//g' => ['//g', 'http://g']; + yield '?y' => ['?y', 'http://a/b/c/d;p?y']; + yield 'g?y' => ['g?y', 'http://a/b/c/g?y']; + yield '#s' => ['#s', 'http://a/b/c/d;p?q#s']; + yield 'g#s' => ['g#s', 'http://a/b/c/g#s']; + yield 'g?y#s' => ['g?y#s', 'http://a/b/c/g?y#s']; + yield ';x' => [';x', 'http://a/b/c/;x']; + yield 'g;x' => ['g;x', 'http://a/b/c/g;x']; + yield 'g;x?y#s' => ['g;x?y#s', 'http://a/b/c/g;x?y#s']; + yield 'empty' => ['', 'http://a/b/c/d;p?q']; + yield '.' => ['.', 'http://a/b/c/']; + yield './' => ['./', 'http://a/b/c/']; + yield '..' => ['..', 'http://a/b/']; + yield '../' => ['../', 'http://a/b/']; + yield '../g' => ['../g', 'http://a/b/g']; + yield '../..' => ['../..', 'http://a/']; + yield '../../' => ['../../', 'http://a/']; + yield '../../g' => ['../../g', 'http://a/g']; + } + + public function testSolidPodRelativeId(): void + { + $base = 'http://localhost:3000/test/'; + $this->assertSame('http://localhost:3000/test/file.ttl', IriHelper::resolve($base, 'file.ttl')); + $this->assertSame('http://localhost:3000/test/sub/', IriHelper::resolve($base, 'sub/')); + } + + public function testAbsoluteReferenceUnchanged(): void + { + $this->assertSame( + 'https://other.example/path', + IriHelper::resolve('http://example.com/base/', 'https://other.example/path'), + ); + } +}