diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7edb293..9942a78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,8 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable + with: + toolchain: stable - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - run: cargo check --workspace @@ -28,6 +30,8 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable + with: + toolchain: stable - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - run: cargo test --workspace @@ -38,6 +42,7 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable with: + toolchain: stable components: clippy - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - run: cargo clippy --workspace -- -D warnings @@ -49,5 +54,6 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable with: + toolchain: stable components: rustfmt - run: cargo fmt --all -- --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ece9d8..b643605 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,7 @@ jobs: - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable with: + toolchain: stable targets: ${{ matrix.target }} - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 diff --git a/CLAUDE.md b/CLAUDE.md index 1f15dd3..4422f96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,15 +111,20 @@ wordpress-cli/ │ │ # AuthCommands │ ├── context.rs # build_client(): resolves site profile + credentials -> WpClient │ ├── crud.rs # Generic CRUD helpers: list, get, create, update, delete, -│ │ # list_all_pages (streaming NDJSON), list_object_keyed, -│ │ # get_by_slug, to_query_params, object_values_to_array +│ │ # *_at variants taking an explicit api_path (custom post types), +│ │ # list_all_pages_at (streaming NDJSON), list_object_keyed, +│ │ # get_by_slug, resolve_post_type_path, to_query_params +│ ├── input.rs # json_from_stdin, resolve_content (--content/--content-file/-), +│ │ # parse_key_values (--query k=v) │ ├── dispatch.rs # Unified dispatcher: dispatch(command_path, args, client, dry_run) │ │ # used by CLI and fleet exec │ └── commands/ │ ├── mod.rs # Module declarations +│ ├── api.rs # `wpx api `: raw REST escape hatch (execute() shared with dispatch) │ ├── post.rs # PostCommands: list, get, create, update, delete, search +│ │ # PostTypeArgs (--type/--rest-base) routes to any post type's rest_base │ ├── page.rs # PageCommands: list, get, create, update, delete -│ ├── media.rs # MediaCommands: list, get, upload, delete +│ ├── media.rs # MediaCommands: list, get, upload, update, delete │ ├── user.rs # UserCommands: list, get, me │ ├── comment.rs # CommentCommands: list, get, create, update, delete │ ├── category.rs # CategoryCommands: list, get, create, update, delete @@ -211,15 +216,35 @@ Located in `crates/wpx-cli/src/crud.rs`. All are generic over `R: Resource`: | Helper | Signature | Notes | |--------|-----------|-------| | `list` | `(client, params) -> RenderPayload` | Converts params to query string via `to_query_params()` | -| `list_all_pages` | `(client, params) -> RenderPayload` | Streams all pages as NDJSON to stdout (100/page) | +| `list_at` | `(client, api_path, params) -> RenderPayload` | Same, against an explicit collection path | +| `list_all_pages_at` | `(client, api_path, params) -> RenderPayload` | Streams all pages as NDJSON to stdout (100/page) | | `list_object_keyed` | `(client, api_path) -> RenderPayload` | For endpoints returning `{slug: {...}}` instead of arrays | | `get` | `(client, id) -> RenderPayload` | GET `{API_PATH}/{id}` | +| `get_at` | `(client, api_path, id, params) -> RenderPayload` | GET `{api_path}/{id}?{params}` (e.g. `context=edit` for `content.raw`) | | `get_by_slug` | `(client, api_path, slug) -> RenderPayload` | GET `{api_path}/{slug}` | | `create` | `(client, body, dry_run) -> RenderPayload` | POST to `API_PATH`; dry_run returns what would be created | +| `create_at` | `(client, api_path, body, dry_run) -> RenderPayload` | POST to `api_path` | | `update` | `(client, id, body, dry_run) -> RenderPayload` | POST to `{API_PATH}/{id}` | +| `update_at` | `(client, api_path, id, body, dry_run) -> RenderPayload` | POST to `{api_path}/{id}` | | `delete` | `(client, id, force, dry_run) -> RenderPayload` | DELETE; force=true permanently deletes, false trashes | +| `delete_at` | `(client, api_path, id, force, dry_run) -> RenderPayload` | DELETE `{api_path}/{id}` | +| `resolve_post_type_path` | `(client, post_type, rest_base) -> String` | `None`/`post` → `wp/v2/posts`, `page` → `wp/v2/pages`, else `GET wp/v2/types/{slug}` → `{rest_namespace}/{rest_base}`; `rest_base` skips the lookup | -The `to_query_params()` helper serializes any `Serialize` struct to `Vec<(String, String)>`, skipping `None` values. This is why list args structs derive both `Args` (for clap) and `Serialize` (for query params). +The non-`_at` helpers are one-line wrappers that pass `R::API_PATH`. The `_at` variants exist so one `Resource` struct (e.g. `Post`) can be used against any post type's collection (`wp/v2/blog`, `wp/v2/case-studies`), which is how `--type` / `--rest-base` work on `post` commands. + +The `to_query_params()` helper serializes any `Serialize` struct to `Vec<(String, String)>`, skipping `None` values. This is why list args structs derive both `Args` (for clap) and `Serialize` (for query params). Routing-only args (`PostTypeArgs`) are `#[serde(skip)]` so they never leak into the query string. + +### Passthrough Params + +`PostCreateParams` / `PageCreateParams` carry `template`, `featured_media`, `meta`, `acf` and a `#[serde(flatten)] extra: Map`. A `--json` stdin payload therefore reaches WordPress verbatim (custom taxonomies like `blog_category`, plugin fields, ...) instead of being trimmed to known keys. The `Post` / `Page` response structs flatten unknown keys the same way, so `--fields` masks can select any key WordPress returns. + +### Raw Routes (`wpx api`) + +`commands/api.rs` wraps `WpClient::request_raw(method, path, params, body)`: any verb, any route under `/wp-json/`, JSON body from `--data` or stdin (`--json`), query params via repeatable `--query k=v`. Non-GET requests honour `--dry-run`. The same `execute()` backs the `["api"]` dispatch route (`{"method","path","query","body"}`) for fleet use. + +### Media Upload + +`WpClient::upload_file(path, file_name, bytes, mime, fields)` builds the multipart form (file part + text fields) on top of `post_multipart`; `mime_from_extension()` guesses the MIME type without a dependency. `wpx media upload [--title --alt-text --caption --description --post --mime-type]`. ### API Response Format @@ -242,7 +267,9 @@ pub struct ApiResponse { Signature: `dispatch(command_path: &[&str], args: &Value, client: &WpClient, dry_run: bool) -> Result` -Command paths are string slices like `["post", "list"]`, `["plugin", "activate"]`, `["search"]`. +Command paths are string slices like `["post", "list"]`, `["plugin", "activate"]`, `["search"]`, `["api"]`, `["media", "upload"]`. + +`["post", *]` routes read `type` / `rest_base` from args (resolved via `resolve_post_type_path`) and strip `type`, `rest_base` and `id` from the body before sending (`type` is read-only in the REST schema; `id` on create triggers `rest_post_exists`). ## Configuration @@ -296,6 +323,8 @@ token_url = "https://staging.example.com/oauth/token" |----------|-------------|---------| | `WPX_SITE` | Target site profile name | `default` | | `WPX_URL` | Direct URL override (skips profile lookup) | — | +| `WPX_USERNAME` | Username for application-password auth; overrides `credentials.toml` for every site | — | +| `WPX_PASSWORD` | Application password (alias `WPX_APP_PASSWORD`); both must be set to take effect. With `WPX_URL`, no profile or credentials file is needed (CI) | — | | `WPX_OUTPUT` | Output format: json, table, csv, yaml, ndjson, auto | `auto` | | `WPX_TIMEOUT` | Request timeout in seconds | `30` | | `WPX_RETRIES` | Retry count for failed requests | `3` | @@ -306,7 +335,7 @@ token_url = "https://staging.example.com/oauth/token" All flags are available on every command via `--flag`: -`--site`, `--url`, `--output`, `--fields` (comma-separated field mask), `--no-color`, `--no-prompt`, `--quiet`, `--verbose`, `--timeout`, `--retries`, `--dry-run`, `--confirm`, `--all-pages` +`--site`, `--url`, `--output`, `--fields` (comma-separated field mask; dotted paths such as `content.raw` select nested keys), `--no-color`, `--no-prompt`, `--quiet`, `--verbose`, `--timeout`, `--retries`, `--dry-run`, `--confirm`, `--all-pages` ## Error Handling diff --git a/Cargo.lock b/Cargo.lock index 070fbe3..ded60de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2264,6 +2264,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "wiremock", "wpx-api", "wpx-auth", "wpx-config", diff --git a/README.md b/README.md index 5f3073c..56fae0a 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,40 @@ wpx post create --site production --title "Hello from wpx" --status draft wpx search "migration guide" --site production ``` +### Custom Post Types, Content Files, Media and Raw Routes + +```bash +# Any post type exposed in REST: --type resolves wp/v2/types/ to its rest_base +wpx post list --type blog --per-page 5 +wpx post get 6166 --type blog --context edit --fields id,slug,content.raw,acf,meta + +# Push block markup from a file (or "-" for stdin); unknown JSON keys pass through verbatim +wpx post create --type blog --title "Hello" --status draft --content-file ./post.html +echo '{"title":"Hi","status":"draft","blog_category":[9],"acf":{"hero":{"title":"x"}}}' \ + | wpx post create --type blog --json + +# Pages: template + featured image + ACF/meta via --json +wpx page update 4338 --template template-kafka-services.php --featured-media 6168 --content-file page.html + +# Upload media +wpx media upload ./hero.png --title "Hero" --alt-text "Kafka cluster diagram" + +# Anything else: raw REST route (relative to /wp-json/) +wpx api GET wp/v2/types/blog --fields rest_base +wpx api POST rankmath/v1/updateMeta --data '{"objectType":"post","objectID":42,"meta":{"rank_math_title":"x"}}' +``` + +Every write honours `--dry-run`. + ## Command Reference ### Content | Command | Description | Subcommands | |---------|-------------|-------------| -| `post` | Manage posts | `list`, `get`, `create`, `update`, `delete`, `search` | +| `post` | Manage posts of any type (`--type blog`, `--type page`, ...) | `list`, `get`, `create`, `update`, `delete`, `search` | | `page` | Manage pages | `list`, `get`, `create`, `update`, `delete` | -| `media` | Manage media attachments | `list`, `get`, `update`, `delete` | +| `media` | Manage media attachments | `list`, `get`, `upload`, `update`, `delete` | | `comment` | Manage comments | `list`, `get`, `create`, `update`, `delete` | | `block` | Manage reusable blocks | `list`, `get`, `create`, `update`, `delete`, `search`, `render` | | `search` | Global search across content | *(direct command -- takes a query argument)* | @@ -138,6 +163,7 @@ wpx search "migration guide" --site production | `post-status` | List and inspect post statuses | `list`, `get` | | `discover` | Probe a site's REST API capabilities | *(direct command -- takes a URL argument)* | | `schema` | Show JSON Schema for a command | *(direct command -- takes a command path)* | +| `api` | Call any REST route under `/wp-json/` | *(direct command -- `api `)* | ### Utilities @@ -156,7 +182,7 @@ wpx search "migration guide" --site production | `--site ` | `WPX_SITE` | `default` | Target site profile name | | `--url ` | `WPX_URL` | -- | Direct URL override (skips profile lookup) | | `--output ` | `WPX_OUTPUT` | `auto` | Output format: `json`, `table`, `csv`, `yaml`, `ndjson`, `auto` | -| `--fields ` | -- | -- | Comma-separated field mask to reduce output | +| `--fields ` | -- | -- | Comma-separated field mask to reduce output; dotted paths select nested keys (`content.raw`, `acf.hero.title`) | | `--no-color` | `NO_COLOR` | -- | Disable colored output | | `--no-prompt` | `WPX_NO_PROMPT` | -- | Disable all interactive prompts | | `--quiet` | -- | -- | Suppress non-essential output | @@ -200,6 +226,8 @@ username = "editor" |----------|-------------| | `WPX_SITE` | Default site profile name | | `WPX_URL` | Direct WordPress URL (bypasses profile lookup) | +| `WPX_USERNAME` | Username for application-password auth (overrides `credentials.toml`) | +| `WPX_PASSWORD` | Application password (alias: `WPX_APP_PASSWORD`); with `WPX_URL` no site profile is needed -- ideal for CI | | `WPX_OUTPUT` | Default output format | | `WPX_TIMEOUT` | Request timeout in seconds | | `WPX_RETRIES` | Retry count for failed requests | @@ -228,6 +256,14 @@ WordPress 5.6+ supports Application Passwords natively. No plugins required. wpx auth set --site production --username admin --password "XXXX XXXX XXXX XXXX" ``` +`auth set` stores the password; the site URL comes from a `[sites.production]` profile in +`~/.config/wpx/config.toml` or a project `.wpx.toml`. For CI, skip the files entirely: + +```bash +export WPX_URL=https://example.com WPX_USERNAME=ci-bot WPX_PASSWORD="xxxx xxxx xxxx xxxx" +wpx auth test +``` + ### OAuth 2.1 For environments that require OAuth (headless WordPress, enterprise SSO): diff --git a/crates/wpx-api/src/client.rs b/crates/wpx-api/src/client.rs index c478cf9..26de4b3 100644 --- a/crates/wpx-api/src/client.rs +++ b/crates/wpx-api/src/client.rs @@ -134,6 +134,93 @@ impl WpClient { self.parse_response(response).await } + /// Perform a PATCH request with a JSON body. + pub async fn patch( + &self, + path: &str, + body: &B, + ) -> Result, WpxError> { + let url = self.api_url(path)?; + debug!("PATCH {url}"); + + self.request_with_retry(|| { + let req = self.http.patch(url.clone()).json(body); + self.auth.authenticate(req) + }) + .await + } + + /// Perform an arbitrary request against any REST route. + /// + /// `method` is an HTTP verb (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`), `path` is + /// relative to `/wp-json/` (e.g. `wp/v2/types/blog` or `rankmath/v1/updateMeta`), + /// `params` are appended as query string and `body` (if any) is sent as JSON. + /// This is the escape hatch behind `wpx api` for routes wpx has no typed command for. + pub async fn request_raw( + &self, + method: &str, + path: &str, + params: &[(&str, &str)], + body: Option<&serde_json::Value>, + ) -> Result, WpxError> { + let method = match method.to_ascii_uppercase().as_str() { + "GET" => reqwest::Method::GET, + "POST" => reqwest::Method::POST, + "PUT" => reqwest::Method::PUT, + "PATCH" => reqwest::Method::PATCH, + "DELETE" => reqwest::Method::DELETE, + "HEAD" => reqwest::Method::HEAD, + "OPTIONS" => reqwest::Method::OPTIONS, + other => { + return Err(WpxError::Validation { + field: "method".into(), + message: format!( + "Unsupported HTTP method '{other}' (use GET, POST, PUT, PATCH, DELETE, HEAD or OPTIONS)" + ), + }) + } + }; + let url = self.api_url(path)?; + debug!("{method} {url}"); + + self.request_with_retry(|| { + let mut req = self.http.request(method.clone(), url.clone()).query(params); + if let Some(body) = body { + req = req.json(body); + } + self.auth.authenticate(req) + }) + .await + } + + /// Upload a file as a multipart form (e.g. `POST wp/v2/media`). + /// + /// The file is sent in the `file` part with the given `file_name` and `mime` type; + /// `fields` become additional text parts (`title`, `alt_text`, `caption`, `post`, ...). + pub async fn upload_file( + &self, + path: &str, + file_name: &str, + bytes: Vec, + mime: &str, + fields: &[(&str, String)], + ) -> Result, WpxError> { + let part = reqwest::multipart::Part::bytes(bytes) + .file_name(file_name.to_string()) + .mime_str(mime) + .map_err(|e| WpxError::Validation { + field: "mime".into(), + message: format!("Invalid MIME type '{mime}': {e}"), + })?; + + let mut form = reqwest::multipart::Form::new().part("file", part); + for (key, value) in fields { + form = form.text((*key).to_string(), value.clone()); + } + + self.post_multipart(path, form).await + } + /// Execute a request with retry logic for transient failures. async fn request_with_retry(&self, build_request: F) -> Result, WpxError> where @@ -201,10 +288,18 @@ impl WpClient { let total = Self::parse_header_u64(&headers, "x-wp-total"); let total_pages = Self::parse_header_u64(&headers, "x-wp-totalpages"); - let data: T = response - .json() + let bytes = response + .bytes() .await - .map_err(|e| WpxError::Other(format!("Failed to parse response: {e}")))?; + .map_err(|e| WpxError::Other(format!("Failed to read response: {e}")))?; + // HEAD and 204 responses carry no body; treat that as JSON `null` so + // callers expecting a `Value` (e.g. `wpx api HEAD ...`) still succeed. + let data: T = if bytes.iter().all(u8::is_ascii_whitespace) { + serde_json::from_str("null") + } else { + serde_json::from_slice(&bytes) + } + .map_err(|e| WpxError::Other(format!("Failed to parse response: {e}")))?; Ok(ApiResponse { data, @@ -356,6 +451,45 @@ impl WpClient { } } +/// Guess a MIME type from a file name's extension. +/// +/// Covers the upload types WordPress allows by default; anything else falls back to +/// `application/octet-stream` (WordPress will still sniff and validate server-side). +pub fn mime_from_extension(file_name: &str) -> &'static str { + let ext = file_name + .rsplit('.') + .next() + .map(|e| e.to_ascii_lowercase()) + .unwrap_or_default(); + match ext.as_str() { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "svg" => "image/svg+xml", + "webp" => "image/webp", + "avif" => "image/avif", + "ico" => "image/x-icon", + "pdf" => "application/pdf", + "mp4" | "m4v" => "video/mp4", + "webm" => "video/webm", + "mov" => "video/quicktime", + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + "ogg" => "audio/ogg", + "txt" => "text/plain", + "csv" => "text/csv", + "json" => "application/json", + "zip" => "application/zip", + "doc" => "application/msword", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" => "application/vnd.ms-excel", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt" => "application/vnd.ms-powerpoint", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + _ => "application/octet-stream", + } +} + struct RetryContext { #[allow(dead_code)] status: Option, @@ -435,6 +569,17 @@ mod tests { assert_eq!(client.backoff_delay(3, None), Duration::from_secs(8)); } + #[test] + fn mime_from_extension_table() { + assert_eq!(mime_from_extension("hero.PNG"), "image/png"); + assert_eq!(mime_from_extension("a/b/photo.jpeg"), "image/jpeg"); + assert_eq!(mime_from_extension("logo.svg"), "image/svg+xml"); + assert_eq!(mime_from_extension("doc.pdf"), "application/pdf"); + assert_eq!(mime_from_extension("clip.webm"), "video/webm"); + assert_eq!(mime_from_extension("noext"), "application/octet-stream"); + assert_eq!(mime_from_extension("weird.xyz"), "application/octet-stream"); + } + #[test] fn backoff_respects_retry_after() { let client = WpClient::new( diff --git a/crates/wpx-api/src/lib.rs b/crates/wpx-api/src/lib.rs index 932fb51..b83dfad 100644 --- a/crates/wpx-api/src/lib.rs +++ b/crates/wpx-api/src/lib.rs @@ -2,5 +2,5 @@ pub mod client; pub mod error; pub mod response; -pub use client::WpClient; +pub use client::{mime_from_extension, WpClient}; pub use response::ApiResponse; diff --git a/crates/wpx-api/tests/client_requests.rs b/crates/wpx-api/tests/client_requests.rs new file mode 100644 index 0000000..b015880 --- /dev/null +++ b/crates/wpx-api/tests/client_requests.rs @@ -0,0 +1,173 @@ +use serde_json::json; +use url::Url; +use wiremock::matchers::{body_string_contains, header, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; +use wpx_api::WpClient; + +fn client_for(server: &MockServer) -> WpClient { + WpClient::new( + Url::parse(&server.uri()).unwrap(), + Box::new(wpx_auth::ApplicationPasswordAuth::new( + "bot".into(), + "secret".into(), + )), + 5, + 0, + ) + .unwrap() +} + +#[tokio::test] +async fn upload_file_sends_multipart_with_fields() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/wp-json/wp/v2/media")) + .and(header("authorization", "Basic Ym90OnNlY3JldA==")) + .and(body_string_contains("name=\"file\"; filename=\"hero.png\"")) + .and(body_string_contains("Content-Type: image/png")) + .and(body_string_contains("name=\"title\"")) + .and(body_string_contains("Hero image")) + .and(body_string_contains("name=\"alt_text\"")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "id": 6168, + "slug": "hero", + "title": {"rendered": "Hero image"}, + "mime_type": "image/png", + "source_url": "https://example.com/wp-content/uploads/hero.png" + }))) + .expect(1) + .mount(&server) + .await; + + let client = client_for(&server); + let resp: wpx_api::ApiResponse = client + .upload_file( + "wp/v2/media", + "hero.png", + b"fake-png-bytes".to_vec(), + "image/png", + &[ + ("title", "Hero image".to_string()), + ("alt_text", "A hero".to_string()), + ], + ) + .await + .unwrap(); + + assert_eq!(resp.data["id"], 6168); + assert_eq!(resp.data["mime_type"], "image/png"); +} + +#[tokio::test] +async fn request_raw_get_with_query() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/wp-json/wp/v2/types/blog")) + .and(query_param("context", "edit")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "slug": "blog", "rest_base": "blog", "rest_namespace": "wp/v2" + }))) + .expect(1) + .mount(&server) + .await; + + let client = client_for(&server); + let resp = client + .request_raw("get", "/wp/v2/types/blog", &[("context", "edit")], None) + .await + .unwrap(); + assert_eq!(resp.data["rest_base"], "blog"); +} + +#[tokio::test] +async fn request_raw_post_with_json_body() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/wp-json/rankmath/v1/updateMeta")) + .and(header("content-type", "application/json")) + .and(body_string_contains("\"objectID\":42")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": true}))) + .expect(1) + .mount(&server) + .await; + + let client = client_for(&server); + let body = json!({"objectType": "post", "objectID": 42, "meta": {"rank_math_title": "x"}}); + let resp = client + .request_raw("POST", "rankmath/v1/updateMeta", &[], Some(&body)) + .await + .unwrap(); + assert_eq!(resp.data["success"], true); +} + +#[tokio::test] +async fn request_raw_rejects_unknown_method() { + let server = MockServer::start().await; + let client = client_for(&server); + let err = client + .request_raw("FETCH", "wp/v2/posts", &[], None) + .await + .unwrap_err(); + assert!(matches!(err, wpx_core::WpxError::Validation { .. })); +} + +#[tokio::test] +async fn request_raw_maps_wordpress_errors() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/wp-json/wp/v2/pages/999999")) + .respond_with(ResponseTemplate::new(404).set_body_json(json!({ + "code": "rest_post_invalid_id", + "message": "Invalid post ID.", + "data": {"status": 404} + }))) + .mount(&server) + .await; + + let client = client_for(&server); + let err = client + .request_raw("GET", "wp/v2/pages/999999", &[], None) + .await + .unwrap_err(); + assert!( + matches!(err, wpx_core::WpxError::NotFound { .. }), + "{err:?}" + ); +} + +#[tokio::test] +async fn request_raw_head_without_body_yields_null_and_headers() { + let server = MockServer::start().await; + Mock::given(method("HEAD")) + .and(path("/wp-json/wp/v2/posts")) + .respond_with(ResponseTemplate::new(200).insert_header("x-wp-total", "12")) + .expect(1) + .mount(&server) + .await; + + let client = client_for(&server); + let resp = client + .request_raw("HEAD", "wp/v2/posts", &[], None) + .await + .unwrap(); + assert!(resp.data.is_null()); + assert_eq!(resp.total, Some(12)); +} + +#[tokio::test] +async fn request_raw_no_content_yields_null() { + let server = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/wp-json/custom/v1/cache")) + .respond_with(ResponseTemplate::new(204)) + .expect(1) + .mount(&server) + .await; + + let client = client_for(&server); + let resp = client + .request_raw("DELETE", "custom/v1/cache", &[], None) + .await + .unwrap(); + assert!(resp.data.is_null()); +} diff --git a/crates/wpx-cli/Cargo.toml b/crates/wpx-cli/Cargo.toml index 06d7167..da46457 100644 --- a/crates/wpx-cli/Cargo.toml +++ b/crates/wpx-cli/Cargo.toml @@ -26,3 +26,4 @@ url = { workspace = true } [dev-dependencies] assert_cmd = { workspace = true } predicates = { workspace = true } +wiremock = { workspace = true } diff --git a/crates/wpx-cli/src/cli.rs b/crates/wpx-cli/src/cli.rs index 156b88d..efb7e00 100644 --- a/crates/wpx-cli/src/cli.rs +++ b/crates/wpx-cli/src/cli.rs @@ -48,11 +48,23 @@ pub struct GlobalFlags { pub fields: Option>, /// Disable colored output. - #[arg(long, env = "NO_COLOR", global = true)] + #[arg( + long, + env = "NO_COLOR", + global = true, + action = clap::ArgAction::SetTrue, + value_parser = clap::builder::BoolishValueParser::new() + )] pub no_color: bool, /// Disable all interactive prompts. - #[arg(long, env = "WPX_NO_PROMPT", global = true)] + #[arg( + long, + env = "WPX_NO_PROMPT", + global = true, + action = clap::ArgAction::SetTrue, + value_parser = clap::builder::BoolishValueParser::new() + )] pub no_prompt: bool, /// Suppress non-essential output. @@ -235,6 +247,9 @@ pub enum Commands { args: crate::commands::search::SearchArgs, }, + /// Call any REST route directly: wpx api GET wp/v2/types/blog + Api(crate::commands::api::ApiArgs), + /// Manage site settings / options. Settings { #[command(subcommand)] diff --git a/crates/wpx-cli/src/commands/api.rs b/crates/wpx-cli/src/commands/api.rs new file mode 100644 index 0000000..540744f --- /dev/null +++ b/crates/wpx-cli/src/commands/api.rs @@ -0,0 +1,209 @@ +//! `wpx api` — raw REST escape hatch for routes without a typed command. + +use clap::Args; +use serde_json::json; +use wpx_api::WpClient; +use wpx_core::WpxError; +use wpx_output::RenderPayload; + +use crate::input; + +#[derive(Debug, Args)] +pub struct ApiArgs { + /// HTTP method: GET, POST, PUT, PATCH, DELETE. + #[arg(value_parser = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"], ignore_case = true)] + pub method: String, + + /// Route relative to /wp-json/, e.g. "wp/v2/types/blog" or "rankmath/v1/updateMeta". + pub path: String, + + /// Inline JSON request body. + #[arg(long, conflicts_with = "json", value_name = "JSON")] + pub data: Option, + + /// Read the JSON request body from stdin. + #[arg(long)] + pub json: bool, + + /// Query parameter (repeatable): --query context=edit --query per_page=5 + #[arg(long = "query", value_name = "KEY=VALUE")] + pub query: Vec, +} + +impl ApiArgs { + /// Parse the request body from `--data` or stdin, if any. + fn body(&self) -> Result, WpxError> { + if let Some(data) = &self.data { + let value = serde_json::from_str(data).map_err(|e| WpxError::Validation { + field: "data".into(), + message: format!("Invalid JSON in --data: {e}"), + })?; + return Ok(Some(value)); + } + if self.json { + return Ok(Some(input::json_from_stdin()?)); + } + Ok(None) + } +} + +/// Execute a raw request. Non-GET methods honour `dry_run`. +pub async fn handle( + args: &ApiArgs, + client: &WpClient, + dry_run: bool, +) -> Result { + let method = args.method.to_ascii_uppercase(); + let path = args.path.trim().trim_start_matches('/').to_string(); + if path.is_empty() { + return Err(WpxError::Validation { + field: "path".into(), + message: "path must not be empty".into(), + }); + } + let query = input::parse_key_values(&args.query)?; + let body = args.body()?; + + execute(client, &method, &path, &query, body.as_ref(), dry_run).await +} + +/// Shared implementation used by the CLI and the fleet dispatcher. +pub async fn execute( + client: &WpClient, + method: &str, + path: &str, + query: &[(String, String)], + body: Option<&serde_json::Value>, + dry_run: bool, +) -> Result { + let is_read = matches!(method, "GET" | "HEAD" | "OPTIONS"); + + if dry_run && !is_read { + return Ok(RenderPayload { + data: json!({ + "dry_run": true, + "action": "api", + "method": method, + "path": path, + "query": query + .iter() + .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) + .collect::>(), + "body": body.cloned().unwrap_or(serde_json::Value::Null), + }), + summary: None, + }); + } + + let query_refs: Vec<(&str, &str)> = query + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + + let response = client.request_raw(method, path, &query_refs, body).await?; + + let summary = response + .total + .map(|t| format!("{method} {path}: {t} total")); + + Ok(RenderPayload { + data: response.data, + summary, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{ + body_string_contains, method as http_method, path as http_path, query_param, + }; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn client_for(server: &MockServer) -> WpClient { + WpClient::new( + url::Url::parse(&server.uri()).unwrap(), + Box::new(wpx_auth::NoAuth), + 5, + 0, + ) + .unwrap() + } + + #[tokio::test] + async fn get_with_query_and_leading_slash() { + let server = MockServer::start().await; + Mock::given(http_method("GET")) + .and(http_path("/wp-json/wp/v2/types/blog")) + .and(query_param("context", "edit")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"rest_base": "blog"}))) + .expect(1) + .mount(&server) + .await; + let args = ApiArgs { + method: "get".into(), + path: "/wp/v2/types/blog".into(), + data: None, + json: false, + query: vec!["context=edit".into()], + }; + let payload = handle(&args, &client_for(&server), false).await.unwrap(); + assert_eq!(payload.data["rest_base"], "blog"); + } + + #[tokio::test] + async fn post_dry_run_does_not_send() { + let server = MockServer::start().await; + let args = ApiArgs { + method: "POST".into(), + path: "rankmath/v1/updateMeta".into(), + data: Some(r#"{"objectID": 42}"#.into()), + json: false, + query: vec![], + }; + let payload = handle(&args, &client_for(&server), true).await.unwrap(); + assert_eq!(payload.data["dry_run"], true); + assert_eq!(payload.data["method"], "POST"); + assert_eq!(payload.data["path"], "rankmath/v1/updateMeta"); + assert_eq!(payload.data["body"]["objectID"], 42); + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn post_sends_inline_body() { + let server = MockServer::start().await; + Mock::given(http_method("POST")) + .and(http_path("/wp-json/rankmath/v1/updateMeta")) + .and(body_string_contains("\"objectID\":42")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success": true}))) + .expect(1) + .mount(&server) + .await; + let args = ApiArgs { + method: "POST".into(), + path: "rankmath/v1/updateMeta".into(), + data: Some(r#"{"objectID": 42}"#.into()), + json: false, + query: vec![], + }; + let payload = handle(&args, &client_for(&server), false).await.unwrap(); + assert_eq!(payload.data["success"], true); + } + + #[tokio::test] + async fn invalid_inline_json_is_validation_error() { + let server = MockServer::start().await; + let args = ApiArgs { + method: "POST".into(), + path: "wp/v2/posts".into(), + data: Some("{not json".into()), + json: false, + query: vec![], + }; + let err = handle(&args, &client_for(&server), false) + .await + .err() + .expect("expected an error"); + assert!(matches!(err, WpxError::Validation { .. })); + } +} diff --git a/crates/wpx-cli/src/commands/auth.rs b/crates/wpx-cli/src/commands/auth.rs index 6cf668b..9b81722 100644 --- a/crates/wpx-cli/src/commands/auth.rs +++ b/crates/wpx-cli/src/commands/auth.rs @@ -64,7 +64,7 @@ async fn handle_test( } else { return Err(WpxError::Config { message: format!( - "Site '{site_name}' not found in config. Use --url or configure the site first." + "Site '{site_name}' not found in config. Use --url (or WPX_URL), or add a [sites.{site_name}] profile to ~/.config/wpx/config.toml or ./.wpx.toml." ), }); }; @@ -73,8 +73,8 @@ async fn handle_test( message: format!("Invalid URL '{site_url}': {e}"), })?; - // Build auth provider - let auth: Box = if let Some(creds) = store.get(site_name) { + // Build auth provider (env vars WPX_USERNAME / WPX_PASSWORD take precedence) + let auth: Box = if let Some(creds) = store.resolve(site_name) { Box::new(ApplicationPasswordAuth::new( creds.username.clone(), creds.password.clone(), diff --git a/crates/wpx-cli/src/commands/media.rs b/crates/wpx-cli/src/commands/media.rs index c955f86..dab1c52 100644 --- a/crates/wpx-cli/src/commands/media.rs +++ b/crates/wpx-cli/src/commands/media.rs @@ -1,8 +1,9 @@ use clap::{Args, Subcommand}; use serde::Serialize; +use serde_json::json; use wpx_api::WpClient; use wpx_core::resources::media::{Media, MediaUpdateParams}; -use wpx_core::WpxError; +use wpx_core::{Resource, WpxError}; use wpx_output::RenderPayload; use crate::crud; @@ -13,6 +14,8 @@ pub enum MediaCommands { List(MediaListArgs), /// Get a media item by ID. Get { id: u64 }, + /// Upload a file to the media library. + Upload(MediaUploadArgs), /// Update media metadata. Update { id: u64, @@ -81,6 +84,114 @@ impl MediaUpdateCli { } } +#[derive(Debug, Args, serde::Deserialize)] +pub struct MediaUploadArgs { + /// Path of the file to upload. + pub file: String, + /// Attachment title (defaults to the file name). + #[arg(long)] + pub title: Option, + /// Alternative text for images. + #[arg(long)] + pub alt_text: Option, + /// Caption. + #[arg(long)] + pub caption: Option, + /// Description. + #[arg(long)] + pub description: Option, + /// Attach to this post ID. + #[arg(long)] + pub post: Option, + /// Override the MIME type guessed from the file extension. + #[arg(long)] + pub mime_type: Option, +} + +impl MediaUploadArgs { + fn file_name(&self) -> String { + std::path::Path::new(&self.file) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("upload.bin") + .to_string() + } + + fn mime(&self, file_name: &str) -> String { + self.mime_type + .clone() + .unwrap_or_else(|| wpx_api::mime_from_extension(file_name).to_string()) + } + + fn fields(&self) -> Vec<(&'static str, String)> { + let mut fields = Vec::new(); + if let Some(v) = &self.title { + fields.push(("title", v.clone())); + } + if let Some(v) = &self.alt_text { + fields.push(("alt_text", v.clone())); + } + if let Some(v) = &self.caption { + fields.push(("caption", v.clone())); + } + if let Some(v) = &self.description { + fields.push(("description", v.clone())); + } + if let Some(v) = self.post { + fields.push(("post", v.to_string())); + } + fields + } +} + +/// Upload a local file to `wp/v2/media`. +pub async fn upload( + args: &MediaUploadArgs, + client: &WpClient, + dry_run: bool, +) -> Result { + let file_name = args.file_name(); + let mime = args.mime(&file_name); + + let bytes = std::fs::read(&args.file).map_err(|e| WpxError::Validation { + field: "file".into(), + message: format!("Cannot read '{}': {e}", args.file), + })?; + + if dry_run { + return Ok(RenderPayload { + data: json!({ + "dry_run": true, + "action": "upload", + "resource": Media::NAME, + "path": Media::API_PATH, + "file": args.file, + "file_name": file_name, + "size": bytes.len(), + "mime": mime, + "fields": args + .fields() + .into_iter() + .map(|(k, v)| (k.to_string(), serde_json::Value::String(v))) + .collect::>(), + }), + summary: None, + }); + } + + let response: wpx_api::ApiResponse = client + .upload_file(Media::API_PATH, &file_name, bytes, &mime, &args.fields()) + .await?; + + let id = response.data.id; + let data = serde_json::to_value(&response.data).map_err(|e| WpxError::Other(e.to_string()))?; + + Ok(RenderPayload { + data, + summary: Some(format!("media {id} uploaded ({file_name})")), + }) +} + pub async fn handle( command: &MediaCommands, client: &WpClient, @@ -89,6 +200,7 @@ pub async fn handle( match command { MediaCommands::List(args) => crud::list::(client, args).await, MediaCommands::Get { id } => crud::get::(client, *id).await, + MediaCommands::Upload(args) => upload(args, client, dry_run).await, MediaCommands::Update { id, args } => { let params = args.to_params(); crud::update::(client, *id, ¶ms, dry_run).await @@ -98,3 +210,112 @@ pub async fn handle( } } } + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{body_string_contains, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn temp_file(name: &str, contents: &[u8]) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("wpx-media-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join(name); + std::fs::write(&file, contents).unwrap(); + file + } + + #[tokio::test] + async fn upload_dry_run_reports_file_and_mime() { + let file = temp_file("hero.png", b"fake"); + let args = MediaUploadArgs { + file: file.to_string_lossy().into_owned(), + title: Some("Hero".into()), + alt_text: None, + caption: None, + description: None, + post: Some(42), + mime_type: None, + }; + let server = MockServer::start().await; + let client = WpClient::new( + url::Url::parse(&server.uri()).unwrap(), + Box::new(wpx_auth::NoAuth), + 5, + 0, + ) + .unwrap(); + let payload = upload(&args, &client, true).await.unwrap(); + assert_eq!(payload.data["dry_run"], true); + assert_eq!(payload.data["action"], "upload"); + assert_eq!(payload.data["mime"], "image/png"); + assert_eq!(payload.data["size"], 4); + assert_eq!(payload.data["fields"]["post"], "42"); + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn upload_posts_multipart_to_media_endpoint() { + let file = temp_file("logo.svg", b""); + let args = MediaUploadArgs { + file: file.to_string_lossy().into_owned(), + title: Some("Logo".into()), + alt_text: Some("OSO logo".into()), + caption: None, + description: None, + post: None, + mime_type: None, + }; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/wp-json/wp/v2/media")) + .and(body_string_contains("filename=\"logo.svg\"")) + .and(body_string_contains("Content-Type: image/svg+xml")) + .and(body_string_contains("OSO logo")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "id": 77, "title": {"rendered": "Logo"}, "mime_type": "image/svg+xml" + }))) + .expect(1) + .mount(&server) + .await; + let client = WpClient::new( + url::Url::parse(&server.uri()).unwrap(), + Box::new(wpx_auth::NoAuth), + 5, + 0, + ) + .unwrap(); + let payload = upload(&args, &client, false).await.unwrap(); + assert_eq!(payload.data["id"], 77); + assert_eq!( + payload.summary.as_deref(), + Some("media 77 uploaded (logo.svg)") + ); + } + + #[test] + fn missing_file_is_validation_error() { + let args = MediaUploadArgs { + file: "/definitely/missing.png".into(), + title: None, + alt_text: None, + caption: None, + description: None, + post: None, + mime_type: None, + }; + let rt = tokio::runtime::Runtime::new().unwrap(); + let client = WpClient::new( + url::Url::parse("https://example.com").unwrap(), + Box::new(wpx_auth::NoAuth), + 5, + 0, + ) + .unwrap(); + let err = rt + .block_on(upload(&args, &client, true)) + .err() + .expect("expected an error"); + assert!(matches!(err, WpxError::Validation { .. })); + } +} diff --git a/crates/wpx-cli/src/commands/mod.rs b/crates/wpx-cli/src/commands/mod.rs index ec82dde..3b9b743 100644 --- a/crates/wpx-cli/src/commands/mod.rs +++ b/crates/wpx-cli/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod api; pub mod auth; pub mod block; pub mod block_pattern; diff --git a/crates/wpx-cli/src/commands/page.rs b/crates/wpx-cli/src/commands/page.rs index 2ee9cbd..231b160 100644 --- a/crates/wpx-cli/src/commands/page.rs +++ b/crates/wpx-cli/src/commands/page.rs @@ -1,9 +1,10 @@ use crate::crud; +use crate::input; use clap::{Args, Subcommand}; use serde::Serialize; use wpx_api::WpClient; use wpx_core::resources::page::{Page, PageCreateParams}; -use wpx_core::WpxError; +use wpx_core::{Resource, WpxError}; use wpx_output::RenderPayload; #[derive(Debug, Subcommand)] @@ -14,6 +15,9 @@ pub enum PageCommands { Get { /// Page ID. id: u64, + /// Response context: view (default), edit (includes raw content, needs auth), embed. + #[arg(long, value_parser = ["view", "edit", "embed"])] + context: Option, }, /// Create a new page. Create(PageCreateCli), @@ -34,73 +38,103 @@ pub enum PageCommands { }, } -#[derive(Debug, Args, Serialize)] +#[derive(Debug, Default, Args, Serialize, serde::Deserialize)] pub struct PageListArgs { + /// Filter by status: publish, draft, pending, private, future, trash. #[arg(long)] #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, + /// Filter by exact slug. + #[arg(long)] + #[serde(skip_serializing_if = "Option::is_none")] + pub slug: Option, + + /// Filter by author ID. #[arg(long)] #[serde(skip_serializing_if = "Option::is_none")] pub author: Option, + /// Filter by parent page ID. #[arg(long)] #[serde(skip_serializing_if = "Option::is_none")] pub parent: Option, + /// Search term. #[arg(long)] #[serde(skip_serializing_if = "Option::is_none")] pub search: Option, + /// Results per page (default 10, max 100). #[arg(long)] #[serde(skip_serializing_if = "Option::is_none")] pub per_page: Option, + /// Page number. #[arg(long)] #[serde(skip_serializing_if = "Option::is_none")] pub page: Option, + /// Sort direction: asc or desc. #[arg(long)] #[serde(skip_serializing_if = "Option::is_none")] pub order: Option, + /// Sort field: date, title, id, modified, slug, menu_order. #[arg(long)] #[serde(skip_serializing_if = "Option::is_none")] pub orderby: Option, + + /// Response context: view (default), edit (needs auth), embed. + #[arg(long, value_parser = ["view", "edit", "embed"])] + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, } #[derive(Debug, Args)] pub struct PageCreateCli { + /// Page title. #[arg(long)] pub title: Option, - #[arg(long)] + /// Page content (HTML or block markup). + #[arg(long, conflicts_with = "content_file")] pub content: Option, + /// Read page content from a file ("-" for stdin). + #[arg(long, value_name = "PATH")] + pub content_file: Option, + /// Page excerpt. #[arg(long)] pub excerpt: Option, + /// Page status: publish, draft, pending, private, future. #[arg(long)] pub status: Option, + /// Author ID. #[arg(long)] pub author: Option, + /// Page slug. #[arg(long)] pub slug: Option, + /// Parent page ID. #[arg(long)] pub parent: Option, + /// Menu order. #[arg(long)] pub menu_order: Option, - /// Read JSON payload from stdin. + /// Page template file name (e.g. template-services.php). + #[arg(long)] + pub template: Option, + /// Featured image attachment ID. + #[arg(long)] + pub featured_media: Option, + /// Read JSON payload from stdin (unknown keys such as `acf` or `meta` are passed through). #[arg(long)] pub json: bool, } impl PageCreateCli { pub fn to_params(&self) -> Result { - let mut params = if self.json { - let stdin = std::io::read_to_string(std::io::stdin()) - .map_err(|e| WpxError::Other(format!("Failed to read stdin: {e}")))?; - serde_json::from_str(&stdin).map_err(|e| WpxError::Validation { - field: "json".into(), - message: format!("Invalid JSON input: {e}"), - })? + let mut params: PageCreateParams = if self.json { + input::json_from_stdin()? } else { PageCreateParams::default() }; @@ -108,8 +142,12 @@ impl PageCreateCli { if self.title.is_some() { params.title = self.title.clone(); } - if self.content.is_some() { - params.content = self.content.clone(); + if let Some(content) = input::resolve_content( + self.content.as_deref(), + self.content_file.as_deref(), + self.json, + )? { + params.content = Some(content); } if self.excerpt.is_some() { params.excerpt = self.excerpt.clone(); @@ -129,6 +167,12 @@ impl PageCreateCli { if self.menu_order.is_some() { params.menu_order = self.menu_order; } + if self.template.is_some() { + params.template = self.template.clone(); + } + if self.featured_media.is_some() { + params.featured_media = self.featured_media; + } Ok(params) } @@ -141,7 +185,13 @@ pub async fn handle( ) -> Result { match command { PageCommands::List(args) => crud::list::(client, args).await, - PageCommands::Get { id } => crud::get::(client, *id).await, + PageCommands::Get { id, context } => { + let params: Vec<(&str, &str)> = context + .as_deref() + .map(|c| vec![("context", c)]) + .unwrap_or_default(); + crud::get_at::(client, Page::API_PATH, *id, ¶ms).await + } PageCommands::Create(args) => { let params = args.to_params()?; crud::create::(client, ¶ms, dry_run).await diff --git a/crates/wpx-cli/src/commands/post.rs b/crates/wpx-cli/src/commands/post.rs index 92ebfee..784fe96 100644 --- a/crates/wpx-cli/src/commands/post.rs +++ b/crates/wpx-cli/src/commands/post.rs @@ -1,4 +1,5 @@ use crate::crud; +use crate::input; use clap::{Args, Subcommand}; use serde::Serialize; use wpx_api::WpClient; @@ -14,6 +15,11 @@ pub enum PostCommands { Get { /// Post ID. id: u64, + #[command(flatten)] + target: PostTypeArgs, + /// Response context: view (default), edit (includes raw content, needs auth), embed. + #[arg(long, value_parser = ["view", "edit", "embed"])] + context: Option, }, /// Create a new post. Create(PostCreateArgs), @@ -31,6 +37,8 @@ pub enum PostCommands { /// Permanently delete instead of trashing. #[arg(long)] force: bool, + #[command(flatten)] + target: PostTypeArgs, }, /// Search posts by query. Search { @@ -41,6 +49,32 @@ pub enum PostCommands { }, } +/// Which post type collection to talk to. +/// +/// `--type` is resolved through `GET wp/v2/types/{slug}` to the type's REST base +/// (e.g. `blog` → `wp/v2/blog`); core types (`post`, `page`, `attachment`) resolve +/// without a lookup. `--rest-base` bypasses the lookup entirely. +#[derive(Debug, Default, Clone, Args, Serialize, serde::Deserialize)] +pub struct PostTypeArgs { + /// Post type slug (post, page, or any custom post type exposed in REST). + #[arg(long = "type")] + #[serde(skip)] + pub post_type: Option, + + /// REST collection path to use instead of resolving --type (e.g. "blog" or "wc/v3/products"). + #[arg(long)] + #[serde(skip)] + pub rest_base: Option, +} + +impl PostTypeArgs { + /// Resolve the collection path for these args. + pub async fn api_path(&self, client: &WpClient) -> Result { + crud::resolve_post_type_path(client, self.post_type.as_deref(), self.rest_base.as_deref()) + .await + } +} + #[derive(Debug, Default, Args, Serialize, serde::Deserialize)] pub struct PostListArgs { /// Filter by status: publish, draft, pending, private, future, trash. @@ -48,10 +82,14 @@ pub struct PostListArgs { #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, - /// Filter by post type. - #[arg(long, name = "type")] + #[command(flatten)] + #[serde(flatten)] + pub target: PostTypeArgs, + + /// Filter by exact slug. + #[arg(long)] #[serde(skip_serializing_if = "Option::is_none")] - pub post_type: Option, + pub slug: Option, /// Filter by author ID. #[arg(long)] @@ -102,18 +140,30 @@ pub struct PostListArgs { #[arg(long)] #[serde(skip_serializing_if = "Option::is_none")] pub orderby: Option, + + /// Response context: view (default), edit (needs auth), embed. + #[arg(long, value_parser = ["view", "edit", "embed"])] + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, } #[derive(Debug, Args)] pub struct PostCreateArgs { + #[command(flatten)] + pub target: PostTypeArgs, + /// Post title. #[arg(long)] pub title: Option, - /// Post content (HTML). - #[arg(long)] + /// Post content (HTML or block markup). + #[arg(long, conflicts_with = "content_file")] pub content: Option, + /// Read post content from a file ("-" for stdin). + #[arg(long, value_name = "PATH")] + pub content_file: Option, + /// Post excerpt. #[arg(long)] pub excerpt: Option, @@ -130,7 +180,15 @@ pub struct PostCreateArgs { #[arg(long)] pub slug: Option, - /// Read JSON payload from stdin. + /// Template file name (e.g. template-landing.php). + #[arg(long)] + pub template: Option, + + /// Featured image attachment ID. + #[arg(long)] + pub featured_media: Option, + + /// Read JSON payload from stdin (unknown keys such as custom taxonomies are passed through). #[arg(long)] pub json: bool, } @@ -138,13 +196,8 @@ pub struct PostCreateArgs { impl PostCreateArgs { /// Convert to API parameters, merging with optional JSON stdin. pub fn to_params(&self) -> Result { - let mut params = if self.json { - let stdin = std::io::read_to_string(std::io::stdin()) - .map_err(|e| WpxError::Other(format!("Failed to read stdin: {e}")))?; - serde_json::from_str(&stdin).map_err(|e| WpxError::Validation { - field: "json".into(), - message: format!("Invalid JSON input: {e}"), - })? + let mut params: PostCreateParams = if self.json { + input::json_from_stdin()? } else { PostCreateParams::default() }; @@ -153,8 +206,12 @@ impl PostCreateArgs { if self.title.is_some() { params.title = self.title.clone(); } - if self.content.is_some() { - params.content = self.content.clone(); + if let Some(content) = input::resolve_content( + self.content.as_deref(), + self.content_file.as_deref(), + self.json, + )? { + params.content = Some(content); } if self.excerpt.is_some() { params.excerpt = self.excerpt.clone(); @@ -168,6 +225,12 @@ impl PostCreateArgs { if self.slug.is_some() { params.slug = self.slug.clone(); } + if self.template.is_some() { + params.template = self.template.clone(); + } + if self.featured_media.is_some() { + params.featured_media = self.featured_media; + } Ok(params) } @@ -181,29 +244,46 @@ pub async fn handle( ) -> Result { match command { PostCommands::List(args) => { + let path = args.target.api_path(client).await?; if all_pages { - crud::list_all_pages::(client, args).await + crud::list_all_pages_at::(client, &path, args).await } else { - crud::list::(client, args).await + crud::list_at::(client, &path, args).await } } - PostCommands::Get { id } => crud::get::(client, *id).await, + PostCommands::Get { + id, + target, + context, + } => { + let path = target.api_path(client).await?; + let params: Vec<(&str, &str)> = context + .as_deref() + .map(|c| vec![("context", c)]) + .unwrap_or_default(); + crud::get_at::(client, &path, *id, ¶ms).await + } PostCommands::Create(args) => { + let path = args.target.api_path(client).await?; let params = args.to_params()?; - crud::create::(client, ¶ms, dry_run).await + crud::create_at::(client, &path, ¶ms, dry_run).await } PostCommands::Update { id, args } => { + let path = args.target.api_path(client).await?; let params = args.to_params()?; - crud::update::(client, *id, ¶ms, dry_run).await + crud::update_at::(client, &path, *id, ¶ms, dry_run).await } - PostCommands::Delete { id, force } => { - crud::delete::(client, *id, *force, dry_run).await + PostCommands::Delete { id, force, target } => { + let path = target.api_path(client).await?; + crud::delete_at::(client, &path, *id, *force, dry_run).await } PostCommands::Search { query, args } => { + let path = args.target.api_path(client).await?; let list_args = PostListArgs { search: Some(query.clone()), status: args.status.clone(), - post_type: args.post_type.clone(), + target: args.target.clone(), + slug: args.slug.clone(), author: args.author, categories: args.categories.clone(), tags: args.tags.clone(), @@ -213,8 +293,38 @@ pub async fn handle( page: args.page, order: args.order.clone(), orderby: Some(args.orderby.clone().unwrap_or_else(|| "relevance".into())), + context: args.context.clone(), }; - crud::list::(client, &list_args).await + crud::list_at::(client, &path, &list_args).await } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_args_do_not_leak_type_into_query() { + let args = PostListArgs { + target: PostTypeArgs { + post_type: Some("blog".into()), + rest_base: None, + }, + per_page: Some(2), + ..Default::default() + }; + let query = crud::to_query_params(&args); + assert_eq!(query, vec![("per_page".to_string(), "2".to_string())]); + } + + #[test] + fn list_args_deserialize_type_for_dispatch() { + // Dispatch passes JSON args; `type` is handled separately (serde(skip)), the rest flows. + let args: PostListArgs = + serde_json::from_value(serde_json::json!({"status": "draft", "slug": "x"})).unwrap(); + assert_eq!(args.status.as_deref(), Some("draft")); + assert_eq!(args.slug.as_deref(), Some("x")); + assert!(args.target.post_type.is_none()); + } +} diff --git a/crates/wpx-cli/src/commands/schema.rs b/crates/wpx-cli/src/commands/schema.rs index 6eea378..bf34684 100644 --- a/crates/wpx-cli/src/commands/schema.rs +++ b/crates/wpx-cli/src/commands/schema.rs @@ -30,11 +30,15 @@ fn schemas() -> Vec { vec![ SchemaEntry { command: "post list", - description: "List posts with optional filters", + description: "List posts with optional filters (any post type via `type`)", input: json!({ "type": "object", "properties": { + "type": { "type": "string", "description": "Post type slug; resolved via wp/v2/types/{slug} to its REST base (default: post)" }, + "rest_base": { "type": "string", "description": "REST collection path to use instead of resolving `type` (e.g. blog, wc/v3/products)" }, "status": { "type": "string", "enum": ["publish", "draft", "pending", "private", "future", "trash"] }, + "slug": { "type": "string" }, + "context": { "type": "string", "enum": ["view", "edit", "embed"] }, "search": { "type": "string" }, "author": { "type": "integer" }, "per_page": { "type": "integer", "minimum": 1, "maximum": 100, "default": 10 }, @@ -50,8 +54,13 @@ fn schemas() -> Vec { }, SchemaEntry { command: "post get", - description: "Get a single post by ID", - input: json!({ "type": "object", "properties": { "id": { "type": "integer" } }, "required": ["id"] }), + description: "Get a single post by ID (any post type via `type`; `context: edit` returns content.raw)", + input: json!({ "type": "object", "properties": { + "id": { "type": "integer" }, + "type": { "type": "string" }, + "rest_base": { "type": "string" }, + "context": { "type": "string", "enum": ["view", "edit", "embed"] } + }, "required": ["id"] }), output: json!({ "type": "object", "properties": { "id": { "type": "integer" }, "title": { "type": "object" }, "status": { "type": "string" }, "content": { "type": "object" }, "date": { "type": "string" } @@ -59,47 +68,74 @@ fn schemas() -> Vec { }, SchemaEntry { command: "post create", - description: "Create a new post", + description: "Create a new post (any post type via `type`; unknown keys pass through to WordPress)", input: json!({ "type": "object", "properties": { - "title": { "type": "string" }, "content": { "type": "string" }, - "status": { "type": "string", "enum": ["publish", "draft", "pending", "private"] }, - "author": { "type": "integer" }, "excerpt": { "type": "string" } - }}), + "type": { "type": "string", "description": "Post type slug (default: post)" }, + "rest_base": { "type": "string" }, + "title": { "type": "string" }, + "content": { "type": "string", "description": "HTML or block markup" }, + "content_file": { "type": "string", "description": "CLI only: read content from a file, '-' for stdin" }, + "status": { "type": "string", "enum": ["publish", "draft", "pending", "private", "future"] }, + "author": { "type": "integer" }, "excerpt": { "type": "string" }, "slug": { "type": "string" }, + "template": { "type": "string", "description": "Template file name, e.g. template-landing.php" }, + "featured_media": { "type": "integer", "description": "Attachment ID" }, + "meta": { "type": "object", "description": "Post meta (keys registered with show_in_rest)" }, + "acf": { "type": "object", "description": "ACF fields (field groups exposed with show_in_rest)" } + }, "additionalProperties": { "description": "Custom taxonomies (e.g. blog_category: [9]) and other REST fields are passed through verbatim" } }), output: json!({ "type": "object" }), }, SchemaEntry { command: "post update", - description: "Update an existing post", + description: "Update an existing post (any post type via `type`; same body as post create)", input: json!({ "type": "object", "properties": { - "id": { "type": "integer" }, "title": { "type": "string" }, - "content": { "type": "string" }, "status": { "type": "string" } - }, "required": ["id"] }), + "id": { "type": "integer" }, "type": { "type": "string" }, "rest_base": { "type": "string" }, + "title": { "type": "string" }, "content": { "type": "string" }, "content_file": { "type": "string" }, + "status": { "type": "string" }, "template": { "type": "string" }, "featured_media": { "type": "integer" }, + "meta": { "type": "object" }, "acf": { "type": "object" } + }, "required": ["id"], "additionalProperties": true }), output: json!({ "type": "object" }), }, SchemaEntry { command: "post delete", - description: "Delete or trash a post", + description: "Delete or trash a post (any post type via `type`)", input: json!({ "type": "object", "properties": { - "id": { "type": "integer" }, "force": { "type": "boolean", "default": false } + "id": { "type": "integer" }, "type": { "type": "string" }, "rest_base": { "type": "string" }, + "force": { "type": "boolean", "default": false } }, "required": ["id"] }), output: json!({ "type": "object" }), }, SchemaEntry { command: "page list", description: "List pages", - input: json!({"type":"object","properties":{"status":{"type":"string"},"per_page":{"type":"integer"}}}), + input: json!({"type":"object","properties":{"status":{"type":"string"},"slug":{"type":"string"},"per_page":{"type":"integer"},"context":{"type":"string","enum":["view","edit","embed"]}}}), output: json!({"type":"array"}), }, SchemaEntry { command: "page get", - description: "Get a page by ID", - input: json!({"type":"object","properties":{"id":{"type":"integer"}},"required":["id"]}), + description: "Get a page by ID (`context: edit` returns content.raw)", + input: json!({"type":"object","properties":{"id":{"type":"integer"},"context":{"type":"string","enum":["view","edit","embed"]}},"required":["id"]}), output: json!({"type":"object"}), }, SchemaEntry { command: "page create", - description: "Create a page", - input: json!({"type":"object","properties":{"title":{"type":"string"},"content":{"type":"string"},"status":{"type":"string"}}}), + description: "Create a page (unknown keys such as acf/meta pass through to WordPress)", + input: json!({"type":"object","properties":{ + "title":{"type":"string"},"content":{"type":"string"}, + "content_file":{"type":"string","description":"CLI only: read content from a file, '-' for stdin"}, + "status":{"type":"string"},"slug":{"type":"string"},"parent":{"type":"integer"},"menu_order":{"type":"integer"}, + "template":{"type":"string"},"featured_media":{"type":"integer"}, + "meta":{"type":"object"},"acf":{"type":"object"} + },"additionalProperties":true}), + output: json!({"type":"object"}), + }, + SchemaEntry { + command: "page update", + description: "Update a page (same body as page create)", + input: json!({"type":"object","properties":{ + "id":{"type":"integer"},"title":{"type":"string"},"content":{"type":"string"},"content_file":{"type":"string"}, + "status":{"type":"string"},"template":{"type":"string"},"featured_media":{"type":"integer"}, + "meta":{"type":"object"},"acf":{"type":"object"} + },"required":["id"],"additionalProperties":true}), output: json!({"type":"object"}), }, SchemaEntry { @@ -114,6 +150,28 @@ fn schemas() -> Vec { input: json!({"type":"object","properties":{"id":{"type":"integer"}},"required":["id"]}), output: json!({"type":"object"}), }, + SchemaEntry { + command: "media upload", + description: "Upload a local file to the media library (multipart POST wp/v2/media)", + input: json!({"type":"object","properties":{ + "file":{"type":"string","description":"Local file path"}, + "title":{"type":"string"},"alt_text":{"type":"string"},"caption":{"type":"string"}, + "description":{"type":"string"},"post":{"type":"integer","description":"Attach to post ID"}, + "mime_type":{"type":"string","description":"Override the MIME type guessed from the extension"} + },"required":["file"]}), + output: json!({"type":"object","properties":{"id":{"type":"integer"},"source_url":{"type":"string"},"mime_type":{"type":"string"}}}), + }, + SchemaEntry { + command: "api", + description: "Call any REST route under /wp-json/ (escape hatch for routes without a typed command)", + input: json!({"type":"object","properties":{ + "method":{"type":"string","enum":["GET","POST","PUT","PATCH","DELETE","HEAD","OPTIONS"],"default":"GET"}, + "path":{"type":"string","description":"Route relative to /wp-json/, e.g. wp/v2/types/blog or rankmath/v1/updateMeta"}, + "query":{"type":"object","description":"Query parameters (CLI: repeatable --query key=value)"}, + "body":{"description":"JSON request body (CLI: --data '' or --json for stdin)"} + },"required":["path"]}), + output: json!({"description":"Raw JSON response from WordPress"}), + }, SchemaEntry { command: "user list", description: "List users", diff --git a/crates/wpx-cli/src/context.rs b/crates/wpx-cli/src/context.rs index a106850..7247d60 100644 --- a/crates/wpx-cli/src/context.rs +++ b/crates/wpx-cli/src/context.rs @@ -19,8 +19,8 @@ pub fn build_client(global: &GlobalFlags) -> Result { } else { return Err(WpxError::Config { message: format!( - "Site '{}' not found. Use --url to specify a URL or configure the site with 'wpx auth set'.", - global.site + "Site '{}' not found. Use --url (or WPX_URL) to specify a URL, or add a [sites.{}] profile to ~/.config/wpx/config.toml or ./.wpx.toml.", + global.site, global.site ), }); }; @@ -29,8 +29,9 @@ pub fn build_client(global: &GlobalFlags) -> Result { message: format!("Invalid URL '{site_url}': {e}"), })?; - // Build auth provider based on credential type - let auth: Box = if let Some(creds) = store.get(&global.site) { + // Build auth provider based on credential type. + // `resolve` prefers WPX_USERNAME / WPX_PASSWORD env vars over credentials.toml. + let auth: Box = if let Some(creds) = store.resolve(&global.site) { match creds.auth_type.as_str() { "oauth2" => { if let Some(token) = &creds.access_token { diff --git a/crates/wpx-cli/src/crud.rs b/crates/wpx-cli/src/crud.rs index cf7c0ad..b93ab76 100644 --- a/crates/wpx-cli/src/crud.rs +++ b/crates/wpx-cli/src/crud.rs @@ -1,6 +1,7 @@ use serde::Serialize; use serde_json::json; use wpx_api::WpClient; +use wpx_core::resources::post_type::PostType; use wpx_core::{Resource, WpxError}; use wpx_output::RenderPayload; @@ -41,12 +42,79 @@ pub fn to_query_params(params: &T) -> Vec<(String, String)> { result } +/// Resolve the REST collection path for a post type slug. +/// +/// - `rest_base` (from `--rest-base`) wins and skips any lookup. A bare value such as +/// `blog` is namespaced under `wp/v2/`; a value containing `/` is used as-is. +/// - `None` or `post` → `wp/v2/posts`, `page` → `wp/v2/pages`, `attachment` → `wp/v2/media` +/// (no network round-trip for core types). +/// - Any other slug → `GET wp/v2/types/{slug}` and `{rest_namespace}/{rest_base}`. +/// +/// Errors with `NotFound` when the type does not exist or is not exposed in REST. +pub async fn resolve_post_type_path( + client: &WpClient, + post_type: Option<&str>, + rest_base: Option<&str>, +) -> Result { + if let Some(base) = rest_base { + let base = base.trim().trim_matches('/'); + if base.is_empty() { + return Err(WpxError::Validation { + field: "rest_base".into(), + message: "--rest-base must not be empty".into(), + }); + } + return Ok(if base.contains('/') { + base.to_string() + } else { + format!("wp/v2/{base}") + }); + } + + match post_type.map(str::trim) { + None | Some("") | Some("post") | Some("posts") => Ok("wp/v2/posts".to_string()), + Some("page") | Some("pages") => Ok("wp/v2/pages".to_string()), + Some("attachment") | Some("media") => Ok("wp/v2/media".to_string()), + Some(slug) => { + let path = format!("wp/v2/types/{slug}"); + let response: wpx_api::ApiResponse = + client.get(&path, &[]).await.map_err(|e| match e { + WpxError::NotFound { .. } => WpxError::NotFound { + resource: "post type".into(), + id: slug.to_string(), + }, + other => other, + })?; + let rest_base = response + .data + .rest_base + .filter(|b| !b.is_empty()) + .ok_or_else(|| WpxError::NotFound { + resource: "post type (not exposed in REST)".into(), + id: slug.to_string(), + })?; + let namespace = response + .data + .rest_namespace + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| "wp/v2".to_string()); + Ok(format!( + "{}/{}", + namespace.trim_matches('/'), + rest_base.trim_matches('/') + )) + } + } +} + /// Auto-paginating list that streams results as NDJSON to stdout. /// -/// Fetches all pages (100 items per page) and writes each item -/// as a single JSON line immediately, without buffering the full result. -pub async fn list_all_pages( +/// Fetches all pages (100 items per page) from `api_path` (use `R::API_PATH` for the +/// resource's default collection) and writes each item as a single JSON line +/// immediately, without buffering the full result. +pub async fn list_all_pages_at( client: &WpClient, + api_path: &str, params: &impl Serialize, ) -> Result { use std::io::Write; @@ -64,7 +132,7 @@ pub async fn list_all_pages( .collect(); // First page - let response: wpx_api::ApiResponse> = client.get(R::API_PATH, &query_refs).await?; + let response: wpx_api::ApiResponse> = client.get(api_path, &query_refs).await?; let total_pages = response.total_pages.unwrap_or(1); let _total = response.total.unwrap_or(0); let mut count = 0u64; @@ -93,8 +161,7 @@ pub async fn list_all_pages( .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let page_response: wpx_api::ApiResponse> = - client.get(R::API_PATH, &page_refs).await?; + let page_response: wpx_api::ApiResponse> = client.get(api_path, &page_refs).await?; for item in &page_response.data { let line = serde_json::to_string(item).map_err(|e| WpxError::Other(e.to_string()))?; @@ -118,6 +185,15 @@ pub async fn list_all_pages( pub async fn list( client: &WpClient, params: &impl Serialize, +) -> Result { + list_at::(client, R::API_PATH, params).await +} + +/// [`list`] against an explicit collection path (e.g. a custom post type). +pub async fn list_at( + client: &WpClient, + api_path: &str, + params: &impl Serialize, ) -> Result { let query = to_query_params(params); let query_refs: Vec<(&str, &str)> = query @@ -125,7 +201,7 @@ pub async fn list( .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let response: wpx_api::ApiResponse> = client.get(R::API_PATH, &query_refs).await?; + let response: wpx_api::ApiResponse> = client.get(api_path, &query_refs).await?; let data = serde_json::to_value(&response.data).map_err(|e| WpxError::Other(e.to_string()))?; @@ -138,8 +214,19 @@ pub async fn list( /// Generic get-by-ID handler for any Resource. pub async fn get(client: &WpClient, id: u64) -> Result { - let path = format!("{}/{id}", R::API_PATH); - let response: wpx_api::ApiResponse = client.get(&path, &[]).await?; + get_at::(client, R::API_PATH, id, &[]).await +} + +/// [`get`] against an explicit collection path, with extra query params +/// (e.g. `("context", "edit")` to receive `content.raw`). +pub async fn get_at( + client: &WpClient, + api_path: &str, + id: u64, + params: &[(&str, &str)], +) -> Result { + let path = format!("{api_path}/{id}"); + let response: wpx_api::ApiResponse = client.get(&path, params).await?; let data = serde_json::to_value(&response.data).map_err(|e| WpxError::Other(e.to_string()))?; @@ -154,6 +241,16 @@ pub async fn create( client: &WpClient, body: &impl Serialize, dry_run: bool, +) -> Result { + create_at::(client, R::API_PATH, body, dry_run).await +} + +/// [`create`] against an explicit collection path (e.g. a custom post type). +pub async fn create_at( + client: &WpClient, + api_path: &str, + body: &impl Serialize, + dry_run: bool, ) -> Result { if dry_run { let body_value = serde_json::to_value(body).map_err(|e| WpxError::Other(e.to_string()))?; @@ -162,13 +259,14 @@ pub async fn create( "dry_run": true, "action": "create", "resource": R::NAME, + "path": api_path, "would_create": body_value, }), summary: None, }); } - let response: wpx_api::ApiResponse = client.post(R::API_PATH, body).await?; + let response: wpx_api::ApiResponse = client.post(api_path, body).await?; let data = serde_json::to_value(&response.data).map_err(|e| WpxError::Other(e.to_string()))?; @@ -184,6 +282,17 @@ pub async fn update( id: u64, body: &impl Serialize, dry_run: bool, +) -> Result { + update_at::(client, R::API_PATH, id, body, dry_run).await +} + +/// [`update`] against an explicit collection path (e.g. a custom post type). +pub async fn update_at( + client: &WpClient, + api_path: &str, + id: u64, + body: &impl Serialize, + dry_run: bool, ) -> Result { if dry_run { let body_value = serde_json::to_value(body).map_err(|e| WpxError::Other(e.to_string()))?; @@ -192,6 +301,7 @@ pub async fn update( "dry_run": true, "action": "update", "resource": R::NAME, + "path": api_path, "id": id, "would_update": body_value, }), @@ -199,7 +309,7 @@ pub async fn update( }); } - let path = format!("{}/{id}", R::API_PATH); + let path = format!("{api_path}/{id}"); let response: wpx_api::ApiResponse = client.post(&path, body).await?; let data = serde_json::to_value(&response.data).map_err(|e| WpxError::Other(e.to_string()))?; @@ -217,8 +327,20 @@ pub async fn delete( force: bool, dry_run: bool, ) -> Result { + delete_at::(client, R::API_PATH, id, force, dry_run).await +} + +/// [`delete`] against an explicit collection path (e.g. a custom post type). +pub async fn delete_at( + client: &WpClient, + api_path: &str, + id: u64, + force: bool, + dry_run: bool, +) -> Result { + let path = format!("{api_path}/{id}"); + if dry_run { - let path = format!("{}/{id}", R::API_PATH); let existing: Result, _> = client.get(&path, &[]).await; let would_delete = existing .ok() @@ -229,6 +351,7 @@ pub async fn delete( "dry_run": true, "action": "delete", "resource": R::NAME, + "path": api_path, "id": id, "force": force, "would_delete": would_delete, @@ -237,7 +360,6 @@ pub async fn delete( }); } - let path = format!("{}/{id}", R::API_PATH); let params = if force { vec![("force", "true")] } else { @@ -301,6 +423,9 @@ pub async fn get_by_slug( #[cfg(test)] mod tests { use super::*; + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + use wpx_core::resources::post::Post; #[derive(Debug, Serialize)] struct TestParams { @@ -309,6 +434,16 @@ mod tests { search: Option, } + fn client_for(server: &MockServer) -> WpClient { + WpClient::new( + url::Url::parse(&server.uri()).unwrap(), + Box::new(wpx_auth::NoAuth), + 5, + 0, + ) + .unwrap() + } + #[test] fn query_params_skips_none() { let params = TestParams { @@ -347,4 +482,151 @@ mod tests { let result = to_query_params(¶ms); assert!(result.is_empty()); } + + #[tokio::test] + async fn resolve_core_types_without_network() { + let server = MockServer::start().await; + let client = client_for(&server); + assert_eq!( + resolve_post_type_path(&client, None, None).await.unwrap(), + "wp/v2/posts" + ); + assert_eq!( + resolve_post_type_path(&client, Some("post"), None) + .await + .unwrap(), + "wp/v2/posts" + ); + assert_eq!( + resolve_post_type_path(&client, Some("page"), None) + .await + .unwrap(), + "wp/v2/pages" + ); + assert_eq!( + resolve_post_type_path(&client, Some("attachment"), None) + .await + .unwrap(), + "wp/v2/media" + ); + // --rest-base skips lookup entirely + assert_eq!( + resolve_post_type_path(&client, Some("whatever"), Some("blog")) + .await + .unwrap(), + "wp/v2/blog" + ); + assert_eq!( + resolve_post_type_path(&client, None, Some("/wc/v3/products/")) + .await + .unwrap(), + "wc/v3/products" + ); + // No requests were made (server received nothing) + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn resolve_custom_type_via_types_endpoint() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/wp-json/wp/v2/types/blog")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "slug": "blog", "name": "Blog", "rest_base": "blog", "rest_namespace": "wp/v2" + }))) + .expect(1) + .mount(&server) + .await; + let client = client_for(&server); + assert_eq!( + resolve_post_type_path(&client, Some("blog"), None) + .await + .unwrap(), + "wp/v2/blog" + ); + } + + #[tokio::test] + async fn resolve_type_without_rest_base_is_not_found() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/wp-json/wp/v2/types/hidden")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "slug": "hidden", "name": "Hidden", "rest_base": null + }))) + .mount(&server) + .await; + let client = client_for(&server); + let err = resolve_post_type_path(&client, Some("hidden"), None) + .await + .unwrap_err(); + assert!(matches!(err, WpxError::NotFound { .. }), "{err:?}"); + } + + #[tokio::test] + async fn get_at_passes_context_param_and_keeps_extra_fields() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/wp-json/wp/v2/blog/6166")) + .and(query_param("context", "edit")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": 6166, + "type": "blog", + "title": {"rendered": "T", "raw": "T"}, + "content": {"rendered": "

x

", "raw": "

x

"}, + "blog_category": [9], + "template": "", + "acf": [] + }))) + .expect(1) + .mount(&server) + .await; + let client = client_for(&server); + let payload = get_at::(&client, "wp/v2/blog", 6166, &[("context", "edit")]) + .await + .unwrap(); + assert_eq!(payload.data["id"], 6166); + assert_eq!(payload.data["blog_category"], serde_json::json!([9])); + assert!(payload.data["content"]["raw"] + .as_str() + .unwrap() + .starts_with("")); + } + + #[tokio::test] + async fn create_at_dry_run_reports_path() { + let server = MockServer::start().await; + let client = client_for(&server); + let body = serde_json::json!({"title": "x", "blog_category": [9]}); + let payload = create_at::(&client, "wp/v2/blog", &body, true) + .await + .unwrap(); + assert_eq!(payload.data["dry_run"], true); + assert_eq!(payload.data["path"], "wp/v2/blog"); + assert_eq!( + payload.data["would_create"]["blog_category"], + serde_json::json!([9]) + ); + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn create_at_posts_to_custom_path() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/wp-json/wp/v2/blog")) + .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({ + "id": 7000, "type": "blog", "status": "draft" + }))) + .expect(1) + .mount(&server) + .await; + let client = client_for(&server); + let body = serde_json::json!({"title": "x", "status": "draft"}); + let payload = create_at::(&client, "wp/v2/blog", &body, false) + .await + .unwrap(); + assert_eq!(payload.data["id"], 7000); + assert_eq!(payload.summary.as_deref(), Some("post created")); + } } diff --git a/crates/wpx-cli/src/dispatch.rs b/crates/wpx-cli/src/dispatch.rs index fb33b6e..d37f252 100644 --- a/crates/wpx-cli/src/dispatch.rs +++ b/crates/wpx-cli/src/dispatch.rs @@ -16,27 +16,44 @@ pub async fn dispatch( dry_run: bool, ) -> Result { match command_path { - // Posts + // Posts (honour `type` / `rest_base` in args for custom post types) ["post", "list"] => { + let path = post_type_path(client, args).await?; let params: crate::commands::post::PostListArgs = serde_json::from_value(args.clone()).unwrap_or_default(); - crate::crud::list::(client, ¶ms).await + crate::crud::list_at::(client, &path, ¶ms).await } ["post", "get"] => { + let path = post_type_path(client, args).await?; let id = args_id(args)?; - crate::crud::get::(client, id).await + let context = args.get("context").and_then(|v| v.as_str()); + let params: Vec<(&str, &str)> = + context.map(|c| vec![("context", c)]).unwrap_or_default(); + crate::crud::get_at::(client, &path, id, ¶ms).await } ["post", "create"] => { - crate::crud::create::(client, args, dry_run).await + let path = post_type_path(client, args).await?; + let body = strip_routing_keys(args); + crate::crud::create_at::(client, &path, &body, dry_run) + .await } ["post", "update"] => { + let path = post_type_path(client, args).await?; let id = args_id(args)?; - crate::crud::update::(client, id, args, dry_run).await + let body = strip_routing_keys(args); + crate::crud::update_at::( + client, &path, id, &body, dry_run, + ) + .await } ["post", "delete"] => { + let path = post_type_path(client, args).await?; let id = args_id(args)?; let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false); - crate::crud::delete::(client, id, force, dry_run).await + crate::crud::delete_at::( + client, &path, id, force, dry_run, + ) + .await } // Pages @@ -112,6 +129,42 @@ pub async fn dispatch( let id = args_id(args)?; crate::crud::get::(client, id).await } + ["media", "upload"] => { + let upload_args: crate::commands::media::MediaUploadArgs = + serde_json::from_value(args.clone()).map_err(|e| WpxError::Validation { + field: "file".into(), + message: format!("media upload needs a 'file' argument: {e}"), + })?; + commands::media::upload(&upload_args, client, dry_run).await + } + + // Raw REST escape hatch: {"method": "GET", "path": "wp/v2/...", "query": {..}, "body": {..}} + ["api"] => { + let method = args + .get("method") + .and_then(|v| v.as_str()) + .unwrap_or("GET") + .to_ascii_uppercase(); + let path = args_str(args, "path")?; + let path = path.trim().trim_start_matches('/').to_string(); + let query: Vec<(String, String)> = args + .get("query") + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .map(|(k, v)| { + let value = match v { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + (k.clone(), value) + }) + .collect() + }) + .unwrap_or_default(); + let body = args.get("body").or_else(|| args.get("data")); + commands::api::execute(client, &method, &path, &query, body, dry_run).await + } // Plugins ["plugin", "list"] => { @@ -281,6 +334,30 @@ pub async fn dispatch( } } +/// Resolve the collection path for post commands from `type` / `rest_base` args. +async fn post_type_path(client: &WpClient, args: &Value) -> Result { + let post_type = args.get("type").and_then(|v| v.as_str()); + let rest_base = args.get("rest_base").and_then(|v| v.as_str()); + crate::crud::resolve_post_type_path(client, post_type, rest_base).await +} + +/// Remove routing-only keys (`type`, `rest_base`, `id`) before sending a body. +/// +/// `type` is read-only in the REST schema and `id` on create triggers +/// `rest_post_exists`, so neither may leak into the request body. +fn strip_routing_keys(args: &Value) -> Value { + match args { + Value::Object(map) => { + let mut body = map.clone(); + body.remove("type"); + body.remove("rest_base"); + body.remove("id"); + Value::Object(body) + } + other => other.clone(), + } +} + /// Extract a numeric ID from args. fn args_id(args: &Value) -> Result { args.get("id") @@ -323,4 +400,90 @@ mod tests { let args = json!({"id": 1}); assert!(args_str(&args, "slug").is_err()); } + + #[test] + fn strip_routing_keys_removes_type_rest_base_and_id() { + let args = + json!({"id": 5, "type": "blog", "rest_base": "blog", "title": "x", "acf": {"a": 1}}); + let body = strip_routing_keys(&args); + assert_eq!(body, json!({"title": "x", "acf": {"a": 1}})); + } + + fn client_for(server: &wiremock::MockServer) -> WpClient { + WpClient::new( + url::Url::parse(&server.uri()).unwrap(), + Box::new(wpx_auth::NoAuth), + 5, + 0, + ) + .unwrap() + } + + #[tokio::test] + async fn dispatch_api_get() { + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/wp-json/wp/v2/types/blog")) + .and(query_param("context", "view")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"rest_base": "blog"}))) + .expect(1) + .mount(&server) + .await; + + let args = + json!({"method": "get", "path": "/wp/v2/types/blog", "query": {"context": "view"}}); + let payload = dispatch(&["api"], &args, &client_for(&server), false) + .await + .unwrap(); + assert_eq!(payload.data["rest_base"], "blog"); + } + + #[tokio::test] + async fn dispatch_api_post_dry_run() { + let server = wiremock::MockServer::start().await; + let args = json!({"method": "POST", "path": "wp/v2/posts", "body": {"title": "x"}}); + let payload = dispatch(&["api"], &args, &client_for(&server), true) + .await + .unwrap(); + assert_eq!(payload.data["dry_run"], true); + assert_eq!(payload.data["body"]["title"], "x"); + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn dispatch_post_list_routes_custom_type() { + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/wp-json/wp/v2/types/blog")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "slug": "blog", "rest_base": "blog", "rest_namespace": "wp/v2" + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/wp-json/wp/v2/blog")) + .and(query_param("per_page", "1")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("x-wp-total", "93") + .set_body_json(json!([{"id": 6166, "type": "blog", "blog_category": [9]}])), + ) + .expect(1) + .mount(&server) + .await; + + let args = json!({"type": "blog", "per_page": 1}); + let payload = dispatch(&["post", "list"], &args, &client_for(&server), false) + .await + .unwrap(); + assert_eq!(payload.data[0]["id"], 6166); + assert_eq!(payload.data[0]["blog_category"], json!([9])); + assert_eq!(payload.summary.as_deref(), Some("93 posts found")); + } } diff --git a/crates/wpx-cli/src/input.rs b/crates/wpx-cli/src/input.rs new file mode 100644 index 0000000..e534ae0 --- /dev/null +++ b/crates/wpx-cli/src/input.rs @@ -0,0 +1,137 @@ +//! Helpers for reading request bodies and content from files / stdin. + +use wpx_core::WpxError; + +/// Read a JSON payload from stdin and deserialize it. +pub fn json_from_stdin() -> Result { + let stdin = std::io::read_to_string(std::io::stdin()) + .map_err(|e| WpxError::Other(format!("Failed to read stdin: {e}")))?; + serde_json::from_str(&stdin).map_err(|e| WpxError::Validation { + field: "json".into(), + message: format!("Invalid JSON input: {e}"), + }) +} + +/// Resolve post content from `--content` or `--content-file`. +/// +/// `--content-file -` reads stdin. Returns `Ok(None)` when neither was given. +/// `reading_stdin_json` guards against both `--json` and `--content-file -` +/// competing for stdin. +pub fn resolve_content( + content: Option<&str>, + content_file: Option<&str>, + reading_stdin_json: bool, +) -> Result, WpxError> { + match (content, content_file) { + (Some(_), Some(_)) => Err(WpxError::Validation { + field: "content".into(), + message: "--content and --content-file are mutually exclusive".into(), + }), + (Some(c), None) => Ok(Some(c.to_string())), + (None, Some("-")) => { + if reading_stdin_json { + return Err(WpxError::Validation { + field: "content_file".into(), + message: "--content-file - cannot be combined with --json (both read stdin)" + .into(), + }); + } + let text = std::io::read_to_string(std::io::stdin()) + .map_err(|e| WpxError::Other(format!("Failed to read stdin: {e}")))?; + Ok(Some(text)) + } + (None, Some(path)) => { + let text = std::fs::read_to_string(path).map_err(|e| WpxError::Validation { + field: "content_file".into(), + message: format!("Cannot read '{path}': {e}"), + })?; + Ok(Some(text)) + } + (None, None) => Ok(None), + } +} + +/// Parse repeated `key=value` arguments into query pairs. +pub fn parse_key_values(items: &[String]) -> Result, WpxError> { + items + .iter() + .map(|item| { + let (k, v) = item.split_once('=').ok_or_else(|| WpxError::Validation { + field: "query".into(), + message: format!("Expected key=value, got '{item}'"), + })?; + if k.trim().is_empty() { + return Err(WpxError::Validation { + field: "query".into(), + message: format!("Empty key in '{item}'"), + }); + } + Ok((k.trim().to_string(), v.to_string())) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn content_inline_wins_when_only_content_given() { + assert_eq!( + resolve_content(Some("

x

"), None, false).unwrap(), + Some("

x

".into()) + ); + } + + #[test] + fn content_and_file_conflict() { + let err = resolve_content(Some("a"), Some("b.html"), false).unwrap_err(); + assert!(matches!(err, WpxError::Validation { .. })); + } + + #[test] + fn content_file_reads_from_disk() { + let dir = std::env::temp_dir().join(format!("wpx-input-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("body.html"); + std::fs::write( + &file, + "

hi

", + ) + .unwrap(); + let got = resolve_content(None, Some(file.to_str().unwrap()), false).unwrap(); + assert!(got.unwrap().starts_with("")); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn content_file_missing_is_validation_error() { + let err = resolve_content(None, Some("/definitely/missing.html"), false).unwrap_err(); + assert!(matches!(err, WpxError::Validation { .. })); + } + + #[test] + fn stdin_conflict_with_json() { + let err = resolve_content(None, Some("-"), true).unwrap_err(); + assert!(matches!(err, WpxError::Validation { .. })); + } + + #[test] + fn none_when_nothing_given() { + assert_eq!(resolve_content(None, None, false).unwrap(), None); + } + + #[test] + fn key_values_parse() { + let got = parse_key_values(&["context=edit".into(), "per_page=5".into()]).unwrap(); + assert_eq!( + got, + vec![ + ("context".to_string(), "edit".to_string()), + ("per_page".to_string(), "5".to_string()) + ] + ); + assert!(parse_key_values(&["novalue".into()]).is_err()); + assert!(parse_key_values(&["=x".into()]).is_err()); + } +} diff --git a/crates/wpx-cli/src/main.rs b/crates/wpx-cli/src/main.rs index f51ba35..f11ca88 100644 --- a/crates/wpx-cli/src/main.rs +++ b/crates/wpx-cli/src/main.rs @@ -3,6 +3,7 @@ mod commands; mod context; mod crud; pub mod dispatch; +mod input; use clap::{CommandFactory, Parser}; use cli::{Cli, Commands}; @@ -132,6 +133,10 @@ async fn run(cli: &Cli) -> Result { let client = context::build_client(&cli.global)?; commands::search::handle(query, args, &client).await } + Commands::Api(args) => { + let client = context::build_client(&cli.global)?; + commands::api::handle(args, &client, cli.global.dry_run).await + } Commands::Settings { command } | Commands::Option { command } => { let client = context::build_client(&cli.global)?; commands::settings::handle(command, &client, cli.global.dry_run).await diff --git a/crates/wpx-config/src/credentials.rs b/crates/wpx-config/src/credentials.rs index cb306c1..21913ca 100644 --- a/crates/wpx-config/src/credentials.rs +++ b/crates/wpx-config/src/credentials.rs @@ -56,7 +56,47 @@ impl Default for SiteCredentials { } } +/// Environment variable holding the username for `WPX_PASSWORD` / `WPX_APP_PASSWORD`. +pub const ENV_USERNAME: &str = "WPX_USERNAME"; +/// Environment variable holding an application password (spaces are tolerated). +pub const ENV_PASSWORD: &str = "WPX_PASSWORD"; +/// Alias for [`ENV_PASSWORD`], mirroring the WordPress UI terminology. +pub const ENV_APP_PASSWORD: &str = "WPX_APP_PASSWORD"; + +impl SiteCredentials { + /// Build application-password credentials from `WPX_USERNAME` and + /// `WPX_PASSWORD` (or `WPX_APP_PASSWORD`). + /// + /// Returns `None` unless both a username and a password are present. Intended for + /// CI and agent environments where writing `credentials.toml` is undesirable. + pub fn from_env() -> Option { + let username = std::env::var(ENV_USERNAME).ok().filter(|v| !v.is_empty())?; + let password = std::env::var(ENV_PASSWORD) + .ok() + .filter(|v| !v.is_empty()) + .or_else(|| { + std::env::var(ENV_APP_PASSWORD) + .ok() + .filter(|v| !v.is_empty()) + })?; + Some(Self { + username, + password, + ..Default::default() + }) + } +} + impl CredentialStore { + /// Resolve credentials for a site: environment variables win over the store. + /// + /// Precedence follows the global rule (env vars > config files): if + /// `WPX_USERNAME` and `WPX_PASSWORD`/`WPX_APP_PASSWORD` are set they are used for + /// every site, otherwise the stored credentials for `site` (if any) are returned. + pub fn resolve(&self, site: &str) -> Option { + SiteCredentials::from_env().or_else(|| self.get(site).cloned()) + } + /// Load credentials from the credentials file. pub fn load() -> Self { let path = match super::WpxConfig::credentials_path() { @@ -137,6 +177,66 @@ mod tests { assert!(store.get("prod").is_none()); } + #[test] + fn resolve_prefers_store_when_env_unset() { + // Serialise access to process env across tests in this module. + let _guard = ENV_LOCK.lock().unwrap(); + std::env::remove_var(ENV_USERNAME); + std::env::remove_var(ENV_PASSWORD); + std::env::remove_var(ENV_APP_PASSWORD); + + let mut store = CredentialStore::default(); + store.set( + "prod".into(), + SiteCredentials { + username: "stored".into(), + password: "pw".into(), + ..Default::default() + }, + ); + assert_eq!(store.resolve("prod").unwrap().username, "stored"); + assert!(store.resolve("missing").is_none()); + } + + #[test] + fn resolve_prefers_env_over_store() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::set_var(ENV_USERNAME, "ci-bot"); + std::env::remove_var(ENV_PASSWORD); + std::env::set_var(ENV_APP_PASSWORD, "abcd efgh"); + + let mut store = CredentialStore::default(); + store.set( + "prod".into(), + SiteCredentials { + username: "stored".into(), + password: "pw".into(), + ..Default::default() + }, + ); + let creds = store.resolve("prod").unwrap(); + assert_eq!(creds.username, "ci-bot"); + assert_eq!(creds.password, "abcd efgh"); + assert_eq!(creds.auth_type, "application-password"); + // Env credentials apply even when the site has no stored entry. + assert_eq!(store.resolve("other").unwrap().username, "ci-bot"); + + std::env::remove_var(ENV_USERNAME); + std::env::remove_var(ENV_APP_PASSWORD); + } + + #[test] + fn from_env_requires_both_values() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::set_var(ENV_USERNAME, "ci-bot"); + std::env::remove_var(ENV_PASSWORD); + std::env::remove_var(ENV_APP_PASSWORD); + assert!(SiteCredentials::from_env().is_none()); + std::env::remove_var(ENV_USERNAME); + } + + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] fn credential_store_serialization() { let mut store = CredentialStore::default(); diff --git a/crates/wpx-config/src/lib.rs b/crates/wpx-config/src/lib.rs index d3171a9..17eb5d2 100644 --- a/crates/wpx-config/src/lib.rs +++ b/crates/wpx-config/src/lib.rs @@ -3,5 +3,7 @@ pub mod credentials; pub mod profile; pub use config::WpxConfig; -pub use credentials::{CredentialStore, SiteCredentials}; +pub use credentials::{ + CredentialStore, SiteCredentials, ENV_APP_PASSWORD, ENV_PASSWORD, ENV_USERNAME, +}; pub use profile::SiteProfile; diff --git a/crates/wpx-core/src/resources/page.rs b/crates/wpx-core/src/resources/page.rs index 5ebbf1b..dc5e29c 100644 --- a/crates/wpx-core/src/resources/page.rs +++ b/crates/wpx-core/src/resources/page.rs @@ -20,6 +20,17 @@ pub struct Page { pub menu_order: Option, #[serde(rename = "type")] pub post_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub template: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub featured_media: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acf: Option, + /// Any additional fields returned by WordPress, preserved verbatim. + #[serde(flatten)] + pub extra: serde_json::Map, } impl Resource for Page { @@ -49,6 +60,39 @@ pub struct PageCreateParams { pub parent: Option, #[serde(skip_serializing_if = "Option::is_none")] pub menu_order: Option, + /// Page template file name (e.g. `template-services.php`). + #[serde(skip_serializing_if = "Option::is_none")] + pub template: Option, + /// Featured image attachment ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub featured_media: Option, + /// Post meta object (keys must be registered with `show_in_rest`). + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// ACF fields object (field groups exposed with `show_in_rest`). + #[serde(skip_serializing_if = "Option::is_none")] + pub acf: Option, + /// Any other keys, passed through to WordPress verbatim. + #[serde(flatten)] + pub extra: serde_json::Map, } pub type PageUpdateParams = PageCreateParams; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_params_round_trip() { + let input = serde_json::json!({ + "title": "x", + "template": "template-services.php", + "acf": {"hero": {"title": "Hi"}}, + "meta": {"rank_math_title": "SEO"}, + "custom_key": "kept" + }); + let params: PageCreateParams = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(¶ms).unwrap(), input); + } +} diff --git a/crates/wpx-core/src/resources/post.rs b/crates/wpx-core/src/resources/post.rs index 03d91a4..f12444e 100644 --- a/crates/wpx-core/src/resources/post.rs +++ b/crates/wpx-core/src/resources/post.rs @@ -21,6 +21,20 @@ pub struct Post { pub sticky: Option, pub categories: Option>, pub tags: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub template: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub featured_media: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acf: Option, + /// Any additional fields returned by WordPress (custom taxonomies, plugin fields, ...). + /// + /// Custom post types expose their own keys (e.g. `blog_category`), which are preserved + /// here so `--fields` masks and JSON output never silently drop data. + #[serde(flatten)] + pub extra: serde_json::Map, } /// WordPress rendered content with raw and rendered variants. @@ -62,6 +76,24 @@ pub struct PostCreateParams { pub categories: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub tags: Option>, + /// Page/post template file name (e.g. `template-landing.php`). + #[serde(skip_serializing_if = "Option::is_none")] + pub template: Option, + /// Featured image attachment ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub featured_media: Option, + /// Post meta object (keys must be registered with `show_in_rest`). + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// ACF fields object (field groups exposed with `show_in_rest`). + #[serde(skip_serializing_if = "Option::is_none")] + pub acf: Option, + /// Any other keys (custom taxonomies such as `blog_category`, plugin fields, ...). + /// + /// Captured with `#[serde(flatten)]` so a `--json` payload is passed through to + /// WordPress verbatim instead of silently dropping unknown keys. + #[serde(flatten)] + pub extra: serde_json::Map, } /// Parameters for updating a post (same as create). @@ -103,4 +135,48 @@ mod tests { assert_eq!(Post::NAME, "post"); assert_eq!(Post::API_PATH, "wp/v2/posts"); } + + #[test] + fn create_params_preserve_unknown_keys() { + let input = serde_json::json!({ + "title": "x", + "blog_category": [9], + "acf": {"a": 1}, + "template": "t.php", + "featured_media": 6168, + "meta": {"rank_math_title": "SEO"} + }); + let params: PostCreateParams = serde_json::from_value(input.clone()).unwrap(); + assert_eq!(params.title.as_deref(), Some("x")); + assert_eq!(params.template.as_deref(), Some("t.php")); + assert_eq!(params.featured_media, Some(6168)); + assert_eq!(params.acf, Some(serde_json::json!({"a": 1}))); + assert_eq!( + params.extra.get("blog_category"), + Some(&serde_json::json!([9])) + ); + + let output = serde_json::to_value(¶ms).unwrap(); + assert_eq!(output, input); + } + + #[test] + fn create_params_default_serializes_empty_object() { + let params = PostCreateParams::default(); + assert_eq!( + serde_json::to_value(¶ms).unwrap(), + serde_json::json!({}) + ); + } + + #[test] + fn deserialize_post_keeps_custom_taxonomy() { + let json = r#"{"id": 7, "type": "blog", "blog_category": [9], "acf": []}"#; + let post: Post = serde_json::from_str(json).unwrap(); + assert_eq!( + post.extra.get("blog_category"), + Some(&serde_json::json!([9])) + ); + assert_eq!(post.acf, Some(serde_json::json!([]))); + } } diff --git a/crates/wpx-output/src/fields.rs b/crates/wpx-output/src/fields.rs index 99f3332..62049c2 100644 --- a/crates/wpx-output/src/fields.rs +++ b/crates/wpx-output/src/fields.rs @@ -1,8 +1,11 @@ -use serde_json::Value; +use serde_json::{Map, Value}; /// Apply a field mask to a JSON value. /// -/// For objects: retains only the specified keys. +/// For objects: retains only the specified keys. A dotted field such as +/// `content.raw` keeps `content` but masks it down to `raw`; several dotted +/// fields under the same key are merged, and a bare `content` keeps the whole +/// value. /// For arrays: applies the mask to each element. /// For other types: returns as-is. pub fn apply_field_mask(value: Value, fields: &[String]) -> Value { @@ -13,10 +16,22 @@ pub fn apply_field_mask(value: Value, fields: &[String]) -> Value { .collect(), ), Value::Object(map) => { - let filtered = map - .into_iter() - .filter(|(k, _)| fields.iter().any(|f| f == k)) - .collect(); + let mut filtered = Map::new(); + for (key, value) in map { + if fields.iter().any(|f| f == &key) { + filtered.insert(key, value); + continue; + } + let nested: Vec = fields + .iter() + .filter_map(|f| f.strip_prefix(key.as_str())) + .filter_map(|rest| rest.strip_prefix('.')) + .map(str::to_string) + .collect(); + if !nested.is_empty() { + filtered.insert(key, apply_field_mask(value, &nested)); + } + } Value::Object(filtered) } other => other, @@ -28,11 +43,16 @@ mod tests { use super::*; use serde_json::json; + fn mask(value: Value, fields: &[&str]) -> Value { + let fields: Vec = fields.iter().map(|f| f.to_string()).collect(); + apply_field_mask(value, &fields) + } + #[test] fn filter_object_fields() { let value = json!({"id": 1, "title": "Hello", "status": "publish", "content": "

...

"}); - let filtered = apply_field_mask(value, &["id".into(), "title".into()]); + let filtered = mask(value, &["id", "title"]); assert_eq!(filtered, json!({"id": 1, "title": "Hello"})); } @@ -42,7 +62,7 @@ mod tests { {"id": 1, "title": "Post 1", "status": "publish"}, {"id": 2, "title": "Post 2", "status": "draft"}, ]); - let filtered = apply_field_mask(value, &["id".into(), "title".into()]); + let filtered = mask(value, &["id", "title"]); assert_eq!( filtered, json!([ @@ -55,14 +75,63 @@ mod tests { #[test] fn filter_preserves_non_objects() { let value = json!("hello"); - let filtered = apply_field_mask(value.clone(), &["id".into()]); + let filtered = mask(value.clone(), &["id"]); assert_eq!(filtered, value); } #[test] fn filter_empty_fields_returns_empty_object() { let value = json!({"id": 1, "title": "Hello"}); - let filtered = apply_field_mask(value, &[]); + let filtered = mask(value, &[]); assert_eq!(filtered, json!({})); } + + #[test] + fn filter_nested_field_path() { + let value = json!({ + "id": 1, + "content": {"raw": "", "rendered": "

", "protected": false}, + "title": {"rendered": "T"} + }); + let filtered = mask(value, &["id", "content.raw"]); + assert_eq!( + filtered, + json!({"id": 1, "content": {"raw": ""}}) + ); + } + + #[test] + fn filter_nested_paths_merge_and_bare_key_keeps_everything() { + let value = json!({"content": {"raw": "a", "rendered": "b", "protected": false}}); + assert_eq!( + mask(value.clone(), &["content.raw", "content.rendered"]), + json!({"content": {"raw": "a", "rendered": "b"}}) + ); + assert_eq!(mask(value.clone(), &["content", "content.raw"]), value); + } + + #[test] + fn filter_nested_path_applies_inside_arrays_and_deeper_levels() { + let value = json!([ + {"id": 1, "acf": {"hero": {"title": "A", "image": 5}}}, + {"id": 2, "acf": {"hero": {"title": "B", "image": 6}}}, + ]); + assert_eq!( + mask(value, &["id", "acf.hero.title"]), + json!([ + {"id": 1, "acf": {"hero": {"title": "A"}}}, + {"id": 2, "acf": {"hero": {"title": "B"}}}, + ]) + ); + } + + #[test] + fn filter_nested_path_does_not_match_keys_sharing_a_prefix() { + let value = json!({"content": {"raw": "a"}, "content_file": "x"}); + assert_eq!( + mask(value.clone(), &["content.raw"]), + json!({"content": {"raw": "a"}}) + ); + assert_eq!(mask(value, &["content_file"]), json!({"content_file": "x"})); + } }