Skip to content

Commit 53b59e4

Browse files
authored
Experiment: Add support for fetch-post to wp_rs_cli (#834)
* Initial implementation of fetch-post CLI subcommand Vibe-coded with cursor-agent * Allow to use --post-url instead of --post-id * Add autodiscovery of api-root for WpOrg sites too * Refactor to extract Auth params To make things more generic for potential future subcommands that might need similar auth instead of making this too specific to FetchPost * Update README * Move methods around for better code org * Fix missed parameter renaming in error messages * Make resolve_post_client agnostic of FetchPostArgs So it could be made more generic and reusable for any future subcommands that might need such conversion too * Fix clippy violations * Move TargetSiteResolver within build_api_client As this is only used within the implementation details of that method so makes more sense rather than polluting the module namespace * Use dedicated `CliAppNotifier` to print auth error * Refactor to extract SiteApiType - Renamed TargetSiteResolver to SiteApiType - Extract the logic to detect the type based on AuthArgs and url into a dedicated `detect_from_args` method - Extract the logic to build the right `ApiUrlResolver` and `WpAuthenticationProvider` for each `SiteApiType` into dedicated methods This helps simplfy a lot the code for `build_api_client` and makes everything more readable * Fix formatting * Move code around for consistency Move the `FetchPostArgs` struct to the top near the other subcommand definitions for consistency with having everything related to subcommand arguments grouped in the same place.
1 parent 986ca5e commit 53b59e4

File tree

4 files changed

+340
-2
lines changed

4 files changed

+340
-2
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

wp_rs_cli/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ serde_json = { workspace = true }
1515
tokio = { workspace = true, features = ["full"] }
1616
tokio-stream = { workspace = true }
1717
wp_api = { path = "../wp_api", features = [ "reqwest-request-executor" ] }
18+
async-trait = { workspace = true }
19+
url = { workspace = true }
1820

1921
[[bin]]
2022
name = "wp_rs_cli"

wp_rs_cli/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,39 @@ wp_rs_cli --help
3232
## Commands
3333

3434
- `discover-login-url`: Tries connecting to the given URL, and prints the library's relevant error message if unable to.
35+
- `fetch-post`: Fetch a post and its comments, supporting WordPress.com (Bearer token) and WordPress.org/Jetpack (Application Password) sites.
36+
37+
### fetch-post examples
38+
39+
```bash
40+
# WordPress.com (Bearer) by post URL (auto derive site)
41+
wp_rs_cli fetch-post \
42+
--url https://example.wordpress.com/2024/07/01/my-post \
43+
--bearer "$WP_BEARER_TOKEN" \
44+
--pretty
45+
46+
# WordPress.com (Bearer) by explicit site and post id
47+
wp_rs_cli fetch-post \
48+
--wpcom-site example.wordpress.com \
49+
--post-id 123 \
50+
--bearer "$WP_BEARER_TOKEN" \
51+
--pretty
52+
53+
# WordPress.org/Jetpack (Application Password) by post URL (auto-discover /wp-json)
54+
wp_rs_cli fetch-post \
55+
--url https://yoursite.com/blog/2024/07/01/my-post \
56+
--username "$WP_USERNAME" \
57+
--password "$WP_APP_PASSWORD" \
58+
--pretty
59+
60+
# WordPress.org/Jetpack (Application Password) by explicit API root and post id
61+
wp_rs_cli fetch-post \
62+
--api-root https://yoursite.com/wp-json \
63+
--post-id 123 \
64+
--username "$WP_USERNAME" \
65+
--password "$WP_APP_PASSWORD" \
66+
--pretty
67+
```
3568

3669
## License
3770

wp_rs_cli/src/bin/wp_rs_cli/main.rs

Lines changed: 303 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,17 @@
1-
use anyhow::Result;
2-
use clap::{Parser, Subcommand};
1+
use anyhow::{Result, anyhow};
2+
use clap::{ArgGroup, Args, Parser, Subcommand};
33
use colored::Colorize;
44
use csv::Writer;
55
use futures::stream::StreamExt;
66
use std::{fmt::Display, fs::File, sync::Arc, time::Duration};
7+
use url::Url;
8+
use wp_api::{
9+
comments::CommentListParams,
10+
parsed_url::ParsedUrl,
11+
posts::{PostId, PostRetrieveParams},
12+
request::endpoint::WpOrgSiteApiUrlResolver,
13+
wp_com::{WpComBaseUrl, endpoint::WpComDotOrgApiUrlResolver},
14+
};
715
use wp_api::{
816
login::url_discovery::{
917
AutoDiscoveryAttemptFailure, FetchAndParseApiRootFailure, FindApiRootFailure,
@@ -29,6 +37,67 @@ enum Commands {
2937
input_file: String,
3038
output_file: String,
3139
},
40+
/// Fetch a single post and its comments
41+
FetchPost(FetchPostArgs),
42+
}
43+
44+
#[derive(Debug, Args, Clone)]
45+
struct AuthArgs {
46+
/// WordPress.com site (e.g. example.wordpress.com or numeric ID)
47+
#[arg(long)]
48+
wpcom_site: Option<String>,
49+
50+
/// WordPress.org/Jetpack API root (must end with /wp-json)
51+
#[arg(long)]
52+
api_root: Option<String>,
53+
54+
/// Bearer token for WordPress.com (fallback env: WP_BEARER_TOKEN)
55+
#[arg(long)]
56+
bearer: Option<String>,
57+
58+
/// Application Password username for wp.org/Jetpack (fallback env: WP_USERNAME)
59+
#[arg(long)]
60+
username: Option<String>,
61+
62+
/// Application Password for wp.org/Jetpack (fallback env: WP_APP_PASSWORD)
63+
#[arg(long)]
64+
password: Option<String>,
65+
}
66+
67+
#[derive(Debug, Parser)]
68+
#[command(group(
69+
ArgGroup::new("site_type")
70+
.args(["wpcom_site", "api_root", "url"]),
71+
), group(
72+
ArgGroup::new("post_ref")
73+
.required(true)
74+
.args(["post_id", "url"]),
75+
))]
76+
struct FetchPostArgs {
77+
/// Common authentication parameters
78+
#[command(flatten)]
79+
auth: AuthArgs,
80+
81+
/// Full post URL (alternative to --post-id)
82+
/// When provided, this URL is used to infer the site (wp.com) or autodiscover the API root (wp.org/Jetpack).
83+
#[arg(long)]
84+
url: Option<String>,
85+
86+
/// The post ID to fetch
87+
#[arg(long, value_parser = parse_post_id)]
88+
post_id: Option<PostId>,
89+
90+
/// Password for the post if it is password-protected
91+
#[arg(long)]
92+
post_password: Option<String>,
93+
94+
/// Max items per page when fetching comments
95+
#[arg(long, default_value_t = 100)]
96+
per_page: u32,
97+
98+
/// Output pretty-printed JSON
99+
#[arg(long, default_value_t = false)]
100+
pretty: bool,
32101
}
33102

34103
#[tokio::main]
@@ -46,6 +115,9 @@ async fn main() -> Result<()> {
46115
} => {
47116
batch_test_autodiscovery(&login_client, input_file.as_str(), output_file).await?;
48117
}
118+
Commands::FetchPost(args) => {
119+
fetch_post_and_comments(args).await?;
120+
}
49121
}
50122

51123
Ok(())
@@ -221,3 +293,232 @@ fn csv_error_type(failure: &AutoDiscoveryAttemptFailure) -> String {
221293
},
222294
}
223295
}
296+
297+
#[derive(Debug)]
298+
enum SiteApiType {
299+
WpCom { site: String },
300+
WpOrg { api_root: Arc<ParsedUrl> },
301+
}
302+
303+
impl SiteApiType {
304+
async fn detect_from_args(
305+
args: &AuthArgs,
306+
url: &Option<String>,
307+
request_executor: &Arc<ReqwestRequestExecutor>,
308+
) -> Result<Self> {
309+
if let Some(site) = &args.wpcom_site {
310+
// Explicit WordPress.com site takes priority
311+
return Ok(SiteApiType::WpCom { site: site.clone() });
312+
}
313+
if let Some(api_root) = &args.api_root {
314+
// Explicit api_root takes priority for wp.org/Jetpack
315+
let parsed = ParsedUrl::try_from(api_root.as_str()).map_err(|_| {
316+
anyhow!("Invalid api_root URL: must be a valid URL ending with /wp-json")
317+
})?;
318+
return Ok(SiteApiType::WpOrg {
319+
api_root: Arc::new(parsed),
320+
});
321+
}
322+
if let Some(url) = url {
323+
// Derive from URL if possible
324+
if let Ok(u) = Url::parse(url.as_str()) {
325+
let host = u.host_str().unwrap_or("");
326+
if host.ends_with(".wordpress.com") {
327+
return Ok(SiteApiType::WpCom {
328+
site: host.to_string(),
329+
});
330+
}
331+
332+
// Attempt autodiscovery of API root from URL
333+
let login_client =
334+
WpLoginClient::new_with_default_middleware_pipeline(request_executor.clone());
335+
match login_client
336+
.api_discovery(url.clone())
337+
.await
338+
.combined_result()
339+
.cloned()
340+
{
341+
Ok(success) => Ok(SiteApiType::WpOrg {
342+
api_root: success.api_root_url,
343+
}),
344+
Err(_) => Err(anyhow!(
345+
"Could not autodiscover API root from URL. Please provide --api-root explicitly."
346+
)),
347+
}
348+
} else {
349+
Err(anyhow!("Invalid URL; could not parse"))
350+
}
351+
} else {
352+
Err(anyhow!(
353+
"Provide either --wpcom-site, or --api-root, or a wordpress.com URL"
354+
))
355+
}
356+
}
357+
358+
fn api_url_resolver(&self) -> Arc<dyn ApiUrlResolver> {
359+
match self {
360+
SiteApiType::WpCom { site } => Arc::new(WpComDotOrgApiUrlResolver::new(
361+
site.clone(),
362+
WpComBaseUrl::Production,
363+
)),
364+
SiteApiType::WpOrg { api_root } => {
365+
Arc::new(WpOrgSiteApiUrlResolver::new(api_root.clone()))
366+
}
367+
}
368+
}
369+
370+
fn auth_provider(&self, args: &AuthArgs) -> Result<Arc<WpAuthenticationProvider>> {
371+
match self {
372+
SiteApiType::WpCom { .. } => {
373+
let token = env_or_arg(&args.bearer, "WP_BEARER_TOKEN").ok_or_else(|| {
374+
anyhow!("Missing bearer token. Provide --bearer or set WP_BEARER_TOKEN")
375+
})?;
376+
Ok(Arc::new(WpAuthenticationProvider::static_with_auth(
377+
WpAuthentication::Bearer { token },
378+
)))
379+
}
380+
SiteApiType::WpOrg { .. } => {
381+
let username = env_or_arg(&args.username, "WP_USERNAME").ok_or_else(|| {
382+
anyhow!("Missing username. Provide --username or set WP_USERNAME")
383+
})?;
384+
let password = env_or_arg(&args.password, "WP_APP_PASSWORD").ok_or_else(|| {
385+
anyhow!(
386+
"Missing application password. Provide --password or set WP_APP_PASSWORD"
387+
)
388+
})?;
389+
Ok(Arc::new(
390+
WpAuthenticationProvider::static_with_username_and_password(username, password),
391+
))
392+
}
393+
}
394+
}
395+
}
396+
397+
fn env_or_arg(value: &Option<String>, var: &str) -> Option<String> {
398+
value.clone().or_else(|| std::env::var(var).ok())
399+
}
400+
401+
async fn build_api_client(args: &AuthArgs, url: &Option<String>) -> Result<WpApiClient> {
402+
let request_executor = Arc::new(ReqwestRequestExecutor::new(false, Duration::from_secs(60)));
403+
let middleware_pipeline = Arc::new(WpApiMiddlewarePipeline::default());
404+
// Determine resolver and auth provider
405+
let api_type = SiteApiType::detect_from_args(args, url, &request_executor).await?;
406+
let resolver: Arc<dyn ApiUrlResolver> = api_type.api_url_resolver();
407+
let auth_provider: Arc<WpAuthenticationProvider> = api_type.auth_provider(args)?;
408+
409+
#[derive(Debug)]
410+
struct CliAppNotifier;
411+
#[async_trait::async_trait]
412+
impl WpAppNotifier for CliAppNotifier {
413+
async fn requested_with_invalid_authentication(&self) {
414+
eprintln!(
415+
"Authentication failed. Please verify your credentials or token and try again."
416+
);
417+
std::process::exit(1);
418+
}
419+
}
420+
421+
Ok(WpApiClient::new(
422+
resolver,
423+
WpApiClientDelegate {
424+
auth_provider,
425+
request_executor,
426+
middleware_pipeline,
427+
app_notifier: Arc::new(CliAppNotifier),
428+
},
429+
))
430+
}
431+
432+
fn parse_post_id(s: &str) -> Result<PostId, String> {
433+
s.parse::<i64>()
434+
.map(PostId)
435+
.map_err(|e| format!("Invalid post id '{s}': {e}"))
436+
}
437+
438+
async fn resolve_post_id(client: &WpApiClient, post_url: &str) -> Result<PostId> {
439+
// Strategy: retrieve by slug via posts list API when possible.
440+
// For wp.com, the resolver requires site context; for wp.org, api_root is given.
441+
// We'll try to parse the URL and extract a last path segment as potential slug.
442+
let url = Url::parse(post_url).map_err(|e| anyhow!("Invalid url: {e}"))?;
443+
let slug_candidate = url
444+
.path_segments()
445+
.and_then(|segs| segs.rev().find(|s| !s.is_empty()))
446+
.map(|s| s.trim_end_matches('/'))
447+
.unwrap_or("")
448+
.to_string();
449+
450+
if slug_candidate.is_empty() {
451+
return Err(anyhow!("Could not parse a slug from url"));
452+
}
453+
454+
// Query posts by slug; returns an array, take first match.
455+
// Using view context to ensure public content shape.
456+
let params = wp_api::posts::PostListParams {
457+
slug: vec![slug_candidate.clone()],
458+
per_page: Some(1),
459+
..Default::default()
460+
};
461+
let resp = client.posts().list_with_view_context(&params).await?;
462+
if let Some(p) = resp.data.into_iter().map(|sp| sp.id).next() {
463+
return Ok(p);
464+
}
465+
466+
Err(anyhow!(
467+
"No post found for slug '{slug}' parsed from URL '{url}'",
468+
slug = slug_candidate,
469+
url = post_url
470+
))
471+
}
472+
473+
async fn fetch_post_and_comments(args: FetchPostArgs) -> Result<()> {
474+
let client = build_api_client(&args.auth, &args.url).await?;
475+
476+
let post_id = if let Some(id) = args.post_id {
477+
id
478+
} else {
479+
let post_url = args
480+
.url
481+
.as_ref()
482+
.ok_or_else(|| anyhow!("Either --post-id or --url must be provided"))?;
483+
resolve_post_id(&client, post_url.as_str()).await?
484+
};
485+
486+
let post = client
487+
.posts()
488+
.retrieve_with_view_context(
489+
&post_id,
490+
&PostRetrieveParams {
491+
password: args.post_password.clone(),
492+
},
493+
)
494+
.await?;
495+
496+
let mut all_comments = Vec::new();
497+
let mut page = client
498+
.comments()
499+
.list_with_view_context(&CommentListParams {
500+
post: vec![post_id],
501+
per_page: Some(args.per_page),
502+
..Default::default()
503+
})
504+
.await?;
505+
all_comments.extend(page.data);
506+
while let Some(next_params) = page.next_page_params.take() {
507+
page = client
508+
.comments()
509+
.list_with_view_context(&next_params)
510+
.await?;
511+
all_comments.extend(page.data);
512+
}
513+
514+
let out = serde_json::json!({
515+
"post": post,
516+
"comments": all_comments,
517+
});
518+
if args.pretty {
519+
println!("{}", serde_json::to_string_pretty(&out)?);
520+
} else {
521+
println!("{}", serde_json::to_string(&out)?);
522+
}
523+
Ok(())
524+
}

0 commit comments

Comments
 (0)