From 1137d2a07e3fdff7715e76e9754c58b7e2b5f3ba Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Fri, 13 Mar 2026 20:39:50 +0000 Subject: [PATCH] Move request parsing into DTOs via fromRequest(), expand update handler Each DTO now owns its own parsing logic via fromRequest(), removing the normalization layer from MicropubRequest entirely. UpdateHandler gains delete support and allows replace/add/delete to compose in a single request rather than being mutually exclusive. Co-Authored-By: Claude Sonnet 4.6 --- app/Http/Controllers/MicropubController.php | 17 +-- app/Http/Requests/MicropubRequest.php | 108 ++--------------- app/Services/Micropub/Data/CardData.php | 30 +++++ app/Services/Micropub/Data/EntryData.php | 60 ++++++++++ app/Services/Micropub/Data/MicropubData.php | 4 + app/Services/Micropub/Data/UpdateData.php | 25 ++++ .../Micropub/Handlers/UpdateHandler.php | 82 ++++++++++--- tests/Feature/MicropubControllerTest.php | 110 ++++++++++++++++++ 8 files changed, 311 insertions(+), 125 deletions(-) diff --git a/app/Http/Controllers/MicropubController.php b/app/Http/Controllers/MicropubController.php index a1e81e36..c6008a9c 100644 --- a/app/Http/Controllers/MicropubController.php +++ b/app/Http/Controllers/MicropubController.php @@ -10,6 +10,7 @@ use App\Exceptions\MicropubUnsupportedModelException; use App\Http\Requests\MicropubRequest; use App\Models\Place; use App\Models\SyndicationTarget; +use App\Services\Micropub\Data\MicropubData; use App\Services\Micropub\MicropubHandlerRegistry; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; @@ -28,9 +29,9 @@ class MicropubController extends Controller /** * Respond to a POST request to the micropub endpoint. * - * The request is initially processed by the MicropubRequest form request - * class. The normalizes the data, so we can pass it into the handlers for - * the different micropub requests, h-entry or h-card, for example. + * MicropubRequest detects the request type (e.g. entry, card, update). + * The handler registry resolves the appropriate handler, whose DTO + * extracts the relevant fields from the request via fromRequest(). */ public function post(MicropubRequest $request): JsonResponse { @@ -46,7 +47,8 @@ class MicropubController extends Controller try { $handler = $this->handlerRegistry->getHandler($type); $dataClass = $handler->dataClass(); - $data = $dataClass::fromArray($request->getMicropubData()); + /** @var MicropubData $data */ + $data = $dataClass::fromRequest($request); $result = $handler->handle($data); if ($result['response'] === 'updated') { @@ -95,10 +97,9 @@ class MicropubController extends Controller /** * Respond to a GET request to the micropub endpoint. * - * A GET request has been made to `api/post` with an accompanying - * token, here we check whether the token is valid and respond - * appropriately. Further if the request has the query parameter - * syndicate-to we respond with the known syndication endpoints. + * Token validation is handled by the VerifyMicropubToken middleware. + * Supports q=syndicate-to, q=config, and q=geo:, queries. + * The default response returns the token metadata. */ public function get(Request $request): JsonResponse { diff --git a/app/Http/Requests/MicropubRequest.php b/app/Http/Requests/MicropubRequest.php index 0f34746d..ab4fde74 100644 --- a/app/Http/Requests/MicropubRequest.php +++ b/app/Http/Requests/MicropubRequest.php @@ -5,12 +5,9 @@ declare(strict_types=1); namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Support\Arr; class MicropubRequest extends FormRequest { - protected array $micropubData = []; - public function rules(): array { return [ @@ -18,110 +15,25 @@ class MicropubRequest extends FormRequest ]; } - public function getMicropubData(): array - { - return $this->micropubData; - } - public function getType(): ?string { - // Return consistent type regardless of input format - return $this->micropubData['type'] ?? null; - } - - protected function prepareForValidation(): void - { - // Normalize the request data based on content type if ($this->isJson()) { - $this->normalizeMicropubJson(); - } else { - $this->normalizeMicropubForm(); - } - } + $data = $this->json()->all(); - private function normalizeMicropubJson(): void - { - $json = $this->json(); - if ($json === null) { - throw new \InvalidArgumentException('`isJson()` passed but there is no json data'); - } - - $data = $json->all(); - - $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); + if (isset($data['action']) && $data['action'] === 'update') { + return 'update'; } - } - // Add h-entry values - $this->micropubData['content'] = Arr::get($data, 'properties.content.0'); - $this->micropubData['in-reply-to'] = Arr::get($data, 'properties.in-reply-to.0'); - $this->micropubData['published'] = Arr::get($data, 'properties.published.0'); - $this->micropubData['location'] = $this->getLocationData($data); - $this->micropubData['bookmark-of'] = Arr::get($data, 'properties.bookmark-of.0'); - $this->micropubData['like-of'] = Arr::get($data, 'properties.like-of.0'); - $this->micropubData['mp-syndicate-to'] = Arr::get($data, 'properties.mp-syndicate-to'); + if (isset($data['type']) && is_array($data['type'])) { + $type = current($data['type']); + if (str_starts_with($type, 'h-')) { + return substr($type, 2); + } + } - // Add h-card values - $this->micropubData['name'] = Arr::get($data, 'properties.name.0'); - $this->micropubData['description'] = Arr::get($data, 'properties.description.0'); - $this->micropubData['geo'] = Arr::get($data, 'properties.geo.0'); - - // Add checkin value - $this->micropubData['checkin'] = Arr::get($data, 'checkin'); - $this->micropubData['syndication'] = Arr::get($data, 'properties.syndication.0'); - - // Add photos - $this->micropubData['photos'] = Arr::get($data, 'properties.photo'); - } - - private function normalizeMicropubForm(): void - { - // Convert form h=entry to type=entry - if ($h = $this->input('h')) { - $this->micropubData['type'] = $h; - } - - // Add some fields to the micropub data with default null values - $this->micropubData['in-reply-to'] = null; - $this->micropubData['published'] = null; - $this->micropubData['location'] = null; - $this->micropubData['description'] = null; - $this->micropubData['geo'] = null; - $this->micropubData['latitude'] = null; - $this->micropubData['longitude'] = null; - - // Map form fields to micropub data - foreach ($this->except(['h', 'access_token']) as $key => $value) { - $this->micropubData[$key] = $value; - } - } - - private function getLocationData(array $data): array|string|null - { - if (! Arr::has($data, 'properties.location')) { return null; } - if (Arr::has($data, 'properties.location.0')) { - return Arr::get($data, 'properties.location.0'); - } - - return Arr::get($data, 'properties.location'); + return $this->input('h') ?: null; } } diff --git a/app/Services/Micropub/Data/CardData.php b/app/Services/Micropub/Data/CardData.php index cee41109..cab40419 100644 --- a/app/Services/Micropub/Data/CardData.php +++ b/app/Services/Micropub/Data/CardData.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace App\Services\Micropub\Data; +use Illuminate\Http\Request; +use Illuminate\Support\Arr; + class CardData extends MicropubData { public function __construct( @@ -16,6 +19,33 @@ class CardData extends MicropubData public readonly ?string $longitude, ) {} + public static function fromRequest(Request $request): static + { + if ($request->isJson()) { + $data = $request->json()->all(); + + return new static( + tokenData: $data['token_data'], + name: Arr::get($data, 'properties.name.0'), + description: Arr::get($data, 'properties.description.0'), + geo: Arr::get($data, 'properties.geo.0'), + location: Arr::get($data, 'properties.location'), + latitude: null, + longitude: null, + ); + } + + return new static( + tokenData: $request->input('token_data'), + name: $request->input('name'), + description: $request->input('description'), + geo: $request->input('geo'), + location: $request->input('location'), + latitude: $request->input('latitude'), + longitude: $request->input('longitude'), + ); + } + public static function fromArray(array $data): static { return new static( diff --git a/app/Services/Micropub/Data/EntryData.php b/app/Services/Micropub/Data/EntryData.php index cf87fdc5..8942be78 100644 --- a/app/Services/Micropub/Data/EntryData.php +++ b/app/Services/Micropub/Data/EntryData.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace App\Services\Micropub\Data; +use Illuminate\Http\Request; +use Illuminate\Support\Arr; + class EntryData extends MicropubData { public function __construct( @@ -23,6 +26,63 @@ class EntryData extends MicropubData public readonly ?array $photos, ) {} + public static function fromRequest(Request $request): static + { + if ($request->isJson()) { + $data = $request->json()->all(); + + $rawContent = Arr::get($data, 'properties.content.0'); + $content = is_array($rawContent) ? ($rawContent['html'] ?? $rawContent['value'] ?? null) : $rawContent; + + return new static( + tokenData: $data['token_data'], + content: $content, + inReplyTo: Arr::get($data, 'properties.in-reply-to.0'), + published: Arr::get($data, 'properties.published.0'), + location: self::extractLocationData($data), + bookmarkOf: Arr::get($data, 'properties.bookmark-of.0'), + likeOf: Arr::get($data, 'properties.like-of.0'), + mpSyndicateTo: Arr::get($data, 'properties.mp-syndicate-to'), + name: Arr::get($data, 'properties.name.0'), + description: Arr::get($data, 'properties.description.0'), + geo: Arr::get($data, 'properties.geo.0'), + checkin: Arr::get($data, 'checkin'), + syndication: Arr::get($data, 'properties.syndication.0'), + photos: Arr::get($data, 'properties.photo'), + ); + } + + return new static( + tokenData: $request->input('token_data'), + content: $request->input('content'), + inReplyTo: $request->input('in-reply-to'), + published: $request->input('published'), + location: $request->input('location'), + bookmarkOf: $request->input('bookmark-of'), + likeOf: $request->input('like-of'), + mpSyndicateTo: $request->input('mp-syndicate-to'), + name: $request->input('name'), + description: $request->input('description'), + geo: $request->input('geo'), + checkin: $request->input('checkin'), + syndication: $request->input('syndication'), + photos: $request->input('photos'), + ); + } + + private static function extractLocationData(array $data): array|string|null + { + if (! Arr::has($data, 'properties.location')) { + return null; + } + + if (Arr::has($data, 'properties.location.0')) { + return Arr::get($data, 'properties.location.0'); + } + + return Arr::get($data, 'properties.location'); + } + public static function fromArray(array $data): static { return new static( diff --git a/app/Services/Micropub/Data/MicropubData.php b/app/Services/Micropub/Data/MicropubData.php index 3fabcadc..d8d56a29 100644 --- a/app/Services/Micropub/Data/MicropubData.php +++ b/app/Services/Micropub/Data/MicropubData.php @@ -4,8 +4,12 @@ declare(strict_types=1); namespace App\Services\Micropub\Data; +use Illuminate\Http\Request; + abstract class MicropubData { + abstract public static function fromRequest(Request $request): static; + abstract public static function fromArray(array $data): static; abstract public function toArray(): array; diff --git a/app/Services/Micropub/Data/UpdateData.php b/app/Services/Micropub/Data/UpdateData.php index b44cc56f..bedaee6d 100644 --- a/app/Services/Micropub/Data/UpdateData.php +++ b/app/Services/Micropub/Data/UpdateData.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Services\Micropub\Data; +use Illuminate\Http\Request; + class UpdateData extends MicropubData { public function __construct( @@ -14,6 +16,29 @@ class UpdateData extends MicropubData public readonly ?array $updateDelete, ) {} + public static function fromRequest(Request $request): static + { + if ($request->isJson()) { + $data = $request->json()->all(); + + return new static( + tokenData: $data['token_data'], + updateUrl: $data['url'] ?? null, + updateReplace: $data['replace'] ?? null, + updateAdd: $data['add'] ?? null, + updateDelete: $data['delete'] ?? null, + ); + } + + return new static( + tokenData: $request->input('token_data'), + updateUrl: $request->input('url'), + updateReplace: $request->input('replace'), + updateAdd: $request->input('add'), + updateDelete: $request->input('delete'), + ); + } + public static function fromArray(array $data): static { return new static( diff --git a/app/Services/Micropub/Handlers/UpdateHandler.php b/app/Services/Micropub/Handlers/UpdateHandler.php index fb633c8b..354cdbdd 100644 --- a/app/Services/Micropub/Handlers/UpdateHandler.php +++ b/app/Services/Micropub/Handlers/UpdateHandler.php @@ -46,21 +46,18 @@ class UpdateHandler implements MicropubHandlerInterface $note = Note::nb60(basename($urlPath))->firstOrFail(); + if ($data->updateReplace === null && $data->updateAdd === null && $data->updateDelete === null) { + throw new MicropubHandlerException('Unsupported update operation'); + } + if ($data->updateReplace !== null) { foreach ($data->updateReplace as $property => $value) { - if ($property === 'content') { - $note->note = $value[0]; - } - if ($property === 'syndication') { - $this->applySyndication($note, $value); - } + match ($property) { + 'content' => $note->note = $value[0], + 'syndication' => $this->applySyndication($note, $value), + default => null, + }; } - $note->save(); - - return [ - 'response' => 'updated', - 'url' => $note->uri, - ]; } if ($data->updateAdd !== null) { @@ -80,15 +77,62 @@ class UpdateHandler implements MicropubHandlerInterface } } } - $note->save(); - - return [ - 'response' => 'updated', - 'url' => $note->uri, - ]; } - throw new MicropubHandlerException('Unsupported update operation'); + if ($data->updateDelete !== null) { + $this->applyDelete($note, $data->updateDelete); + } + + $note->save(); + + return [ + 'response' => 'updated', + 'url' => $note->uri, + ]; + } + + private function applyDelete(Note $note, array $delete): void + { + if (array_is_list($delete)) { + foreach ($delete as $property) { + match ($property) { + 'syndication' => $this->clearSyndication($note), + 'photo' => $note->media()->delete(), + default => null, + }; + } + + return; + } + + foreach ($delete as $property => $values) { + if ($property === 'syndication') { + $this->removeSyndicationValues($note, $values); + } + if ($property === 'photo') { + $note->media()->whereIn('path', $values)->delete(); + } + } + } + + private function clearSyndication(Note $note): void + { + $note->facebook_url = null; + $note->swarm_url = null; + $note->tweet_id = null; + } + + private function removeSyndicationValues(Note $note, array $urls): void + { + foreach ($urls as $url) { + if (Str::startsWith($url, 'https://www.facebook.com') && $note->facebook_url === $url) { + $note->facebook_url = null; + } elseif (Str::startsWith($url, 'https://www.swarmapp.com') && $note->swarm_url === $url) { + $note->swarm_url = null; + } elseif (Str::startsWith($url, 'https://twitter.com')) { + $note->tweet_id = null; + } + } } private function applySyndication(Note $note, array $urls): void diff --git a/tests/Feature/MicropubControllerTest.php b/tests/Feature/MicropubControllerTest.php index a647003c..10d129c0 100644 --- a/tests/Feature/MicropubControllerTest.php +++ b/tests/Feature/MicropubControllerTest.php @@ -711,6 +711,116 @@ class MicropubControllerTest extends TestCase $this->assertDatabaseHas('notes', ['note' => $note]); } + #[Test] + public function micropub_client_api_request_can_combine_replace_and_add_in_single_update(): void + { + $note = Note::factory()->create(); + $response = $this->postJson( + '/api/post', + [ + 'action' => 'update', + 'url' => $note->uri, + 'replace' => [ + 'content' => ['replaced content'], + ], + 'add' => [ + 'syndication' => ['https://www.swarmapp.com/checkin/123'], + ], + ], + ['HTTP_Authorization' => 'Bearer ' . $this->getToken()] + ); + $response + ->assertJson(['response' => 'updated']) + ->assertSuccessful(); + + $note->refresh(); + $this->assertSame('replaced content', $note->content); + $this->assertDatabaseHas('notes', [ + 'swarm_url' => 'https://www.swarmapp.com/checkin/123', + ]); + } + + #[Test] + public function micropub_client_api_request_can_delete_entire_property(): void + { + $note = Note::factory()->create([ + 'swarm_url' => 'https://www.swarmapp.com/checkin/123', + 'facebook_url' => 'https://www.facebook.com/post/123', + ]); + $response = $this->postJson( + '/api/post', + [ + 'action' => 'update', + 'url' => $note->uri, + 'delete' => ['syndication'], + ], + ['HTTP_Authorization' => 'Bearer ' . $this->getToken()] + ); + $response + ->assertJson(['response' => 'updated']) + ->assertSuccessful(); + + $note->refresh(); + $this->assertNull($note->swarm_url); + $this->assertNull($note->facebook_url); + $this->assertNull($note->tweet_id); + } + + #[Test] + public function micropub_client_api_request_can_delete_specific_syndication_value(): void + { + $note = Note::factory()->create([ + 'swarm_url' => 'https://www.swarmapp.com/checkin/123', + 'facebook_url' => 'https://www.facebook.com/post/123', + ]); + $response = $this->postJson( + '/api/post', + [ + 'action' => 'update', + 'url' => $note->uri, + 'delete' => [ + 'syndication' => ['https://www.swarmapp.com/checkin/123'], + ], + ], + ['HTTP_Authorization' => 'Bearer ' . $this->getToken()] + ); + $response + ->assertJson(['response' => 'updated']) + ->assertSuccessful(); + + $note->refresh(); + $this->assertNull($note->swarm_url); + $this->assertSame('https://www.facebook.com/post/123', $note->facebook_url); + } + + #[Test] + public function micropub_client_api_request_can_delete_photo(): void + { + $note = Note::factory()->create(); + $media = new \App\Models\Media; + $media->path = 'https://example.org/photo.jpg'; + $media->type = 'image'; + $media->save(); + $note->media()->save($media); + + $response = $this->postJson( + '/api/post', + [ + 'action' => 'update', + 'url' => $note->uri, + 'delete' => ['photo'], + ], + ['HTTP_Authorization' => 'Bearer ' . $this->getToken()] + ); + $response + ->assertJson(['response' => 'updated']) + ->assertSuccessful(); + + $this->assertDatabaseMissing('media_endpoint', [ + 'path' => 'https://example.org/photo.jpg', + ]); + } + #[Test] public function micropub_client_api_request_creates_articles_when_it_includes_the_name_property(): void {