Skip to content
Development

Building Medium cross-posting for blog-manager

By Victor Da Luz
railsrubymediumdev-logblog-manager

I’ve been publishing posts on my blog at vdaluz.com for a while, and the Medium cross-posting step was always manual: copy the HTML, paste it in, set the canonical URL, trim the title if it was too long, pick tags, click draft. I’d written a Rails app to manage my blog already, so adding a “push to Medium” button seemed like the natural next step.

Here’s how I built it.

What I was trying to do

The goal was simple on paper: click a button on a post’s detail page, have the app send the post to Medium as a draft with the canonical URL pointing back to vdaluz.com, and show me live feedback while it was happening.

The constraints came from the Medium API itself, which has been officially deprecated since 2023 but still works fine with self-issued integration tokens:

  • POST /v1/users/{authorId}/posts with publishStatus: "draft" and contentFormat: "html"
  • Title max 100 characters
  • Tags: up to 3, each max 25 characters
  • No dedicated header image field; you prepend an <img> tag to the HTML body and Medium picks it up

What I built

Three main pieces:

Medium::Client, a thin Net::HTTP wrapper. Nothing fancy. Takes a token, makes a POST, returns the parsed response or raises a typed error (AuthError, RateLimitError, Error). The interesting bit is a connection: injectable parameter that replaces Net::HTTP.start in tests. No WebMock, no stubbing class methods globally, just pass a lambda that returns a fake response struct.

def initialize(token:, connection: nil)
  raise AuthError, "medium token is blank" if token.blank?
  @token = token
  @connection = connection
end

connect = @connection || Net::HTTP.method(:start)
res = connect.call(uri.host, uri.port, use_ssl: true, ...) { |http| http.request(req) }

Medium::DraftCreator, the orchestrator. It fetches the post’s markdown from GitHub (the source of truth), strips the frontmatter, renders MD to HTML via commonmarker, builds the payload, calls the client, and updates the post record on success.

Tag filtering ended up being two rules in one pass:

def filtered_tags
  Array(@post.tags).select { |t| t.to_s.length <= TAG_MAX_LENGTH }.first(TAG_MAX_COUNT)
end

Drop anything over 25 characters, then take the first 3. The order matters; if you reversed it you’d keep 3 tags and then potentially drop some that were valid.

MediumImportJob, a Solid Queue async job that mirrors the scan job. The key design decision here was ownership: the job owns medium_import_state (idle/running/failed) and the service owns medium_status (not_imported/draft/published). They’re separate enums on the same model. The job sets running before calling the service, then idle after success or failed after an error. The service never touches import state.

def perform(post)
  post.update!(medium_import_state: :running, medium_error: nil)
  Medium::DraftCreator.new(post).call
  post.update!(medium_import_state: :idle)
  broadcast_post(post)
rescue Medium::Client::AuthError
  raise  # discard_on handles this at the class level
rescue StandardError => e
  post.update!(medium_import_state: :failed, medium_error: "#{e.class.name.demodulize}: #{e.message}")
  broadcast_post(post)
  raise
end

Each state change triggers a Turbo Stream broadcast that replaces the post partial in the browser. The show page subscribes with turbo_stream_from @post and the partial handles all four states: idle (import button), running (spinner), draft (view link), failed (error message with retry).

Decisions I made and why

No Pexels gate. The original spec said the import button should only be active once a Pexels header image is selected (that’s the image-selection feature, not yet built). I shipped without that gate; the button is active for any not_imported post with a configured blog token. When the Pexels feature lands, the condition tightens and the payload gets an <img> prepended. Shipping now meant I could test the actual Medium integration without waiting for an unrelated feature.

Fetch HTML on-demand. I could have stored the rendered HTML in the database. Instead, DraftCreator fetches the markdown from GitHub and renders it at import time. No schema change needed, always reflects the current file content, and the GitHub fetch is a one-shot operation per import, not a hot path.

base_url on blogs. The canonical URL needs to be https://vdaluz.com/blog/slug. I added a base_url column to the blogs table and exposed it via the blog edit form. Validation is a regex on URI.regexp(%w[http https]) with allow_blank: true; required for canonical URLs to work, but not blocking for blogs that haven’t set it yet.

Injectable dependencies throughout. Medium::Client takes connection:, Medium::DraftCreator takes client: and github_client:. Tests pass fakes directly without touching global state. This made testing straightforward and fast; the entire test suite runs in under a second for these new files.

What surprised me

The public toggle problem. At one point I had def index defined below private in the controller, with a public re-opener to bring it back out. It worked, Ruby is fine with this, but it was awkward to read. Moved index above private where it belongs.

URI::regexp vs URI.regexp. RuboCop caught a style offense on my initial validation: URI::regexp uses :: for a method call, which should be URI.regexp. Both work in Ruby, but the :: form is technically calling a method, not accessing a constant, so RuboCop flags it under Style/ColonMethodCall. Easy fix, but it blocked the commit.

State machine ownership took a couple of iterations. My first version had DraftCreator setting medium_import_state: :idle after success. That broke the job test; the fake creator didn’t set that state, so the test saw running instead of idle. Moving the idle reset to the job fixed it and was actually the right design: the job started the state transition, so the job should finish it.

What’s next

The Pexels image-selection feature will tighten the import button condition and prepend the header image to the HTML payload. That’s a clean addition; DraftCreator will take an optional image_url: parameter and prepend <img src="..."> to body_html when present.

The Medium API shutdown risk is real. The integration point is narrow enough (Medium::Client is 40 lines) that swapping it for a different implementation, or disabling the feature entirely, touches one file.

Related reading

Development

Building a blog syndication backfill

72 Medium posts my database knew nothing about: the RSS cap, an undocumented GraphQL workaround, Unicode apostrophes, and the kamal stdin trap.

Read