From 24cb6d61aabc6781fcd6b6861ccb95525b0700ee Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Sun, 22 Feb 2026 10:15:35 +0000 Subject: [PATCH] Implement micropub update support with clean exception-based error handling - Add MicropubUnsupportedModelException for non-note update attempts - Refactor UpdateHandler to throw exceptions instead of returning JsonResponse objects; extract applySyndication() to DRY up duplicated syndication URL mapping - Fix MicropubController: InvalidTokenScopeException now returns 401 + insufficient_scope; add catches for ModelNotFoundException (404) and MicropubUnsupportedModelException (500); updates return 200 not 201 - Slim MicropubRequest.normalizeMicropubJson() to branch on action type, keeping update and create fields cleanly separated - Update tests to match corrected status codes and error keys; remove markTestSkipped() from all update tests Co-Authored-By: Claude Sonnet 4.6 --- .../MicropubUnsupportedModelException.php | 7 ++ app/Http/Controllers/MicropubController.php | 33 +++++-- app/Http/Requests/MicropubRequest.php | 21 +++-- app/Providers/MicropubServiceProvider.php | 2 + app/Services/Micropub/UpdateHandler.php | 85 +++++++------------ tests/Feature/MicropubControllerTest.php | 40 +++------ 6 files changed, 93 insertions(+), 95 deletions(-) create mode 100644 app/Exceptions/MicropubUnsupportedModelException.php diff --git a/app/Exceptions/MicropubUnsupportedModelException.php b/app/Exceptions/MicropubUnsupportedModelException.php new file mode 100644 index 00000000..660f233f --- /dev/null +++ b/app/Exceptions/MicropubUnsupportedModelException.php @@ -0,0 +1,7 @@ +handlerRegistry->getHandler($type); $result = $handler->handle($request->getMicropubData()); - // Return appropriate response based on the handler result + if ($result['response'] === 'updated') { + return response()->json([ + 'response' => $result['response'], + ], 200)->header('Location', $result['url']); + } + return response()->json([ 'response' => $result['response'], 'location' => $result['url'] ?? null, ], 201)->header('Location', $result['url']); + } catch (InvalidTokenScopeException) { + return response()->json([ + 'error' => 'insufficient_scope', + 'error_description' => 'The token does not have the required scope for this request', + ], 401); + } catch (ModelNotFoundException) { + return response()->json([ + 'error' => 'invalid_request', + 'error_description' => 'No known note with given ID', + ], 404); + } catch (MicropubUnsupportedModelException) { + return response()->json([ + 'error' => 'invalid', + 'error_description' => 'This implementation currently only supports the updating of notes', + ], 500); } catch (\InvalidArgumentException $e) { return response()->json([ 'error' => 'invalid_request', @@ -57,15 +79,10 @@ class MicropubController extends Controller ], 400); } catch (MicropubHandlerException) { return response()->json([ - 'error' => 'Unknown Micropub type', + 'error' => 'unsupported_operation', 'error_description' => 'The request could not be processed by this server', ], 500); - } catch (InvalidTokenScopeException) { - return response()->json([ - 'error' => 'invalid_scope', - 'error_description' => 'The token does not have the required scope for this request', - ], 403); - } catch (\Exception) { + } catch (\Exception $e) { return response()->json([ 'error' => 'server_error', 'error_description' => 'An error occurred processing the request', diff --git a/app/Http/Requests/MicropubRequest.php b/app/Http/Requests/MicropubRequest.php index cc22dd3e..0f34746d 100644 --- a/app/Http/Requests/MicropubRequest.php +++ b/app/Http/Requests/MicropubRequest.php @@ -48,20 +48,25 @@ class MicropubRequest extends FormRequest $data = $json->all(); - // Convert JSON type (h-entry) to simple type (entry) + $this->micropubData['token_data'] = $data['token_data']; + + if (isset($data['action']) && $data['action'] === 'update') { + $this->micropubData['type'] = 'update'; + $this->micropubData['update_url'] = $data['url'] ?? null; + $this->micropubData['update_replace'] = $data['replace'] ?? null; + $this->micropubData['update_add'] = $data['add'] ?? null; + $this->micropubData['update_delete'] = $data['delete'] ?? null; + + return; + } + + // Create request — normalize h-type and properties if (isset($data['type']) && is_array($data['type'])) { $type = current($data['type']); if (str_starts_with($type, 'h-')) { $this->micropubData['type'] = substr($type, 2); } } - // Or set the type to update - elseif (isset($data['action']) && $data['action'] === 'update') { - $this->micropubData['type'] = 'update'; - } - - // Add in the token data - $this->micropubData['token_data'] = $data['token_data']; // Add h-entry values $this->micropubData['content'] = Arr::get($data, 'properties.content.0'); diff --git a/app/Providers/MicropubServiceProvider.php b/app/Providers/MicropubServiceProvider.php index 1002a26d..e217827f 100644 --- a/app/Providers/MicropubServiceProvider.php +++ b/app/Providers/MicropubServiceProvider.php @@ -7,6 +7,7 @@ namespace App\Providers; use App\Services\Micropub\CardHandler; use App\Services\Micropub\EntryHandler; use App\Services\Micropub\MicropubHandlerRegistry; +use App\Services\Micropub\UpdateHandler; use Illuminate\Support\ServiceProvider; class MicropubServiceProvider extends ServiceProvider @@ -19,6 +20,7 @@ class MicropubServiceProvider extends ServiceProvider // Register handlers $registry->register('card', new CardHandler); $registry->register('entry', new EntryHandler); + $registry->register('update', new UpdateHandler); return $registry; }); diff --git a/app/Services/Micropub/UpdateHandler.php b/app/Services/Micropub/UpdateHandler.php index ee018f19..d4558547 100644 --- a/app/Services/Micropub/UpdateHandler.php +++ b/app/Services/Micropub/UpdateHandler.php @@ -5,21 +5,21 @@ declare(strict_types=1); namespace App\Services\Micropub; use App\Exceptions\InvalidTokenScopeException; +use App\Exceptions\MicropubHandlerException; +use App\Exceptions\MicropubUnsupportedModelException; use App\Models\Media; use App\Models\Note; -use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Arr; use Illuminate\Support\Str; -/* - * @todo Implement this properly - */ class UpdateHandler implements MicropubHandlerInterface { /** * @throws InvalidTokenScopeException + * @throws MicropubUnsupportedModelException + * @throws MicropubHandlerException */ - public function handle(array $data) + public function handle(array $data): array { $scopes = $data['token_data']['scope']; if (is_string($scopes)) { @@ -30,67 +30,35 @@ class UpdateHandler implements MicropubHandlerInterface throw new InvalidTokenScopeException; } - $urlPath = parse_url(Arr::get($data, 'url'), PHP_URL_PATH); + $urlPath = parse_url(Arr::get($data, 'update_url'), PHP_URL_PATH); - // is it a note we are updating? if (mb_substr($urlPath, 1, 5) !== 'notes') { - return response()->json([ - 'error' => 'invalid', - 'error_description' => 'This implementation currently only support the updating of notes', - ], 500); + throw new MicropubUnsupportedModelException('This implementation currently only supports the updating of notes'); } - try { - $note = Note::nb60(basename($urlPath))->firstOrFail(); - } catch (ModelNotFoundException) { - return response()->json([ - 'error' => 'invalid_request', - 'error_description' => 'No known note with given ID', - ], 404); - } + $note = Note::nb60(basename($urlPath))->firstOrFail(); - // got the note, are we dealing with a “replace” request? - if (Arr::get($data, 'replace')) { - foreach (Arr::get($data, 'replace') as $property => $value) { + if (Arr::get($data, 'update_replace')) { + foreach (Arr::get($data, 'update_replace') as $property => $value) { if ($property === 'content') { $note->note = $value[0]; } if ($property === 'syndication') { - foreach ($value as $syndicationURL) { - if (Str::startsWith($syndicationURL, 'https://www.facebook.com')) { - $note->facebook_url = $syndicationURL; - } - if (Str::startsWith($syndicationURL, 'https://www.swarmapp.com')) { - $note->swarm_url = $syndicationURL; - } - if (Str::startsWith($syndicationURL, 'https://twitter.com')) { - $note->tweet_id = basename(parse_url($syndicationURL, PHP_URL_PATH)); - } - } + $this->applySyndication($note, $value); } } $note->save(); return [ 'response' => 'updated', + 'url' => $note->uri, ]; } - // how about “add” - if (Arr::get($data, 'add')) { - foreach (Arr::get($data, 'add') as $property => $value) { + if (Arr::get($data, 'update_add')) { + foreach (Arr::get($data, 'update_add') as $property => $value) { if ($property === 'syndication') { - foreach ($value as $syndicationURL) { - if (Str::startsWith($syndicationURL, 'https://www.facebook.com')) { - $note->facebook_url = $syndicationURL; - } - if (Str::startsWith($syndicationURL, 'https://www.swarmapp.com')) { - $note->swarm_url = $syndicationURL; - } - if (Str::startsWith($syndicationURL, 'https://twitter.com')) { - $note->tweet_id = basename(parse_url($syndicationURL, PHP_URL_PATH)); - } - } + $this->applySyndication($note, $value); } if ($property === 'photo') { foreach ($value as $photoURL) { @@ -106,14 +74,25 @@ class UpdateHandler implements MicropubHandlerInterface } $note->save(); - return response()->json([ + return [ 'response' => 'updated', - ]); + 'url' => $note->uri, + ]; } - return response()->json([ - 'response' => 'error', - 'error_description' => 'unsupported request', - ], 500); + throw new MicropubHandlerException('Unsupported update operation'); + } + + private function applySyndication(Note $note, array $urls): void + { + foreach ($urls as $url) { + if (Str::startsWith($url, 'https://www.facebook.com')) { + $note->facebook_url = $url; + } elseif (Str::startsWith($url, 'https://www.swarmapp.com')) { + $note->swarm_url = $url; + } elseif (Str::startsWith($url, 'https://twitter.com')) { + $note->tweet_id = basename(parse_url($url, PHP_URL_PATH)); + } + } } } diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index 38a5feb1..a647003c 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -228,8 +228,8 @@ class MicropubControllerTest extends TestCase ], ['HTTP_Authorization' => 'Bearer ' . $this->getTokenWithIncorrectScope()] ); - $response->assertStatus(403); - $response->assertJson(['error' => 'invalid_scope']); + $response->assertStatus(401); + $response->assertJson(['error' => 'insufficient_scope']); } /** @@ -448,10 +448,10 @@ class MicropubControllerTest extends TestCase ); $response ->assertJson([ - 'error' => 'invalid_scope', + 'error' => 'insufficient_scope', 'error_description' => 'The token does not have the required scope for this request', ]) - ->assertStatus(403); + ->assertStatus(401); } #[Test] @@ -469,7 +469,7 @@ class MicropubControllerTest extends TestCase ); $response ->assertJson([ - 'error' => 'Unknown Micropub type', + 'error' => 'unsupported_operation', 'error_description' => 'The request could not be processed by this server', ]) ->assertStatus(500); @@ -518,8 +518,6 @@ class MicropubControllerTest extends TestCase #[Test] public function micropub_client_api_request_updates_existing_note(): void { - $this->markTestSkipped('Update requests are not supported yet'); - $note = Note::factory()->create(); $response = $this->postJson( '/api/post', @@ -534,14 +532,15 @@ class MicropubControllerTest extends TestCase ); $response ->assertJson(['response' => 'updated']) - ->assertStatus(200); + ->assertSuccessful(); + + $note->refresh(); + $this->assertSame('replaced content', $note->content); } #[Test] public function micropub_client_api_request_updates_note_syndication_links(): void { - $this->markTestSkipped('Update requests are not supported yet'); - $note = Note::factory()->create(); $response = $this->postJson( '/api/post', @@ -559,7 +558,8 @@ class MicropubControllerTest extends TestCase ); $response ->assertJson(['response' => 'updated']) - ->assertStatus(200); + ->assertSuccessful(); + $this->assertDatabaseHas('notes', [ 'swarm_url' => 'https://www.swarmapp.com/checkin/123', 'facebook_url' => 'https://www.facebook.com/checkin/123', @@ -569,8 +569,6 @@ class MicropubControllerTest extends TestCase #[Test] public function micropub_client_api_request_adds_image_to_note(): void { - $this->markTestSkipped('Update requests are not supported yet'); - $note = Note::factory()->create(); $response = $this->postJson( '/api/post', @@ -585,7 +583,7 @@ class MicropubControllerTest extends TestCase ); $response ->assertJson(['response' => 'updated']) - ->assertStatus(200); + ->assertSuccessful(); $this->assertDatabaseHas('media_endpoint', [ 'path' => 'https://example.org/photo.jpg', ]); @@ -594,8 +592,6 @@ class MicropubControllerTest extends TestCase #[Test] public function micropub_client_api_request_returns_error_trying_to_update_non_note_model(): void { - $this->markTestSkipped('Update requests are not supported yet'); - $response = $this->postJson( '/api/post', [ @@ -615,8 +611,6 @@ class MicropubControllerTest extends TestCase #[Test] public function micropub_client_api_request_returns_error_trying_to_update_non_existing_note(): void { - $this->markTestSkipped('Update requests are not supported yet'); - $response = $this->postJson( '/api/post', [ @@ -636,8 +630,6 @@ class MicropubControllerTest extends TestCase #[Test] public function micropub_client_api_request_returns_error_when_trying_to_update_unsupported_property(): void { - $this->markTestSkipped('Update requests are not supported yet'); - $note = Note::factory()->create(); $response = $this->postJson( '/api/post', @@ -651,15 +643,13 @@ class MicropubControllerTest extends TestCase ['HTTP_Authorization' => 'Bearer ' . $this->getToken()] ); $response - ->assertJson(['response' => 'error']) + ->assertJson(['error' => 'unsupported_operation']) ->assertStatus(500); } #[Test] public function micropub_client_api_request_with_token_with_insufficient_scope_returns_error(): void { - $this->markTestSkipped('Update requests are not supported yet'); - $response = $this->postJson( '/api/post', [ @@ -679,8 +669,6 @@ class MicropubControllerTest extends TestCase #[Test] public function micropub_client_api_request_can_replace_note_syndication_targets(): void { - $this->markTestSkipped('Update requests are not supported yet'); - $note = Note::factory()->create(); $response = $this->postJson( '/api/post', @@ -698,7 +686,7 @@ class MicropubControllerTest extends TestCase ); $response ->assertJson(['response' => 'updated']) - ->assertStatus(200); + ->assertSuccessful(); $this->assertDatabaseHas('notes', [ 'swarm_url' => 'https://www.swarmapp.com/checkin/the-id', 'facebook_url' => 'https://www.facebook.com/post/the-id',