A barebones implementation of an atproto PDS in PHP and Slim Framework 4.
pds php atproto
0

Configure Feed

Select the types of activity you want to include in your feed.

chore: return 405 errors in xrpc expected format

Andrés Ignacio Torres (May 20, 2026, 9:00 AM -0700) 67cfbdb0 88a1ac0b

+136
+5
public/index.php
··· 4 4 5 5 use App\Application\Actions\Pds\XrpcException; 6 6 use App\Application\Handlers\HttpErrorHandler; 7 + use App\Application\Handlers\MethodNotAllowedErrorHandler; 7 8 use App\Application\Handlers\ShutdownHandler; 8 9 use App\Application\Handlers\XrpcErrorHandler; 9 10 use App\Application\ResponseEmitter\ResponseEmitter; ··· 11 12 use DI\ContainerBuilder; 12 13 use Slim\Factory\AppFactory; 13 14 use Slim\Factory\ServerRequestCreatorFactory; 15 + use Slim\Exception\HttpMethodNotAllowedException; 14 16 15 17 require __DIR__ . '/../vendor/autoload.php'; 16 18 ··· 97 99 // Render atproto XRPC errors in the {"error": "...", "message": "..."} format 98 100 $xrpcErrorHandler = new XrpcErrorHandler($callableResolver, $responseFactory); 99 101 $errorMiddleware->setErrorHandler(XrpcException::class, $xrpcErrorHandler); 102 + 103 + $methodNotAllowedErrorHandler = new MethodNotAllowedErrorHandler($callableResolver, $responseFactory); 104 + $errorMiddleware->setErrorHandler(HttpMethodNotAllowedException::class, $methodNotAllowedErrorHandler); 100 105 101 106 // Run App & Emit Response 102 107 $response = $app->handle($request);
+59
src/Application/Handlers/MethodNotAllowedErrorHandler.php
··· 1 + <?php 2 + 3 + declare(strict_types=1); 4 + 5 + namespace App\Application\Handlers; 6 + 7 + use App\Application\Actions\ActionError; 8 + use App\Application\Actions\ActionPayload; 9 + use Psr\Http\Message\ResponseInterface as Response; 10 + use Slim\Exception\HttpMethodNotAllowedException; 11 + use Slim\Handlers\ErrorHandler as SlimErrorHandler; 12 + 13 + class MethodNotAllowedErrorHandler extends SlimErrorHandler 14 + { 15 + /** 16 + * @inheritdoc 17 + */ 18 + protected function respond(): Response 19 + { 20 + $exception = $this->exception; 21 + $response = $this->responseFactory->createResponse(405); 22 + 23 + if ($exception instanceof HttpMethodNotAllowedException) { 24 + $response = $response->withHeader('Allow', implode(', ', $exception->getAllowedMethods())); 25 + } 26 + 27 + if ($this->isXrpcRequest()) { 28 + $payload = (string) json_encode( 29 + [ 30 + 'error' => 'MethodNotAllowed', 31 + 'message' => $exception->getMessage(), 32 + ], 33 + JSON_PRETTY_PRINT 34 + ); 35 + 36 + $response->getBody()->write($payload); 37 + 38 + return $response->withHeader('Content-Type', 'application/json'); 39 + } 40 + 41 + $payload = new ActionPayload( 42 + 405, 43 + null, 44 + new ActionError(ActionError::NOT_ALLOWED, $exception->getMessage()) 45 + ); 46 + $encodedPayload = (string) json_encode($payload, JSON_PRETTY_PRINT); 47 + 48 + $response->getBody()->write($encodedPayload); 49 + 50 + return $response->withHeader('Content-Type', 'application/json'); 51 + } 52 + 53 + private function isXrpcRequest(): bool 54 + { 55 + $path = $this->request->getUri()->getPath(); 56 + 57 + return $path === '/xrpc' || str_starts_with($path, '/xrpc/'); 58 + } 59 + }
+72
tests/Application/Actions/RoutesTest.php
··· 4 4 5 5 namespace Tests\Application\Actions; 6 6 7 + use App\Application\Actions\Pds\XrpcException; 8 + use App\Application\Handlers\HttpErrorHandler; 9 + use App\Application\Handlers\MethodNotAllowedErrorHandler; 10 + use App\Application\Handlers\XrpcErrorHandler; 11 + use Psr\Container\ContainerInterface; 12 + use Slim\App; 13 + use Slim\Exception\HttpMethodNotAllowedException; 7 14 use Tests\TestCase; 8 15 9 16 class RoutesTest extends TestCase ··· 52 59 $this->assertStringContainsString('phpds', $body); 53 60 $this->assertStringContainsString('atproto personal data server', $body); 54 61 $this->assertStringContainsString('/xrpc/', $body); 62 + } 63 + 64 + public function testXrpcMethodNotAllowedReturnsXrpcErrorPayload(): void 65 + { 66 + $app = $this->getAppInstance(); 67 + $this->configureErrorHandling($app); 68 + 69 + $request = $this->createRequest('POST', '/xrpc/com.atproto.server.describeServer'); 70 + $response = $app->handle($request); 71 + 72 + $this->assertSame(405, $response->getStatusCode()); 73 + $this->assertSame('GET, OPTIONS', $response->getHeaderLine('Allow')); 74 + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); 75 + $this->assertSame( 76 + [ 77 + 'error' => 'MethodNotAllowed', 78 + 'message' => 'Method not allowed. Must be one of: GET, OPTIONS', 79 + ], 80 + json_decode((string) $response->getBody(), true) 81 + ); 82 + } 83 + 84 + public function testNonXrpcMethodNotAllowedKeepsDefaultApiErrorPayload(): void 85 + { 86 + $app = $this->getAppInstance(); 87 + $this->configureErrorHandling($app); 88 + 89 + $request = $this->createRequest('POST', '/health'); 90 + $response = $app->handle($request); 91 + 92 + $this->assertSame(405, $response->getStatusCode()); 93 + $this->assertSame('GET, OPTIONS', $response->getHeaderLine('Allow')); 94 + /** @var array{error?: mixed} $payload */ 95 + $payload = json_decode((string) $response->getBody(), true); 96 + $error = $payload['error'] ?? null; 97 + 98 + $this->assertIsArray($error); 99 + $this->assertSame('NOT_ALLOWED', $error['type'] ?? null); 100 + $this->assertSame( 101 + 'Method not allowed. Must be one of: GET, OPTIONS', 102 + $error['description'] ?? null 103 + ); 104 + } 105 + 106 + /** 107 + * @param App<ContainerInterface|null> $app 108 + */ 109 + private function configureErrorHandling(App $app): void 110 + { 111 + $callableResolver = $app->getCallableResolver(); 112 + $responseFactory = $app->getResponseFactory(); 113 + 114 + $app->addRoutingMiddleware(); 115 + $app->addBodyParsingMiddleware(); 116 + 117 + $errorMiddleware = $app->addErrorMiddleware(false, false, false); 118 + $errorMiddleware->setDefaultErrorHandler(new HttpErrorHandler($callableResolver, $responseFactory)); 119 + $errorMiddleware->setErrorHandler( 120 + XrpcException::class, 121 + new XrpcErrorHandler($callableResolver, $responseFactory) 122 + ); 123 + $errorMiddleware->setErrorHandler( 124 + HttpMethodNotAllowedException::class, 125 + new MethodNotAllowedErrorHandler($callableResolver, $responseFactory) 126 + ); 55 127 } 56 128 }