use crate::publish::article::{Article, slugify};
use crate::publish::config::HashnodeConfig;
use crate::publish::cover_image::{self, CoverImage};
use crate::publish::target::{HttpTarget, PublishOutcome};
use anyhow::{Result, bail};
use serde::Serialize;
const PUBLISH_POST_MUTATION: &str = r#"
mutation PublishPost($input: PublishPostInput!) {
publishPost(input: $input) {
post { id url }
}
}
"#;
pub struct Hashnode {
token: String,
publication_id: String,
http: HttpTarget,
}
impl Hashnode {
pub fn new(client: reqwest::Client, cfg: &HashnodeConfig) -> Self {
Self::with_endpoint(client, cfg, "https://gql.hashnode.com")
}
fn with_endpoint(
client: reqwest::Client,
cfg: &HashnodeConfig,
endpoint: impl Into<String>,
) -> Self {
Self {
token: cfg.token.clone(),
publication_id: cfg.publication_id.clone(),
http: HttpTarget::new(client, endpoint),
}
}
}
#[derive(Serialize)]
struct Tag {
name: String,
slug: String,
}
#[derive(Serialize)]
struct CoverImageOptions<'a> {
#[serde(rename = "coverImageURL")]
cover_image_url: &'a str,
}
#[derive(Serialize)]
struct PublishPostInput<'a> {
title: &'a str,
#[serde(rename = "contentMarkdown")]
content_markdown: &'a str,
#[serde(rename = "publicationId")]
publication_id: &'a str,
tags: Vec<Tag>,
slug: &'a str,
#[serde(rename = "coverImageOptions", skip_serializing_if = "Option::is_none")]
cover_image_options: Option<CoverImageOptions<'a>>,
}
impl Hashnode {
pub async fn publish(&self, article: &Article, update: bool) -> Result<PublishOutcome> {
if update {
bail!("the hashnode target doesn't support --update yet");
}
let cover_url = match cover_image::resolve(article)? {
Some(CoverImage::Url(url)) => Some(url),
Some(CoverImage::Local(_)) => {
return Err(cover_image::local_path_not_supported_error("hashnode"));
}
None => None,
};
let input = PublishPostInput {
title: &article.title,
content_markdown: &article.content,
publication_id: &self.publication_id,
tags: article
.tags
.iter()
.map(|t| Tag {
name: t.clone(),
slug: slugify(t),
})
.collect(),
slug: &article.slug,
cover_image_options: cover_url.as_deref().map(|url| CoverImageOptions {
cover_image_url: url,
}),
};
let resp = self
.http
.client
.post(&self.http.base_url)
.header("Authorization", format!("Bearer {}", self.token))
.json(&serde_json::json!({
"query": PUBLISH_POST_MUTATION,
"variables": { "input": input },
}))
.send()
.await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
bail!("hashnode returned {status}: {text}");
}
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
if let Some(errors) = parsed.get("errors").filter(|e| !e.is_null()) {
bail!("hashnode returned GraphQL errors: {errors}");
}
let url = parsed
.pointer("/data/publishPost/post/url")
.and_then(|v| v.as_str())
.unwrap_or("(published, no URL in response)")
.to_string();
Ok(PublishOutcome { message: url })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::publish::article::parse_article;
use assert_fs::{TempDir, prelude::*};
use httpmock::prelude::*;
fn sample_article(dir: &TempDir) -> Article {
let file = dir.child("post.md");
file.write_str(
"---\n\
title: \"Hello\"\n\
date: 2026-07-30\n\
summary: \"A test\"\n\
tags: [rust, cli]\n\
publish: [hashnode]\n\
---\n\
Body.\n",
)
.unwrap();
parse_article(file.path(), None).unwrap()
}
#[tokio::test]
async fn publish_sends_bearer_token_and_parses_url() {
let server = MockServer::start();
let dir = TempDir::new().unwrap();
let article = sample_article(&dir);
let mock = server.mock(|when, then| {
when.method(POST)
.path("/")
.header("Authorization", "Bearer secret-pat");
then.status(200).json_body(serde_json::json!({
"data": { "publishPost": { "post": { "id": "1", "url": "https://blog.example.com/hello" } } }
}));
});
let target = Hashnode::with_endpoint(
reqwest::Client::new(),
&HashnodeConfig {
token: "secret-pat".into(),
publication_id: "pub1".into(),
},
server.base_url(),
);
let outcome = target.publish(&article, false).await.unwrap();
mock.assert();
assert_eq!(outcome.message, "https://blog.example.com/hello");
}
#[tokio::test]
async fn graphql_errors_field_is_surfaced_even_on_http_200() {
let server = MockServer::start();
let dir = TempDir::new().unwrap();
let article = sample_article(&dir);
server.mock(|when, then| {
when.method(POST).path("/");
then.status(200).json_body(serde_json::json!({
"errors": [{"message": "publication requires Hashnode Pro"}]
}));
});
let target = Hashnode::with_endpoint(
reqwest::Client::new(),
&HashnodeConfig {
token: "t".into(),
publication_id: "p".into(),
},
server.base_url(),
);
let err = target.publish(&article, false).await.unwrap_err();
assert!(err.to_string().contains("Hashnode Pro"));
}
#[tokio::test]
async fn cover_image_url_is_sent_via_cover_image_options() {
let server = MockServer::start();
let dir = TempDir::new().unwrap();
let file = dir.child("post.md");
file.write_str(
"---\ntitle: \"Hello\"\ndate: 2026-07-30\nsummary: \"s\"\n\
cover_image: https://example.com/hero.jpg\npublish: [hashnode]\n---\nBody.\n",
)
.unwrap();
let article = parse_article(file.path(), None).unwrap();
let mock = server.mock(|when, then| {
when.method(POST).path("/").json_body_partial(
r#"{"variables":{"input":{"coverImageOptions":{"coverImageURL":"https://example.com/hero.jpg"}}}}"#,
);
then.status(200).json_body(serde_json::json!({
"data": { "publishPost": { "post": { "id": "1", "url": "https://blog.example.com/hello" } } }
}));
});
let target = Hashnode::with_endpoint(
reqwest::Client::new(),
&HashnodeConfig {
token: "t".into(),
publication_id: "p".into(),
},
server.base_url(),
);
target.publish(&article, false).await.unwrap();
mock.assert();
}
#[tokio::test]
async fn cover_image_local_path_is_a_clear_error() {
let dir = TempDir::new().unwrap();
dir.child("hero.jpg").write_binary(&[0xff, 0xd8]).unwrap();
let file = dir.child("post.md");
file.write_str(
"---\ntitle: \"Hello\"\ndate: 2026-07-30\nsummary: \"s\"\n\
cover_image: hero.jpg\npublish: [hashnode]\n---\nBody.\n",
)
.unwrap();
let article = parse_article(file.path(), None).unwrap();
let target = Hashnode::with_endpoint(
reqwest::Client::new(),
&HashnodeConfig {
token: "t".into(),
publication_id: "p".into(),
},
"http://unused",
);
let err = target.publish(&article, false).await.unwrap_err();
assert!(
err.to_string()
.contains("only accepts an already-hosted URL")
);
}
}