Skip to content

Commit 3c4651a

Browse files
committed
Initial implementation of fetch-post CLI subcommand
Vibe-coded with cursor-agent
1 parent f6a010e commit 3c4651a

File tree

4 files changed

+210
-2
lines changed

4 files changed

+210
-2
lines changed

Cargo.lock

Lines changed: 1 addition & 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ 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 }
1819

1920
[[bin]]
2021
name = "wp_rs_cli"

wp_rs_cli/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,26 @@ 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)
41+
wp_rs_cli fetch-post \
42+
--wpcom-site example.wordpress.com \
43+
--post-id 123 \
44+
--bearer "$WP_BEARER_TOKEN" \
45+
--pretty
46+
47+
# WordPress.org/Jetpack (Application Password)
48+
wp_rs_cli fetch-post \
49+
--api-root https://yoursite.com/wp-json \
50+
--post-id 123 \
51+
--username "$WP_USERNAME" \
52+
--password "$WP_APP_PASSWORD" \
53+
--pretty
54+
```
3555

3656
## License
3757

wp_rs_cli/src/bin/wp_rs_cli/main.rs

Lines changed: 188 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1-
use anyhow::Result;
2-
use clap::{Parser, Subcommand};
1+
use anyhow::{Result, anyhow};
2+
use clap::{ArgGroup, 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 wp_api::{
8+
comments::CommentListParams,
9+
parsed_url::ParsedUrl,
10+
posts::{PostId, PostRetrieveParams},
11+
request::endpoint::WpOrgSiteApiUrlResolver,
12+
wp_com::{WpComBaseUrl, endpoint::WpComDotOrgApiUrlResolver},
13+
};
714
use wp_api::{
815
login::url_discovery::{
916
AutoDiscoveryAttemptFailure, FetchAndParseApiRootFailure, FindApiRootFailure,
@@ -29,6 +36,8 @@ enum Commands {
2936
input_file: String,
3037
output_file: String,
3138
},
39+
/// Fetch a single post and its comments
40+
FetchPost(FetchPostArgs),
3241
}
3342

3443
#[tokio::main]
@@ -46,6 +55,9 @@ async fn main() -> Result<()> {
4655
} => {
4756
batch_test_autodiscovery(&login_client, input_file.as_str(), output_file).await?;
4857
}
58+
Commands::FetchPost(args) => {
59+
fetch_post_and_comments(args).await?;
60+
}
4961
}
5062

5163
Ok(())
@@ -123,6 +135,180 @@ fn build_login_client() -> WpLoginClient {
123135
WpLoginClient::new_with_default_middleware_pipeline(request_executor)
124136
}
125137

138+
#[derive(Debug, Parser)]
139+
#[command(group(
140+
ArgGroup::new("target")
141+
.required(true)
142+
.args(["wpcom_site", "api_root"]),
143+
))]
144+
struct FetchPostArgs {
145+
/// The post ID to fetch
146+
#[arg(long, value_parser = parse_post_id)]
147+
post_id: PostId,
148+
149+
/// For WordPress.com: site identifier (e.g. example.wordpress.com or numeric site id)
150+
#[arg(long)]
151+
wpcom_site: Option<String>,
152+
153+
/// For WordPress.org/Jetpack: full API root URL (must end with /wp-json)
154+
#[arg(long)]
155+
api_root: Option<String>,
156+
157+
/// Bearer token for WordPress.com (fallback env: WP_BEARER_TOKEN)
158+
#[arg(long)]
159+
bearer: Option<String>,
160+
161+
/// Application Password username for wp.org/Jetpack (fallback env: WP_USERNAME)
162+
#[arg(long)]
163+
username: Option<String>,
164+
165+
/// Application Password for wp.org/Jetpack (fallback env: WP_APP_PASSWORD)
166+
#[arg(long)]
167+
password: Option<String>,
168+
169+
/// Password for the post if it is password-protected
170+
#[arg(long)]
171+
post_password: Option<String>,
172+
173+
/// Max items per page when fetching comments
174+
#[arg(long, default_value_t = 100)]
175+
per_page: u32,
176+
177+
/// Output pretty-printed JSON
178+
#[arg(long, default_value_t = false)]
179+
pretty: bool,
180+
}
181+
182+
fn parse_post_id(s: &str) -> Result<PostId, String> {
183+
s.parse::<i64>()
184+
.map(PostId)
185+
.map_err(|e| format!("Invalid post id '{s}': {e}"))
186+
}
187+
188+
#[derive(Debug)]
189+
enum TargetSiteResolver {
190+
WpCom { site: String },
191+
WpOrg { api_root: Arc<ParsedUrl> },
192+
}
193+
194+
fn build_api_client(args: &FetchPostArgs) -> Result<WpApiClient> {
195+
// Determine target and auth
196+
let target = if let Some(site) = &args.wpcom_site {
197+
TargetSiteResolver::WpCom { site: site.clone() }
198+
} else if let Some(api_root) = &args.api_root {
199+
let parsed = ParsedUrl::try_from(api_root.as_str()).map_err(|_| {
200+
anyhow!("Invalid api_root URL: must be a valid URL ending with /wp-json")
201+
})?;
202+
TargetSiteResolver::WpOrg {
203+
api_root: Arc::new(parsed),
204+
}
205+
} else {
206+
return Err(anyhow!(
207+
"Either --wpcom-site or --api-root must be provided"
208+
));
209+
};
210+
211+
fn env_or_arg(value: &Option<String>, var: &str) -> Option<String> {
212+
value.clone().or_else(|| std::env::var(var).ok())
213+
}
214+
215+
let (resolver, auth_provider): (Arc<dyn ApiUrlResolver>, Arc<WpAuthenticationProvider>) =
216+
match target {
217+
TargetSiteResolver::WpCom { site } => {
218+
let token = env_or_arg(&args.bearer, "WP_BEARER_TOKEN").ok_or_else(|| {
219+
anyhow!("Missing bearer token. Provide --bearer or set WP_BEARER_TOKEN")
220+
})?;
221+
let resolver: Arc<dyn ApiUrlResolver> = Arc::new(WpComDotOrgApiUrlResolver::new(
222+
site,
223+
WpComBaseUrl::Production,
224+
));
225+
let auth_provider = Arc::new(WpAuthenticationProvider::static_with_auth(
226+
WpAuthentication::Bearer { token },
227+
));
228+
(resolver, auth_provider)
229+
}
230+
TargetSiteResolver::WpOrg { api_root } => {
231+
let username = env_or_arg(&args.username, "WP_USERNAME").ok_or_else(|| {
232+
anyhow!("Missing username. Provide --username or set WP_USERNAME")
233+
})?;
234+
let password = env_or_arg(&args.password, "WP_APP_PASSWORD").ok_or_else(|| {
235+
anyhow!(
236+
"Missing application password. Provide --password or set WP_APP_PASSWORD"
237+
)
238+
})?;
239+
let resolver: Arc<dyn ApiUrlResolver> =
240+
Arc::new(WpOrgSiteApiUrlResolver::new(api_root));
241+
let auth_provider = Arc::new(
242+
WpAuthenticationProvider::static_with_username_and_password(username, password),
243+
);
244+
(resolver, auth_provider)
245+
}
246+
};
247+
248+
let request_executor = Arc::new(ReqwestRequestExecutor::new(false, Duration::from_secs(60)));
249+
let middleware_pipeline = Arc::new(WpApiMiddlewarePipeline::default());
250+
251+
#[derive(Debug)]
252+
struct NoopNotifier;
253+
#[async_trait::async_trait]
254+
impl WpAppNotifier for NoopNotifier {
255+
async fn requested_with_invalid_authentication(&self) {}
256+
}
257+
258+
Ok(WpApiClient::new(
259+
resolver,
260+
WpApiClientDelegate {
261+
auth_provider,
262+
request_executor,
263+
middleware_pipeline,
264+
app_notifier: Arc::new(NoopNotifier),
265+
},
266+
))
267+
}
268+
269+
async fn fetch_post_and_comments(args: FetchPostArgs) -> Result<()> {
270+
let client = build_api_client(&args)?;
271+
272+
let post = client
273+
.posts()
274+
.retrieve_with_view_context(
275+
&args.post_id,
276+
&PostRetrieveParams {
277+
password: args.post_password.clone(),
278+
},
279+
)
280+
.await?;
281+
282+
let mut all_comments = Vec::new();
283+
let mut page = client
284+
.comments()
285+
.list_with_view_context(&CommentListParams {
286+
post: vec![args.post_id],
287+
per_page: Some(args.per_page),
288+
..Default::default()
289+
})
290+
.await?;
291+
all_comments.extend(page.data);
292+
while let Some(next_params) = page.next_page_params.take() {
293+
page = client
294+
.comments()
295+
.list_with_view_context(&next_params)
296+
.await?;
297+
all_comments.extend(page.data);
298+
}
299+
300+
let out = serde_json::json!({
301+
"post": post,
302+
"comments": all_comments,
303+
});
304+
if args.pretty {
305+
println!("{}", serde_json::to_string_pretty(&out)?);
306+
} else {
307+
println!("{}", serde_json::to_string(&out)?);
308+
}
309+
Ok(())
310+
}
311+
126312
fn parse_input_file(input_file: &str) -> Result<Vec<BatchTestRow>> {
127313
Ok(csv::Reader::from_path(input_file)?
128314
.deserialize::<BatchTestRow>()

0 commit comments

Comments
 (0)