Skip to content

Commit 25ef70a

Browse files
soyukaclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 6dd077a commit 25ef70a

File tree

4 files changed

+195
-6
lines changed

4 files changed

+195
-6
lines changed

src/Bundle/Form/SolidLoginType.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
5555
public function configureOptions(OptionsResolver $resolver): void
5656
{
5757
$resolver->setDefaults([
58-
'constraints' => [new Callback(function (array $data, ExecutionContextInterface $context): void {
58+
'constraints' => [new Callback(static function (array $data, ExecutionContextInterface $context): void {
5959
$webId = $data['webid'] ?? '';
6060
$op = $data['op'] ?? '';
6161

src/SolidClient.php

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public function __construct(
3131
) {
3232
}
3333

34-
public function createContainer(string $parentUrl, string $name, string $data = null): ResponseInterface
34+
public function createContainer(string $parentUrl, string $name, ?string $data = null): ResponseInterface
3535
{
3636
return $this->post($parentUrl, $data, $name, true);
3737
}
@@ -41,7 +41,7 @@ public function createContainer(string $parentUrl, string $name, string $data =
4141
*
4242
* @see https://github.com/solid/solid-web-client/blob/main/src/client.js#L231=
4343
*/
44-
public function post(string $url, string $data = null, string $slug = null, bool $isContainer = false, array $options = []): ResponseInterface
44+
public function post(string $url, ?string $data = null, ?string $slug = null, bool $isContainer = false, array $options = []): ResponseInterface
4545
{
4646
if ($isContainer || !isset($options['headers']['Content-Type'])) {
4747
$options['headers']['Content-Type'] = self::DEFAULT_MIME_TYPE;
@@ -53,16 +53,49 @@ public function post(string $url, string $data = null, string $slug = null, bool
5353
$options['headers']['Slug'] = $slug;
5454
}
5555

56-
$options['headers']['Link'] = sprintf('<%s>; rel="type"', $isContainer ? self::LDP_BASIC_CONTAINER : self::LDP_RESOURCE);
56+
$options['headers']['Link'] = \sprintf('<%s>; rel="type"', $isContainer ? self::LDP_BASIC_CONTAINER : self::LDP_RESOURCE);
5757

5858
return $this->request('POST', $url, $options);
5959
}
6060

61+
public function put(string $url, ?string $data = null, bool $isContainer = false, array $options = []): ResponseInterface
62+
{
63+
if (!isset($options['headers']['Content-Type'])) {
64+
$options['headers']['Content-Type'] = self::DEFAULT_MIME_TYPE;
65+
}
66+
if (null !== $data) {
67+
$options['body'] = $data;
68+
}
69+
if ($isContainer) {
70+
$options['headers']['Link'] = \sprintf('<%s>; rel="type"', self::LDP_BASIC_CONTAINER);
71+
}
72+
73+
return $this->request('PUT', $url, $options);
74+
}
75+
6176
public function get(string $url, array $options = []): ResponseInterface
6277
{
6378
return $this->request('GET', $url, $options);
6479
}
6580

81+
public function head(string $url, array $options = []): ResponseInterface
82+
{
83+
return $this->request('HEAD', $url, $options);
84+
}
85+
86+
public function delete(string $url, array $options = []): ResponseInterface
87+
{
88+
return $this->request('DELETE', $url, $options);
89+
}
90+
91+
public function patch(string $url, string $data, string $contentType = 'application/sparql-update', array $options = []): ResponseInterface
92+
{
93+
$options['headers']['Content-Type'] = $contentType;
94+
$options['body'] = $data;
95+
96+
return $this->request('PATCH', $url, $options);
97+
}
98+
6699
public function request(string $method, string $url, array $options = []): ResponseInterface
67100
{
68101
if ($accessToken = $this->oidcClient?->getAccessToken()) {
@@ -88,7 +121,7 @@ public function getOidcIssuer(string $webId, array $options = []): string
88121
{
89122
$graph = $this->getProfile($webId, $options);
90123

91-
$issuer = $graph->get($webId, sprintf('<%s>', self::OIDC_ISSUER))?->getUri();
124+
$issuer = $graph->get($webId, \sprintf('<%s>', self::OIDC_ISSUER))?->getUri();
92125
if (!\is_string($issuer)) {
93126
throw new Exception('Unable to find the OIDC issuer associated with this WebID', 1);
94127
}

src/SolidClientFactory.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public function __construct(private readonly HttpClientInterface $httpClient)
2222
{
2323
}
2424

25-
public function create(OidcClient $oidcClient = null): SolidClient
25+
public function create(?OidcClient $oidcClient = null): SolidClient
2626
{
2727
return new SolidClient($this->httpClient, $oidcClient);
2828
}

tests/SolidClientTest.php

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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\SolidClient;
15+
use PHPUnit\Framework\TestCase;
16+
use Symfony\Component\HttpClient\MockHttpClient;
17+
use Symfony\Component\HttpClient\Response\MockResponse;
18+
19+
class SolidClientTest extends TestCase
20+
{
21+
private static function findHeader(array $headers, string $name): ?string
22+
{
23+
$prefix = $name.': ';
24+
foreach ($headers as $header) {
25+
if (str_starts_with($header, $prefix)) {
26+
return substr($header, \strlen($prefix));
27+
}
28+
}
29+
30+
return null;
31+
}
32+
33+
public function testPut(): void
34+
{
35+
$response = new MockResponse('', ['http_code' => 201]);
36+
$httpClient = new MockHttpClient($response);
37+
$client = new SolidClient($httpClient);
38+
39+
$client->put('http://pod.example/resource', '<> a <http://schema.org/Thing> .');
40+
41+
$this->assertSame('PUT', $response->getRequestMethod());
42+
$this->assertSame('http://pod.example/resource', $response->getRequestUrl());
43+
$this->assertSame('<> a <http://schema.org/Thing> .', $response->getRequestOptions()['body']);
44+
$this->assertSame('text/turtle', self::findHeader($response->getRequestOptions()['headers'], 'Content-Type'));
45+
}
46+
47+
public function testPutContainer(): void
48+
{
49+
$response = new MockResponse('', ['http_code' => 201]);
50+
$httpClient = new MockHttpClient($response);
51+
$client = new SolidClient($httpClient);
52+
53+
$client->put('http://pod.example/container/', null, true);
54+
55+
$this->assertSame('PUT', $response->getRequestMethod());
56+
$this->assertStringContainsString('ldp#BasicContainer', self::findHeader($response->getRequestOptions()['headers'], 'Link') ?? '');
57+
}
58+
59+
public function testPutCustomContentType(): void
60+
{
61+
$response = new MockResponse('', ['http_code' => 201]);
62+
$httpClient = new MockHttpClient($response);
63+
$client = new SolidClient($httpClient);
64+
65+
$client->put('http://pod.example/resource', '{}', false, [
66+
'headers' => ['Content-Type' => 'application/ld+json'],
67+
]);
68+
69+
$this->assertSame('application/ld+json', self::findHeader($response->getRequestOptions()['headers'], 'Content-Type'));
70+
}
71+
72+
public function testHead(): void
73+
{
74+
$response = new MockResponse('', [
75+
'http_code' => 200,
76+
'response_headers' => [
77+
'Content-Type' => 'text/turtle',
78+
'Content-Length' => '1234',
79+
],
80+
]);
81+
$httpClient = new MockHttpClient($response);
82+
$client = new SolidClient($httpClient);
83+
84+
$client->head('http://pod.example/resource');
85+
86+
$this->assertSame('HEAD', $response->getRequestMethod());
87+
$this->assertSame('http://pod.example/resource', $response->getRequestUrl());
88+
}
89+
90+
public function testDelete(): void
91+
{
92+
$response = new MockResponse('', ['http_code' => 200]);
93+
$httpClient = new MockHttpClient($response);
94+
$client = new SolidClient($httpClient);
95+
96+
$client->delete('http://pod.example/resource');
97+
98+
$this->assertSame('DELETE', $response->getRequestMethod());
99+
$this->assertSame('http://pod.example/resource', $response->getRequestUrl());
100+
}
101+
102+
public function testPatchSparqlUpdate(): void
103+
{
104+
$sparql = 'INSERT DATA { <> <http://schema.org/name> "Test" . }';
105+
$response = new MockResponse('', ['http_code' => 200]);
106+
$httpClient = new MockHttpClient($response);
107+
$client = new SolidClient($httpClient);
108+
109+
$client->patch('http://pod.example/resource', $sparql);
110+
111+
$this->assertSame('PATCH', $response->getRequestMethod());
112+
$this->assertSame($sparql, $response->getRequestOptions()['body']);
113+
$this->assertSame('application/sparql-update', self::findHeader($response->getRequestOptions()['headers'], 'Content-Type'));
114+
}
115+
116+
public function testPatchN3(): void
117+
{
118+
$n3Patch = '@prefix solid: <http://www.w3.org/ns/solid/terms#>. _:patch a solid:InsertDeletePatch .';
119+
$response = new MockResponse('', ['http_code' => 200]);
120+
$httpClient = new MockHttpClient($response);
121+
$client = new SolidClient($httpClient);
122+
123+
$client->patch('http://pod.example/resource', $n3Patch, 'text/n3');
124+
125+
$this->assertSame('text/n3', self::findHeader($response->getRequestOptions()['headers'], 'Content-Type'));
126+
}
127+
128+
public function testPost(): void
129+
{
130+
$response = new MockResponse('', [
131+
'http_code' => 201,
132+
'response_headers' => ['Location' => 'http://pod.example/container/new-resource'],
133+
]);
134+
$httpClient = new MockHttpClient($response);
135+
$client = new SolidClient($httpClient);
136+
137+
$client->post('http://pod.example/container/', '<> a <http://schema.org/Thing> .', 'new-resource');
138+
139+
$this->assertSame('POST', $response->getRequestMethod());
140+
$this->assertSame('new-resource', self::findHeader($response->getRequestOptions()['headers'], 'Slug'));
141+
$this->assertStringContainsString('ldp#Resource', self::findHeader($response->getRequestOptions()['headers'], 'Link') ?? '');
142+
}
143+
144+
public function testGet(): void
145+
{
146+
$body = '<> a <http://schema.org/Thing> .';
147+
$response = new MockResponse($body, ['http_code' => 200]);
148+
$httpClient = new MockHttpClient($response);
149+
$client = new SolidClient($httpClient);
150+
151+
$result = $client->get('http://pod.example/resource');
152+
153+
$this->assertSame('GET', $response->getRequestMethod());
154+
$this->assertSame($body, $result->getContent());
155+
}
156+
}

0 commit comments

Comments
 (0)