Skip to content

Commit 90b224f

Browse files
soyukaclaude
andcommitted
feat: add ensureContainerExists with recursive parent creation
Adds ensureContainerExists() which HEADs the target URL and, if it returns 404, recursively ensures parent containers exist before creating the target via PUT with LDP BasicContainer type. Stops recursion at the authority level. CSS requires intermediate containers to exist before creating nested resources, making this a necessary operation before any write to a new path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f259316 commit 90b224f

File tree

2 files changed

+87
-0
lines changed

2 files changed

+87
-0
lines changed

src/SolidClient.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,60 @@ public function getContainerContents(string $url, array $options = []): array
138138
return $entries;
139139
}
140140

141+
/**
142+
* Ensures that an LDP container exists at the given URL, creating it (and any missing parents) if necessary.
143+
*/
144+
public function ensureContainerExists(string $url, array $options = []): void
145+
{
146+
// Normalize: ensure trailing slash
147+
if (!str_ends_with($url, '/')) {
148+
$url .= '/';
149+
}
150+
151+
try {
152+
$response = $this->head($url, $options);
153+
$statusCode = $response->getStatusCode();
154+
if ($statusCode >= 200 && $statusCode < 300) {
155+
return;
156+
}
157+
} catch (\Throwable) {
158+
// Fall through to creation
159+
}
160+
161+
// Ensure parent exists first
162+
$parentUrl = self::getParentContainerUrl($url);
163+
if (null !== $parentUrl && $parentUrl !== $url) {
164+
$this->ensureContainerExists($parentUrl, $options);
165+
}
166+
167+
$this->put($url, null, true, $options);
168+
}
169+
141170
public function getResourceMetadata(string $url, array $options = []): ResourceMetadata
142171
{
143172
$response = $this->head($url, $options);
144173

145174
return ResourceMetadata::fromResponseHeaders($response->getHeaders(false));
146175
}
147176

177+
private static function getParentContainerUrl(string $url): ?string
178+
{
179+
// Remove trailing slash for parsing
180+
$trimmed = rtrim($url, '/');
181+
$lastSlash = strrpos($trimmed, '/');
182+
if (false === $lastSlash) {
183+
return null;
184+
}
185+
186+
$parent = substr($trimmed, 0, $lastSlash + 1);
187+
// Don't go above the authority (e.g., "http://example.com/")
188+
if (preg_match('#^https?://[^/]+/$#', $parent)) {
189+
return null;
190+
}
191+
192+
return $parent;
193+
}
194+
148195
public function request(string $method, string $url, array $options = []): ResponseInterface
149196
{
150197
if ($accessToken = $this->oidcClient?->getAccessToken()) {

tests/ContainerOperationsTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,44 @@ public function testGetContainerContentsWithRelativeIds(): void
9898
$this->assertSame('http://pod.example/data/file.txt', $entries[0]->url);
9999
$this->assertSame('http://pod.example/data/sub/', $entries[1]->url);
100100
}
101+
102+
public function testEnsureContainerExistsAlreadyExists(): void
103+
{
104+
$responses = [
105+
new MockResponse('', ['http_code' => 200]),
106+
];
107+
$httpClient = new MockHttpClient($responses);
108+
$client = new SolidClient($httpClient);
109+
110+
$client->ensureContainerExists('http://pod.example/existing/');
111+
112+
// Only one HEAD request should have been made
113+
$this->assertSame(1, $httpClient->getRequestsCount());
114+
}
115+
116+
public function testEnsureContainerExistsCreates(): void
117+
{
118+
$requests = [];
119+
$httpClient = new MockHttpClient(static function (string $method, string $url) use (&$requests): MockResponse {
120+
$requests[] = [$method, $url];
121+
if ('HEAD' === $method && 'http://pod.example/parent/' === $url) {
122+
return new MockResponse('', ['http_code' => 200]);
123+
}
124+
if ('HEAD' === $method) {
125+
return new MockResponse('', ['http_code' => 404]);
126+
}
127+
if ('PUT' === $method) {
128+
return new MockResponse('', ['http_code' => 201]);
129+
}
130+
131+
return new MockResponse('', ['http_code' => 500]);
132+
});
133+
$client = new SolidClient($httpClient);
134+
135+
$client->ensureContainerExists('http://pod.example/parent/child/');
136+
137+
// Should have HEAD child/ (404), HEAD parent/ (200), then PUT child/
138+
$methods = array_column($requests, 0);
139+
$this->assertContains('PUT', $methods);
140+
}
101141
}

0 commit comments

Comments
 (0)