jonnybarnes.uk/app/Http/Controllers/MicropubController.php
Jonny Barnes ad15b36f4d
Introduce typed DTOs and sub-namespaces for Micropub handlers
Each handler now declares the data class it needs via dataClass(). The
controller builds the appropriate typed DTO from the raw request array
before calling handle(), giving handlers typed property access instead
of raw array lookups.

Handlers moved to App\Services\Micropub\Handlers, data objects to
App\Services\Micropub\Data. MicropubHandlerRegistry and the interface
are documented with flow diagrams and guidance for adding new types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 10:41:33 +00:00

149 lines
5.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Exceptions\InvalidTokenScopeException;
use App\Exceptions\MicropubHandlerException;
use App\Exceptions\MicropubUnsupportedModelException;
use App\Http\Requests\MicropubRequest;
use App\Models\Place;
use App\Models\SyndicationTarget;
use App\Services\Micropub\MicropubHandlerRegistry;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Lcobucci\JWT\Token;
class MicropubController extends Controller
{
protected MicropubHandlerRegistry $handlerRegistry;
public function __construct(MicropubHandlerRegistry $handlerRegistry)
{
$this->handlerRegistry = $handlerRegistry;
}
/**
* 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.
*/
public function post(MicropubRequest $request): JsonResponse
{
$type = $request->getType();
if (! $type) {
return response()->json([
'error' => 'invalid_request',
'error_description' => 'Microformat object type is missing, for example: h-entry or h-card',
], 400);
}
try {
$handler = $this->handlerRegistry->getHandler($type);
$dataClass = $handler->dataClass();
$data = $dataClass::fromArray($request->getMicropubData());
$result = $handler->handle($data);
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',
'error_description' => $e->getMessage(),
], 400);
} catch (MicropubHandlerException) {
return response()->json([
'error' => 'unsupported_operation',
'error_description' => 'The request could not be processed by this server',
], 500);
} catch (\Exception $e) {
return response()->json([
'error' => 'server_error',
'error_description' => 'An error occurred processing the request',
], 500);
}
}
/**
* 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.
*/
public function get(Request $request): JsonResponse
{
if ($request->input('q') === 'syndicate-to') {
return response()->json([
'syndicate-to' => SyndicationTarget::all(),
]);
}
if ($request->input('q') === 'config') {
return response()->json([
'syndicate-to' => SyndicationTarget::all(),
'media-endpoint' => route('media-endpoint'),
]);
}
if ($request->has('q') && str_starts_with($request->input('q'), 'geo:')) {
preg_match_all(
'/([0-9.\-]+)/',
$request->input('q'),
$matches
);
$distance = (count($matches[0]) === 3) ? 100 * $matches[0][2] : 1000;
$places = Place::near(
(object) ['latitude' => $matches[0][0], 'longitude' => $matches[0][1]],
$distance
)->get();
return response()->json([
'response' => 'places',
'places' => $places,
]);
}
// the default response is just to return the token data
/** @var Token $tokenData */
$tokenData = $request->input('token_data');
return response()->json([
'response' => 'token',
'token' => [
'me' => $tokenData['me'],
'scope' => $tokenData['scope'],
'client_id' => $tokenData['client_id'],
],
]);
}
}