Updates idempotentes en Cohete: PUT /post/org/{id}
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.
Comentarios (0)
Sin comentarios todavia. Se el primero!
Deja un comentario