Adicionando o Dev.to como um alvo de syndication
Eu já tenho um app Rails que importa posts para o Medium e dispara um anúncio no LinkedIn assim que eles são publicados. Adicionar o Dev.to parecia o próximo passo óbvio, já que é onde boa parte do público de desenvolvedores realmente está. Imaginei que seria rápido, já que a arquitetura já existia.
E foi, na maior parte. Mas algumas coisas me surpreenderam.
A API é simplesmente melhor
A API do Medium está depreciada. Preciso usar um token que expira, fazer polling num feed RSS para detectar quando um rascunho é publicado, e injetar tags <figure> para imagens de destaque porque a API não tem um campo main_image. A coisa toda se sustenta com parsing de string.
O Dev.to é o oposto. A API do Forem é documentada, mantida ativamente, e faz o que você esperaria: POST /api/articles cria um rascunho, PUT /api/articles/{id} atualiza ele, GET /api/articles/me/all retorna sua lista completa de artigos com timestamps de published_at. Sem RSS. Sem scraping. A imagem de destaque é só um campo no payload.
A autenticação também é mais simples: um único header api-key das configurações da sua conta, armazenado como uma coluna criptografada no model Blog.
Espelhando a arquitetura do Medium
Minha integração com o Medium usa um padrão em que todo service que faz chamadas HTTP recebe um Proc connection: que retorna uma resposta falsa nos testes. Isso me permite testar os ramos de erro (401, 429, 5xx) sem WebMock ou VCR, basta passar uma lambda que retorna um Struct.new(:code, :body).
Mantive o mesmo padrão para o Devto::Client:
def initialize(token:, connection: default_connection)
@token = token
@connection = connection
end
def create_article(payload:)
response = request(Net::HTTP::Post, "/api/articles", payload)
raise AuthError, "..." if response.code == "401"
JSON.parse(response.body)
end
O resto da camada de service espelha o Medium quase por completo: DraftCreator, Publisher, PublishedSync. A principal diferença é que PublishedSync substitui tanto o RssPoller quanto o PublishedBackfill do Medium, porque a API retorna todos os artigos com seu published_at, então não há nada para fazer backfill separadamente.
O refactor do anúncio no LinkedIn
Assim que passei a ter duas plataformas de syndication, a lógica de anúncio no LinkedIn precisou morar em algum lugar compartilhado. Antes ela estava inline no MediumSyncJob, um método privado que checava expiração de token, contagem de retries e idempotência.
Eu extraí ela para Syndication::LinkedInAnnounce, um PORO pequeno que recebe um blog e itera sobre os posts elegíveis:
def eligible_posts
@blog.posts
.where(medium_status: Post.medium_statuses[:published])
.or(@blog.posts.where(devto_status: Post.devto_statuses[:published]))
.select { |p| p.linkedin_not_posted? || (p.linkedin_failed? && p.linkedin_attempts < 3) }
end
A query .or() significa que um post publicado em qualquer uma das plataformas dispara o LinkedIn. O select cuida da idempotência: se o post já tem linkedin_status: :posted, ele é pulado. Um post publicado tanto no Medium quanto no Dev.to só é anunciado uma vez.
Tanto o MediumSyncJob quanto o DevtoSyncJob agora chamam announcer_class.new(blog).call, onde announcer_class é um class_attribute com valor padrão Syndication::LinkedInAnnounce, injetável nos testes sem nenhuma mágica de stubbing.
A armadilha do Zeitwerk
Nomeei a classe como Syndication::LinkedinAnnounce (“in” minúsculo). Parecia natural. Os testes quebraram na hora com NameError: uninitialized constant Syndication::LinkedinAnnounce.
O problema: config/initializers/inflections.rb tem inflect.acronym "LinkedIn". O Zeitwerk usa o inflector do Rails para mapear nomes de arquivo para nomes de constante, então linkedin_announce.rb é mapeado para LinkedInAnnounce, não LinkedinAnnounce. O nome da classe e a constante esperada divergiram silenciosamente.
A correção foi renomear a classe para Syndication::LinkedInAnnounce. Vale saber: qualquer arquivo de service cujo nome contenha um acrônimo registrado (OAuth, GitHub, AWS) precisa usar a forma inflexionada, ou o Zeitwerk não vai encontrar.
Testes
As mesmas convenções de Minitest do resto do app: sem WebMock, sem VCR. Os testes do job usam injeção via class_attribute:
setup do
@original_sync = DevtoSyncJob.sync_class
@original_announcer = DevtoSyncJob.announcer_class
end
teardown do
DevtoSyncJob.sync_class = @original_sync
DevtoSyncJob.announcer_class = @original_announcer
end
Salva o original, injeta um fake, restaura no teardown. Funciona bem com execução paralela de testes porque cada processo de teste recebe sua própria instância de classe.
O teste de idempotência é explícito: um post publicado nas duas plataformas deve aparecer só uma vez no log de chamadas do announcer:
test "idempotent: a post published on both Medium and Dev.to is only announced once" do
post = @blog.posts.create!(slug: "p", medium_status: :published, devto_status: :published, ...)
calls = []
Syndication::LinkedInAnnounce.new(@blog, creator_class: creator_class(calls)).call
assert_equal 1, calls.count { |p| p.id == post.id }
end
O que eu faria diferente
A sanitização de tags (gsub(/[^a-z0-9]/i, "").downcase) é tosca. “C++” vira “c”, “C#” vira “c”. O Dev.to tem restrições de tag (minúsculas, alfanuméricas, no máximo 4), mas eu deveria tratar melhor os casos comuns, ou pelo menos logar quando uma tag fica deformada, para deixar isso visível. Hoje isso falha silenciosamente.
Também deixei de lado a descoberta de username. A API tem GET /users/me, que permitiria guardar devto_username no model Blog. Deixei isso de fora por enquanto, já que a URL do artigo já volta na resposta de criação de qualquer forma.
Próximos passos
Hashnode está na lista. Mesmo padrão, formato de API diferente. Nesse ponto a arquitetura de syndication já está estável o bastante para que adicionar uma terceira plataforma seja praticamente só somar.
Leitura relacionada
Apagando uma integração ao migrar ela para o Postiz
Roteando a publicação no Dev.to pelo Postiz e apagando o cliente nativo: o DTO que valida mais rígido que o runtime, o rascunho que não é rascunho, e uma capa perdida no storage local.
Uma correção de desvio de documentação que não foi tão chata quanto parecia
Três itens da auditoria que cada um virou outra coisa: uma alegação meio corrigida, uma recuperação de senha silenciosamente morta, e um e-mail de staging que apontaria para produção.
Um 500 escondido dentro das rotas isoladas de uma engine montada
O painel de jobs retornava um 500 em vez de uma página de login: helpers de rota sem qualificação resolvem contra a engine, não contra a aplicação. Uma linha, mais o gêmeo dormente dela.
Você também pode achar útil
Proton Pass
Gerenciador de senhas focado em privacidade, da equipe por trás do Proton Mail.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maisProton Drive
Armazenamento em nuvem criptografado, da equipe por trás do Proton Mail.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maiseSIM Airalo
eSIM de dados local para viagens - sem necessidade de trocar um SIM físico.
Este é meu link de indicação da Airalo. Você recebe um desconto no seu primeiro eSIM e eu ganho crédito da Airalo para o meu.
Saiba mais