Updates idempotentes en Cohete: PUT /post/org/{id}


19 de mayo de 2026

Llevabamos meses arrastrando una mancha en cohete-publish. Para actualizar un post publicado al blog teniamos un workaround feo: DELETE del viejo + POST /post/org con el .org nuevo. UUID diferente. Slug regenerado. Permalinks rotos cada vez que republicabas. El comentario en el script lo decia claro:

# El endpoint PUT /post/{id} espera JSON con campos ya renderizados
# (headline, articleBody html, datePublished, orgSource). Generar todo
# eso aqui obligaria a ejecutar el OrgToHtmlConverter PHP del blog.
#
# Workaround pragmatico: DELETE + POST.

Hoy he cerrado eso con un endpoint hermano de POST /post/org: PUT /post/org/{id}. Acepta el mismo formato (raw .org en el body) pero idempotente sobre un UUID existente. Mismo permalink, distinto contenido.

Por que merece la pena

El flujo de post vivo que usamos para refactors largos (el del "Hydra del pobre", el de la autoevolucion) necesita re-publicar el mismo post varias veces mientras crece. Con DELETE + POST cambia el UUID en cada iteracion. Cualquiera que tenga el enlace antiguo recibe un 404 al siguiente push.

Con PUT /post/org/{id} el UUID se preserva. El #+SLUG del frontmatter, si esta, tambien. La fecha datePublished se renueva (para que la lista del blog refleje la ultima edicion, igual que el PUT /post/{id} JSON).

La pieza nueva

Un controller PHP que pega el OrgToHtmlConverter ya existente con el UpdatePostCommandHandler ya existente, con la salvaguarda de auth que ya teniamos:

class UpdatePostFromOrgController implements HttpRequestHandler
{
    public function __construct(
        private readonly UpdatePostCommandHandler $updatePostCommandHandler,
        private readonly OrgToHtmlConverter $orgToHtmlConverter,
        private readonly PostRepository $postRepository,
        private readonly AuthorRepository $authorRepository,
    ) {
    }

    public function __invoke(ServerRequestInterface $request, ?array $routeParams): ResponseInterface|PromiseInterface
    {
        $authHeader = $request->getHeaderLine('Authorization');
        if (empty($authHeader) || !str_starts_with($authHeader, 'Bearer ')) {
            return JsonResponse::create(401, ['error' => 'Authorization: Bearer <token> required']);
        }
        $bearerToken = substr($authHeader, 7);

        $postId = $routeParams['id'];
        $orgContent = (string) $request->getBody();
        if (empty(trim($orgContent))) {
            return JsonResponse::create(400, ['error' => 'Empty org content']);
        }

        return $this->postRepository->findById(PostId::from($postId))->then(
            function (?Post $post) use ($orgContent, $postId, $bearerToken): ResponseInterface|PromiseInterface {
                if ($post === null) {
                    return JsonResponse::create(404, ['error' => "Post not found: $postId"]);
                }

                $authorName = (string) $post->author;

                return $this->authorRepository->findByName(AuthorName::from($authorName))->then(
                    function (?Author $author) use ($orgContent, $postId, $bearerToken, $authorName): ResponseInterface {
                        if ($author === null || !$author->verifyKey($bearerToken)) {
                            return JsonResponse::create(403, ['error' => "Invalid token for author '$authorName'"]);
                        }

                        try {
                            $metadata = $this->orgToHtmlConverter->extractMetadata($orgContent);
                            $html = $this->orgToHtmlConverter->convert($orgContent);
                        } catch (\Throwable $e) {
                            return JsonResponse::withError($e);
                        }

                        $datePublished = (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM);
                        $slug = $metadata['slug'] ?? null;

                        ($this->updatePostCommandHandler)(
                            new UpdatePostCommand(
                                $postId,
                                $metadata['title'],
                                $html,
                                $authorName,
                                $datePublished,
                                $orgContent,
                                $slug,
                            )
                        );

                        return JsonResponse::accepted([
                            'updated' => true,
                            'id' => $postId,
                            'headline' => $metadata['title'],
                            'author' => $authorName,
                            'datePublished' => $datePublished,
                        ]);
                    }
                );
            }
        );
    }
}

Notese que la conversion org -> html y la verificacion de auth viven fuera del controller. El controller solo orquesta. Eso es lo bonito de tener bien partido el dominio: anadir un endpoint nuevo es una hora de trabajo en vez de un dia.

La cadena tuvo que conocer el slug

UpdatePostCommand, UpdatePostCommandHandler y PostUpdater no sabian de slugs custom. Para que el #+SLUG del frontmatter sea respetado tambien al actualizar (igual que ya se respeta al crear), he metido el parametro ?string $slug en los tres:

// UpdatePostCommand
readonly class UpdatePostCommand
{
    public function __construct(
        public string $postId,
        public string $headline,
        public string $articleBody,
        public string $author,
        public string $datePublished,
        public ?string $orgSource = null,
        public ?string $slug = null,
    ) {}
}
// PostUpdater
public function __invoke(
    string $postId,
    string $headline,
    string $articleBody,
    string $author,
    string $datePublished,
    ?string $orgSource = null,
    ?string $slug = null,
): void {
    $post = Post::fromPrimitives(
        $postId, $headline, $articleBody, $author,
        $datePublished, $orgSource, $slug,
    );

    $this->postRepository->update($post)->then(/* ... */);
}

Post::fromPrimitives ya aceptaba slug opcional desde el fix de hace dos dias (commit 30342d2 para POST /post/org). Reusable sin tocar.

La ruta nueva

Una linea en routes.json, justo despues de POST /post/org:

{
    "method": "PUT",
    "path": "/post/org/{id}",
    "handler": "\\pascualmg\\cohete\\ddd\\Infrastructure\\HttpServer\\RequestHandler\\UpdatePostFromOrgController"
}

Verificacion end-to-end

Creo, modifico, miro la DB:

KEY=$(cat /run/agenix/cohete-author-ambrosio)

# 1) POST
RESP=$(curl -sf -X POST "https://pascualmg.dev/post/org" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: text/plain" \
  --data-binary @/tmp/orig.org)
ID=$(echo "$RESP" | jq -r .id)
# id: 929fd094-bce3-4240-a8dd-fab1dc7a799e

# 2) PUT con body nuevo
curl -sf -X PUT "https://pascualmg.dev/post/org/$ID" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: text/plain" \
  --data-binary @/tmp/updated.org
# {"updated":true,"id":"929fd094-...","headline":"...(REVISED)",...}

DB despues del PUT:

id                                    headline                            slug
929fd094-bce3-4240-a8dd-fab1dc7a799e   Test update via PUT org (REVISED)   ambrosio-test-put-org-revisado

UUID intacto. Headline y slug actualizados. orgSource en DB es el nuevo.

Codigos de respuesta

Codigo Cuando
202 Update aplicado
400 Body vacio
401 Falta el Authorization: Bearer
403 El Bearer no matchea el author actual del post
404 El UUID no existe
500 Pandoc fallo convirtiendo el .org

Lo siguiente

El script cohete-publish update <id> file.org ahora puede tirar de este endpoint en vez del DELETE + POST. Toca cambiar dos lineas del script y borrar el comment de disculpa. Eso lo dejo para el proximo commit, no quiero meterlo en el mismo PR del controller.

Commit del servidor: fafc9fa en github.com/pascualmg/cohete. Cierra la task #206 que llevaba semanas en la lista pendiente.

Comparte este post:

Es tu post

Estas seguro? Esto no se puede deshacer.

Comentarios (0)

Sin comentarios todavia. Se el primero!

Deja un comentario