From 0d8892f4f598c60db206a0db2043257e2b53b294 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Mon, 9 Sep 2024 13:26:25 -0400 Subject: [PATCH 1/2] Implement create `/posts` endpoint and its integration tests --- wp_api/src/api_error.rs | 2 + wp_api/src/posts.rs | 139 +++++++++++++++--- wp_api/src/request/endpoint/posts_endpoint.rs | 7 + wp_api_integration_tests/src/backend.rs | 8 +- .../tests/test_posts_err.rs | 17 ++- .../tests/test_posts_mut.rs | 84 ++++++++++- wp_api_integration_tests_backend/src/main.rs | 8 + wp_cli/src/wp_cli_posts.rs | 29 +++- 8 files changed, 270 insertions(+), 24 deletions(-) diff --git a/wp_api/src/api_error.rs b/wp_api/src/api_error.rs index 4bd54c518..0fb90d8c7 100644 --- a/wp_api/src/api_error.rs +++ b/wp_api/src/api_error.rs @@ -94,6 +94,8 @@ pub enum WpErrorCode { CannotViewPlugin, #[serde(rename = "rest_cannot_view_plugins")] CannotViewPlugins, + #[serde(rename = "empty_content")] + EmptyContent, #[serde(rename = "rest_forbidden_context")] ForbiddenContext, #[serde(rename = "rest_forbidden_orderby")] diff --git a/wp_api/src/posts.rs b/wp_api/src/posts.rs index fddc60522..dff870933 100644 --- a/wp_api/src/posts.rs +++ b/wp_api/src/posts.rs @@ -206,6 +206,84 @@ pub struct PostDeleteResponse { pub previous: PostWithEditContext, } +#[derive(Debug, Default, Serialize, uniffi::Record)] +pub struct PostCreateParams { + // The date the post was published, in the site's timezone. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub date: Option, + // The date the post was published, as GMT. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub date_gmt: Option, + // An alphanumeric identifier for the post unique to its type. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub slug: Option, + // A named status for the post. + // One of: publish, future, draft, pending, private + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + // A password to protect access to the content and excerpt. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, + // The title for the post. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + // The content for the post. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + // The ID for the author of the post. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + // The excerpt for the post. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub excerpt: Option, + // The ID of the featured media for the post. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub featured_media: Option, + // Whether or not comments are open on the post. + // One of: open, closed + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub comment_status: Option, + // Whether or not the post can be pinged. + // One of: open, closed + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub ping_status: Option, + // The format for the post. + // One of: standard, aside, chat, gallery, link, image, quote, status, video, audio + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, + // Meta fields. + pub meta: Option, + // Whether or not the post should be treated as sticky. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub sticky: Option, + // The theme file to use to display the post. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Option::is_none")] + pub template: Option, + // The terms assigned to the post in the category taxonomy. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub categories: Vec, + // The terms assigned to the post in the post_tag taxonomy. + #[uniffi(default = None)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + impl_as_query_value_for_new_type!(PostId); uniffi::custom_newtype!(PostId, i32); #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -221,6 +299,11 @@ uniffi::custom_newtype!(CategoryId, i32); #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct CategoryId(pub i32); +impl_as_query_value_for_new_type!(MediaId); +uniffi::custom_newtype!(MediaId, i32); +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct MediaId(pub i32); + impl std::fmt::Display for PostId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) @@ -236,7 +319,8 @@ pub struct SparsePost { #[WpContext(edit, view)] pub date_gmt: Option, #[WpContext(edit, view)] - pub guid: Option, + #[WpContextualField] + pub guid: Option, #[WpContext(edit, embed, view)] pub link: Option, #[WpContext(edit, view)] @@ -257,13 +341,16 @@ pub struct SparsePost { #[WpContext(edit)] pub generated_slug: Option, #[WpContext(edit, embed, view)] - pub title: Option, + #[WpContextualField] + pub title: Option, #[WpContext(edit, view)] - pub content: Option, + #[WpContextualField] + pub content: Option, #[WpContext(edit, embed, view)] pub author: Option, #[WpContext(edit, embed, view)] - pub excerpt: Option, + #[WpContextualField] + pub excerpt: Option, #[WpContext(edit, embed, view)] pub featured_media: Option, #[WpContext(edit, view)] @@ -284,26 +371,42 @@ pub struct SparsePost { pub tags: Option>, } -#[derive(Debug, Serialize, Deserialize, uniffi::Record)] -pub struct PostGuid { - pub rendered: String, +#[derive(Debug, Serialize, Deserialize, uniffi::Record, WpContextual)] +pub struct SparsePostGuid { + #[WpContext(edit)] + pub raw: Option, + #[WpContext(edit, view)] + pub rendered: Option, } -#[derive(Debug, Serialize, Deserialize, uniffi::Record)] -pub struct PostTitle { - pub rendered: String, +#[derive(Debug, Serialize, Deserialize, uniffi::Record, WpContextual)] +pub struct SparsePostTitle { + #[WpContext(edit)] + pub raw: Option, + #[WpContext(edit, embed, view)] + pub rendered: Option, } -#[derive(Debug, Serialize, Deserialize, uniffi::Record)] -pub struct PostContent { - pub rendered: String, - pub protected: bool, +#[derive(Debug, Serialize, Deserialize, uniffi::Record, WpContextual)] +pub struct SparsePostContent { + #[WpContext(edit)] + pub raw: Option, + #[WpContext(edit, view)] + pub rendered: Option, + #[WpContext(edit, view)] + pub protected: Option, + #[WpContext(edit)] + pub block_version: Option, } -#[derive(Debug, Serialize, Deserialize, uniffi::Record)] -pub struct PostExcerpt { - pub rendered: String, - pub protected: bool, +#[derive(Debug, Serialize, Deserialize, uniffi::Record, WpContextual)] +pub struct SparsePostExcerpt { + #[WpContext(edit)] + pub raw: Option, + #[WpContext(edit, embed, view)] + pub rendered: Option, + #[WpContext(edit, embed, view)] + pub protected: Option, } #[derive(Debug, Serialize, Deserialize, uniffi::Record)] diff --git a/wp_api/src/request/endpoint/posts_endpoint.rs b/wp_api/src/request/endpoint/posts_endpoint.rs index 08167f1a7..ea6cbf73d 100644 --- a/wp_api/src/request/endpoint/posts_endpoint.rs +++ b/wp_api/src/request/endpoint/posts_endpoint.rs @@ -15,6 +15,8 @@ enum PostsRequest { List, #[contextual_get(url = "/posts/", params = &crate::posts::PostRetrieveParams, output = crate::posts::SparsePost, filter_by = crate::posts::SparsePostField)] Retrieve, + #[post(url = "/posts", params = &crate::posts::PostCreateParams, output = crate::posts::PostWithEditContext)] + Create, #[delete(url = "/posts/", output = crate::posts::PostDeleteResponse)] Delete, #[delete(url = "/posts/", output = crate::posts::PostWithEditContext)] @@ -51,6 +53,11 @@ mod tests { use rstest::*; use std::sync::Arc; + #[rstest] + fn create_post(endpoint: PostsRequestEndpoint) { + validate_wp_v2_endpoint(endpoint.create(), "/posts"); + } + #[rstest] fn delete_post(endpoint: PostsRequestEndpoint) { validate_wp_v2_endpoint(endpoint.delete(&PostId(54)), "/posts/54?force=true"); diff --git a/wp_api_integration_tests/src/backend.rs b/wp_api_integration_tests/src/backend.rs index eada24b56..7361aa0b7 100644 --- a/wp_api_integration_tests/src/backend.rs +++ b/wp_api_integration_tests/src/backend.rs @@ -1,10 +1,11 @@ use serde::{de::DeserializeOwned, Serialize}; -use wp_api::users::UserId; +use wp_api::{posts::PostId, users::UserId}; use wp_cli::{WpCliPost, WpCliSiteSettings, WpCliUser, WpCliUserMeta}; const BACKEND_ADDRESS: &str = "http://127.0.0.1:4000"; const BACKEND_PATH_RESTORE: &str = "/restore"; const BACKEND_PATH_SITE_SETTINGS: &str = "/wp-cli/site-settings"; +const BACKEND_PATH_POST: &str = "/wp-cli/post"; const BACKEND_PATH_POSTS: &str = "/wp-cli/posts"; const BACKEND_PATH_USER: &str = "/wp-cli/user"; const BACKEND_PATH_USERS: &str = "/wp-cli/users"; @@ -21,6 +22,11 @@ impl Backend { pub async fn site_settings() -> Result { Self::get(BACKEND_PATH_SITE_SETTINGS).await } + pub async fn post(post_id: &PostId) -> WpCliPost { + Self::get(format!("{}?post_id={}", BACKEND_PATH_POST, post_id)) + .await + .expect("Failed to parse fetched post from wp_cli") + } pub async fn posts(post_status: Option<&str>) -> Vec { let url = if let Some(post_status) = post_status { format!("{}?post_status={}", BACKEND_PATH_POSTS, post_status) diff --git a/wp_api_integration_tests/tests/test_posts_err.rs b/wp_api_integration_tests/tests/test_posts_err.rs index 22513148a..766fec831 100644 --- a/wp_api_integration_tests/tests/test_posts_err.rs +++ b/wp_api_integration_tests/tests/test_posts_err.rs @@ -1,10 +1,23 @@ use serial_test::parallel; -use wp_api::{posts::PostRetrieveParams, WpErrorCode}; +use wp_api::{ + posts::{PostCreateParams, PostRetrieveParams}, + WpErrorCode, +}; use wp_api_integration_tests::{api_client, AssertWpError, PASSWORD_PROTECTED_POST_ID}; #[tokio::test] #[parallel] -async fn retrieve_password_protected_err_() { +async fn create_post_err() { + api_client() + .posts() + .create(&PostCreateParams::default()) + .await + .assert_wp_error(WpErrorCode::EmptyContent) +} + +#[tokio::test] +#[parallel] +async fn retrieve_password_protected_post_err_wrong_password() { api_client() .posts() .retrieve_with_view_context( diff --git a/wp_api_integration_tests/tests/test_posts_mut.rs b/wp_api_integration_tests/tests/test_posts_mut.rs index b72b6b9d0..55ca263a9 100644 --- a/wp_api_integration_tests/tests/test_posts_mut.rs +++ b/wp_api_integration_tests/tests/test_posts_mut.rs @@ -1,9 +1,81 @@ use serial_test::serial; +use wp_api::posts::{PostCreateParams, PostWithEditContext}; use wp_api_integration_tests::{ api_client, backend::{Backend, RestoreServer}, - FIRST_POST_ID, + AssertResponse, FIRST_POST_ID, }; +use wp_cli::WpCliPost; + +#[tokio::test] +#[serial] +async fn create_post_with_just_title() { + test_create_post( + &PostCreateParams { + title: Some("foo".to_string()), + ..Default::default() + }, + |created_post, post_from_wp_cli| { + assert_eq!(created_post.title.raw, "foo"); + assert_eq!(post_from_wp_cli.title, "foo"); + }, + ) + .await; +} + +#[tokio::test] +#[serial] +async fn create_post_with_just_content() { + test_create_post( + &PostCreateParams { + content: Some("foo".to_string()), + ..Default::default() + }, + |created_post, post_from_wp_cli| { + assert_eq!(created_post.content.raw, "foo"); + assert_eq!(post_from_wp_cli.content, "foo"); + }, + ) + .await; +} + +#[tokio::test] +#[serial] +async fn create_post_with_just_excerpt() { + test_create_post( + &PostCreateParams { + excerpt: Some("foo".to_string()), + ..Default::default() + }, + |created_post, post_from_wp_cli| { + assert_eq!(created_post.excerpt.raw, "foo"); + assert_eq!(post_from_wp_cli.excerpt, "foo"); + }, + ) + .await; +} + +#[tokio::test] +#[serial] +async fn create_post_with_title_content_and_excerpt() { + test_create_post( + &PostCreateParams { + title: Some("foo".to_string()), + content: Some("bar".to_string()), + excerpt: Some("baz".to_string()), + ..Default::default() + }, + |created_post, post_from_wp_cli| { + assert_eq!(created_post.title.raw, "foo"); + assert_eq!(post_from_wp_cli.title, "foo"); + assert_eq!(created_post.content.raw, "bar"); + assert_eq!(post_from_wp_cli.content, "bar"); + assert_eq!(created_post.excerpt.raw, "baz"); + assert_eq!(post_from_wp_cli.excerpt, "baz"); + }, + ) + .await; +} #[tokio::test] #[serial] @@ -45,3 +117,13 @@ async fn trash_post() { RestoreServer::db().await; } + +async fn test_create_post(params: &PostCreateParams, assert: F) +where + F: Fn(PostWithEditContext, WpCliPost), +{ + let created_post = api_client().posts().create(¶ms).await.assert_response(); + let created_post_from_wp_cli = Backend::post(&created_post.id).await; + assert(created_post, created_post_from_wp_cli); + RestoreServer::db().await; +} diff --git a/wp_api_integration_tests_backend/src/main.rs b/wp_api_integration_tests_backend/src/main.rs index 8318de891..e11e74ada 100644 --- a/wp_api_integration_tests_backend/src/main.rs +++ b/wp_api_integration_tests_backend/src/main.rs @@ -22,6 +22,13 @@ fn wp_cli_site_settings() -> Result, Error> { .map_err(|e| Error::AsString(e.to_string())) } +#[get("/post?")] +fn wp_cli_post(post_id: i64) -> Result, Error> { + WpCliPost::get(post_id) + .map(Json) + .map_err(|e| Error::AsString(e.to_string())) +} + #[get("/posts?")] fn wp_cli_posts(post_status: Option) -> Result>, Error> { WpCliPost::list(Some(WpCliPostListArguments { post_status })) @@ -72,6 +79,7 @@ fn rocket() -> _ { rocket::build() .mount("/", routes![restore_wp_server]) .mount("/wp-cli/", routes![wp_cli_site_settings]) + .mount("/wp-cli/", routes![wp_cli_post]) .mount("/wp-cli/", routes![wp_cli_posts]) .mount("/wp-cli/", routes![wp_cli_user]) .mount("/wp-cli/", routes![wp_cli_users]) diff --git a/wp_cli/src/wp_cli_posts.rs b/wp_cli/src/wp_cli_posts.rs index f71cfcd9b..0c68210a7 100644 --- a/wp_cli/src/wp_cli_posts.rs +++ b/wp_cli/src/wp_cli_posts.rs @@ -4,6 +4,8 @@ use wp_serde_helper::deserialize_i64_or_string; use crate::run_wp_cli_command; +const POST_FIELDS_ARG: &str = "--fields=ID, post_title, post_date, post_status, post_author, post_date_gmt, post_content, post_excerpt, comment_status, ping_status, post_password, post_modified, post_modified_gmt, guid, post_type"; + #[derive(Debug, Default)] pub struct WpCliPostListArguments { pub post_status: Option, @@ -32,7 +34,30 @@ pub struct WpCliPost { #[serde(rename = "ID")] #[serde(deserialize_with = "deserialize_i64_or_string")] pub id: i64, + #[serde(rename = "post_author")] + #[serde(deserialize_with = "deserialize_i64_or_string")] + pub author: i64, + pub comment_status: String, + #[serde(rename = "post_content")] + pub content: String, + #[serde(rename = "post_date")] + pub date: String, + #[serde(rename = "post_date_gmt")] + pub date_gmt: String, + #[serde(rename = "post_excerpt")] + pub excerpt: String, + pub guid: String, + #[serde(rename = "post_modified")] + pub modified: String, + #[serde(rename = "post_modified_gmt")] + pub modified_gmt: String, + #[serde(rename = "post_password")] + pub password: String, + pub ping_status: String, pub post_status: String, + pub post_type: String, + #[serde(rename = "post_title")] + pub title: String, } impl WpCliPost { @@ -47,9 +72,9 @@ impl WpCliPost { } pub fn list(arguments: Option) -> Result> { let output = if let Some(cli_arguments) = arguments.and_then(|a| a.as_wp_cli_arguments()) { - run_wp_cli_command(["post", "list", cli_arguments.as_str()]) + run_wp_cli_command(["post", "list", POST_FIELDS_ARG, cli_arguments.as_str()]) } else { - run_wp_cli_command(["post", "list"]) + run_wp_cli_command(["post", "list", POST_FIELDS_ARG]) }; serde_json::from_slice::>(&output.stdout) .with_context(|| "Failed to parse `wp post list --format=json` into Vec") From c1c4512ca665792190a9626c136a7c7df4736183 Mon Sep 17 00:00:00 2001 From: Oguz Kocer Date: Mon, 9 Sep 2024 13:57:12 -0400 Subject: [PATCH 2/2] Fix the default values for create post categories and tags --- wp_api/src/posts.rs | 4 ++-- wp_api_integration_tests/tests/test_posts_mut.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/wp_api/src/posts.rs b/wp_api/src/posts.rs index dff870933..775d39c0d 100644 --- a/wp_api/src/posts.rs +++ b/wp_api/src/posts.rs @@ -275,11 +275,11 @@ pub struct PostCreateParams { #[serde(skip_serializing_if = "Option::is_none")] pub template: Option, // The terms assigned to the post in the category taxonomy. - #[uniffi(default = None)] + #[uniffi(default = [])] #[serde(skip_serializing_if = "Vec::is_empty")] pub categories: Vec, // The terms assigned to the post in the post_tag taxonomy. - #[uniffi(default = None)] + #[uniffi(default = [])] #[serde(skip_serializing_if = "Vec::is_empty")] pub tags: Vec, } diff --git a/wp_api_integration_tests/tests/test_posts_mut.rs b/wp_api_integration_tests/tests/test_posts_mut.rs index 55ca263a9..4b2c1dcf2 100644 --- a/wp_api_integration_tests/tests/test_posts_mut.rs +++ b/wp_api_integration_tests/tests/test_posts_mut.rs @@ -122,7 +122,7 @@ async fn test_create_post(params: &PostCreateParams, assert: F) where F: Fn(PostWithEditContext, WpCliPost), { - let created_post = api_client().posts().create(¶ms).await.assert_response(); + let created_post = api_client().posts().create(params).await.assert_response(); let created_post_from_wp_cli = Backend::post(&created_post.id).await; assert(created_post, created_post_from_wp_cli); RestoreServer::db().await;