Skip to content

Commit 776b0c9

Browse files
authored
/widgets endpoint (#783)
* Add an initial CLAUDE.md file * Implement `/widgets` endpoint partially generated (incorrectly) by Claude * Ask Claude to update widgets implementation using API reference (and manually fix some issues) * Add widgets to api client * Generate widgets immut tests by Claude (not tested yet) * Update widgets immutable tests to get them to pass * Implement create `/widgets` integration tests * Implement delete `/widgets` integration test, remove trash action * Implement update `/widgets` integration tests * Implement `/widgets` error handling integration tests * Remove `create_widget_with_encoded` test
1 parent 41c18b0 commit 776b0c9

File tree

13 files changed

+779
-2
lines changed

13 files changed

+779
-2
lines changed

CLAUDE.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
This is a Rust implementation of the WordPress REST API client library with cross-platform bindings for iOS, Android, and other platforms. The project uses a workspace structure with multiple crates providing modular functionality.
8+
9+
## Build Commands
10+
11+
```bash
12+
# Start WordPress test instance
13+
make test-server
14+
15+
# Run unit tests
16+
cargo test --lib
17+
18+
# Run integration tests
19+
cargo test -p wp_api_integration_tests
20+
21+
# Run integration tests for a specific file
22+
cargo test -p wp_api_integration_tests --test '{file_name}'
23+
24+
# Run linting and format checks
25+
cargo fmt --all -- --check
26+
cargo clippy --tests --all-targets --all-features -- -D warnings
27+
28+
# Generate API documentation
29+
cargo doc --no-deps --all-features
30+
```
31+
32+
## Architecture
33+
34+
### Workspace Structure
35+
- `wp_api/` - Core REST API implementation
36+
- `wp_api_integration_tests/` - Integration tests requiring Dockerized WordPress instance
37+
- `wp_contextual/` - Procedural macro for context-aware types
38+
- `wp_serde/` - Custom serialization helpers
39+
- `uniffi-bindgen/` - Cross-platform binding generator
40+
- `kotlin/` - Kotlin/Android wrapper for generated bindings
41+
- `native/apple/` - Swift/iOS wrapper for generated bindings
42+
43+
### Testing
44+
45+
Tests require a WordPress instance. Use Docker:
46+
```bash
47+
# Start test server (keep running)
48+
make test-server
49+
50+
# Run the integration tests
51+
cargo test -p wp_api_integration_tests
52+
```
53+
54+
Test credentials are configured in:
55+
- `wp_api_integration_tests/tests/test_credentials.json` (WordPress.org)
56+
- `wp_api_integration_tests/tests/wp_com_test_credentials.json` (WordPress.com)
57+
58+
### Common Development Tasks
59+
60+
1. **Adding new types that uses WordPress REST API endpoint**:
61+
- The API returns different fields depending on the `context` parameter which can be `edit`, `embed` or `view` and defaults to `view`. To support this, we use the `wp_contextual::WpContextual` derive macro and add `#[WpContext(edit, embed, view)]` attribute to each field, using the available contexts.
62+
- Each contextual type's name has to start with `Sparse` prefix and all of its fields has to be `Option<T>`. `wp_contextual::WpContextual` derive macro will generate new types from it with the `WithEditContext`, `WithEmbedContext` & `WithViewContext` suffices. These new types will not use `Option<T>` for its fields unless a field in the original `Sparse` type is marked with `#[WpContextualOption]` attribute.
63+
- Most types should have the following derive macros `#[derive(Debug, serde::Serialize, serde::Deserialize, uniffi::Record)]`.
64+
- To implement new types, use `wp_api/src/posts.rs` as a reference and follow the same style
65+
- For a new endpoint, a set of JSONs should be provided to you for each context type, so you can compare them and figure out which field is returned for which contexts.
66+
67+
2. **Error handling for an endpoint**:
68+
- The server will return a `crate::WpErrorCode` instance for most error types.
69+
- While implementing the errors for a new endpoint, if it's missing from the `crate::WpErrorCode` variants, it needs to be added there. If you need to do this, please add the new variants to the very top of the type and add a comment on top with `// Needs Triage`.
70+
- Integration tests for error cases go into `wp_api_integration_tests/tests/test_{endpoint_name}_err.rs` file.
71+
- The implementation should follow a similar approach to `wp_api_integration_tests/tests/test_users_err.rs`.
72+
- There are several `api_client` helper functions available. The default `api_client` function is authenticated with an admin users. This should be the preferred client if creating the error case doesn't require an inauthenticated or a separate user. There is also `api_client_as_subscriber` function that is authenticated with a subscriber user. Most authentication error types can be triggered using this client type. Another possibility is `api_client_with_auth_provider(WpAuthenticationProvider::none().into())` which doesn't have any authentication headers, so it's useful in specific cases.
73+
- Implementing these tests can be difficult without having a full understanding of how to trigger them. So, if you are not sure how to implement it, generate a test function following existing patterns, but leave the implementation empty. Instead, add a comment about what you can find from the implementation related to how one might be able to trigger this error.
74+
- The existing tests don't have much documentation, because the test implementation can act as one. However, when you are implementing the test, please add a documentation. This is because we need some context about why you implemented a test in a specific way. If you include a documentation, we can check if what you are trying to do is correct, before reviewing the implementation.
75+
76+
## Important Files
77+
78+
- `Makefile` - Build automation and platform-specific targets
79+
- `wp_api/src/lib.rs` - Main library entry point
80+
- `wp_api/src/request.rs` - Core request/response handling
81+
- `wp_api/src/wp_error.rs` - Error types and handling
82+
83+
## Development Tips
84+
85+
- Platform bindings are generated automatically - don't edit generated files directly

api.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ fi
77

88
ADMIN_TOKEN=$(jq .admin_password test_credentials.json)
99

10-
curl --user [email protected]:"$ADMIN_TOKEN" "http://localhost/wp-json$1"
10+
curl --silent --user [email protected]:"$ADMIN_TOKEN" "http://localhost/wp-json$1" | jq .

wp_api/src/api_client.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use crate::{
2424
templates_endpoint::{TemplatesRequestBuilder, TemplatesRequestExecutor},
2525
themes_endpoint::{ThemesRequestBuilder, ThemesRequestExecutor},
2626
users_endpoint::{UsersRequestBuilder, UsersRequestExecutor},
27+
widgets_endpoint::{WidgetsRequestBuilder, WidgetsRequestExecutor},
2728
wp_site_health_tests_endpoint::{
2829
WpSiteHealthTestsRequestBuilder, WpSiteHealthTestsRequestExecutor,
2930
},
@@ -66,6 +67,7 @@ pub struct WpApiRequestBuilder {
6667
templates: Arc<TemplatesRequestBuilder>,
6768
themes: Arc<ThemesRequestBuilder>,
6869
users: Arc<UsersRequestBuilder>,
70+
widgets: Arc<WidgetsRequestBuilder>,
6971
wp_site_health_tests: Arc<WpSiteHealthTestsRequestBuilder>,
7072
}
7173

@@ -92,6 +94,7 @@ impl WpApiRequestBuilder {
9294
templates,
9395
themes,
9496
users,
97+
widgets,
9598
wp_site_health_tests
9699
)
97100
}
@@ -128,6 +131,7 @@ pub struct WpApiClient {
128131
templates: Arc<TemplatesRequestExecutor>,
129132
themes: Arc<ThemesRequestExecutor>,
130133
users: Arc<UsersRequestExecutor>,
134+
widgets: Arc<WidgetsRequestExecutor>,
131135
wp_site_health_tests: Arc<WpSiteHealthTestsRequestExecutor>,
132136
}
133137

@@ -151,6 +155,7 @@ impl WpApiClient {
151155
templates,
152156
themes,
153157
users,
158+
widgets,
154159
wp_site_health_tests
155160
)
156161
}
@@ -184,6 +189,7 @@ api_client_generate_endpoint_impl!(WpApi, taxonomies);
184189
api_client_generate_endpoint_impl!(WpApi, templates);
185190
api_client_generate_endpoint_impl!(WpApi, themes);
186191
api_client_generate_endpoint_impl!(WpApi, users);
192+
api_client_generate_endpoint_impl!(WpApi, widgets);
187193
api_client_generate_endpoint_impl!(WpApi, wp_site_health_tests);
188194

189195
#[macro_export]

wp_api/src/api_error.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@ pub enum WpErrorCode {
196196
CannotManagePlugins,
197197
#[serde(rename = "rest_cannot_manage_templates")]
198198
CannotManageTemplates,
199+
#[serde(rename = "rest_cannot_manage_widgets")]
200+
CannotManageWidgets,
199201
#[serde(rename = "rest_cannot_read_application_password")]
200202
CannotReadApplicationPassword,
201203
#[serde(rename = "rest_cannot_read")]
@@ -264,6 +266,8 @@ pub enum WpErrorCode {
264266
InvalidParam,
265267
#[serde(rename = "rest_invalid_template")]
266268
InvalidTemplate,
269+
#[serde(rename = "rest_invalid_widget")]
270+
InvalidWidget,
267271
#[serde(rename = "rest_no_search_term_defined")]
268272
NoSearchTermDefined,
269273
#[serde(rename = "rest_orderby_include_missing_include")]
@@ -308,6 +312,8 @@ pub enum WpErrorCode {
308312
UserInvalidRole,
309313
#[serde(rename = "rest_user_invalid_slug")]
310314
UserInvalidSlug,
315+
#[serde(rename = "rest_widget_not_found")]
316+
WidgetNotFound,
311317
// ------------------------------------------------------------------------------------
312318
// Untested, because we are unable to create the necessary conditions for them
313319
// ------------------------------------------------------------------------------------

wp_api/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ pub mod themes;
3535
pub mod url_query;
3636
pub mod users;
3737
pub mod uuid;
38+
pub mod widget_types;
39+
pub mod widgets;
3840
pub mod wordpress_org;
3941
pub mod wp_site_health_tests;
4042

@@ -104,7 +106,7 @@ pub enum EnumFromStrParsingError {
104106
UnknownVariant { value: String },
105107
}
106108

107-
#[derive(Debug, Serialize, Deserialize, uniffi::Enum)]
109+
#[derive(Debug, PartialEq, Serialize, Deserialize, uniffi::Enum)]
108110
#[serde(untagged)]
109111
pub enum JsonValue {
110112
Null,

wp_api/src/request/endpoint.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub mod taxonomies_endpoint;
1818
pub mod templates_endpoint;
1919
pub mod themes_endpoint;
2020
pub mod users_endpoint;
21+
pub mod widgets_endpoint;
2122
pub mod wp_site_health_tests_endpoint;
2223

2324
pub const WP_JSON_PATH_SEGMENTS: [&str; 1] = ["wp-json"];
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
use super::{AsNamespace, DerivedRequest, WpNamespace};
2+
use crate::{
3+
SparseField,
4+
widgets::{
5+
SparseWidgetFieldWithEditContext, SparseWidgetFieldWithEmbedContext,
6+
SparseWidgetFieldWithViewContext, WidgetId, WidgetListParams, WidgetWithEditContext,
7+
},
8+
};
9+
use wp_derive_request_builder::WpDerivedRequest;
10+
11+
#[derive(WpDerivedRequest)]
12+
enum WidgetsRequest {
13+
#[contextual_paged(url = "/widgets", params = &WidgetListParams, output = Vec<crate::widgets::SparseWidget>, filter_by = crate::widgets::SparseWidgetField)]
14+
List,
15+
#[contextual_get(url = "/widgets/<widget_id>", output = crate::widgets::SparseWidget, filter_by = crate::widgets::SparseWidgetField)]
16+
Retrieve,
17+
#[post(url = "/widgets", params = &crate::widgets::WidgetCreateParams, output = WidgetWithEditContext)]
18+
Create,
19+
#[post(url = "/widgets/<widget_id>", params = &crate::widgets::WidgetUpdateParams, output = WidgetWithEditContext)]
20+
Update,
21+
#[delete(url = "/widgets/<widget_id>", output = crate::widgets::WidgetDeleteResponse)]
22+
Delete,
23+
}
24+
25+
impl DerivedRequest for WidgetsRequest {
26+
fn additional_query_pairs(&self) -> Vec<(&str, String)> {
27+
match &self {
28+
Self::Delete => vec![("force", true.to_string())],
29+
_ => vec![],
30+
}
31+
}
32+
33+
fn namespace() -> impl AsNamespace {
34+
WpNamespace::WpV2
35+
}
36+
}
37+
38+
super::macros::default_sparse_field_implementation_from_field_name!(
39+
SparseWidgetFieldWithEditContext
40+
);
41+
super::macros::default_sparse_field_implementation_from_field_name!(
42+
SparseWidgetFieldWithEmbedContext
43+
);
44+
super::macros::default_sparse_field_implementation_from_field_name!(
45+
SparseWidgetFieldWithViewContext
46+
);

wp_api/src/widget_types.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
use serde::{Deserialize, Serialize};
2+
use std::fmt::Display;
3+
4+
uniffi::custom_newtype!(WidgetTypeId, String);
5+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6+
pub struct WidgetTypeId(pub String);
7+
8+
impl Display for WidgetTypeId {
9+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10+
write!(f, "{}", self.0)
11+
}
12+
}

wp_api/src/widgets.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
use crate::JsonValue;
2+
use crate::url_query::{
3+
AppendUrlQueryPairs, FromUrlQueryPairs, QueryPairs, QueryPairsExtension, UrlQueryPairsMap,
4+
};
5+
use crate::widget_types::WidgetTypeId;
6+
use serde::{Deserialize, Serialize};
7+
use std::{collections::HashMap, fmt::Display};
8+
use strum_macros::IntoStaticStr;
9+
use wp_contextual::WpContextual;
10+
11+
uniffi::custom_newtype!(WidgetId, String);
12+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13+
pub struct WidgetId(pub String);
14+
15+
impl Display for WidgetId {
16+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17+
write!(f, "{}", self.0)
18+
}
19+
}
20+
21+
#[derive(Debug, Serialize, Deserialize, uniffi::Record, WpContextual)]
22+
pub struct SparseWidget {
23+
#[WpContext(edit, embed, view)]
24+
pub id: Option<WidgetId>,
25+
#[WpContext(edit, embed, view)]
26+
pub id_base: Option<WidgetTypeId>,
27+
#[WpContext(edit, embed, view)]
28+
pub sidebar: Option<String>,
29+
#[WpContext(edit, embed, view)]
30+
pub rendered: Option<String>,
31+
#[WpContext(edit)]
32+
pub rendered_form: Option<String>,
33+
#[WpContext(edit)]
34+
pub instance: Option<WidgetInstance>,
35+
#[WpContext(edit)]
36+
#[WpContextualOption]
37+
pub form_data: Option<String>,
38+
}
39+
40+
#[derive(Debug, Serialize, Deserialize, uniffi::Record)]
41+
pub struct WidgetInstance {
42+
#[serde(skip_serializing_if = "Option::is_none")]
43+
pub encoded: Option<String>,
44+
#[serde(skip_serializing_if = "Option::is_none")]
45+
pub hash: Option<String>,
46+
#[serde(skip_serializing_if = "Option::is_none")]
47+
pub raw: Option<HashMap<String, JsonValue>>,
48+
}
49+
50+
#[derive(Debug, Clone, Default, uniffi::Record)]
51+
pub struct WidgetListParams {
52+
pub sidebar: Option<String>,
53+
}
54+
55+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, IntoStaticStr)]
56+
enum WidgetListParamsField {
57+
#[strum(serialize = "sidebar")]
58+
Sidebar,
59+
}
60+
61+
impl AppendUrlQueryPairs for WidgetListParams {
62+
fn append_query_pairs(&self, query_pairs_mut: &mut QueryPairs) {
63+
query_pairs_mut
64+
.append_option_query_value_pair(WidgetListParamsField::Sidebar, self.sidebar.as_ref());
65+
}
66+
}
67+
impl FromUrlQueryPairs for WidgetListParams {
68+
fn from_url_query_pairs(query_pairs: UrlQueryPairsMap) -> Option<Self> {
69+
Some(Self {
70+
sidebar: query_pairs.get(WidgetListParamsField::Sidebar),
71+
})
72+
}
73+
74+
fn supports_pagination() -> bool {
75+
false
76+
}
77+
}
78+
79+
#[derive(Debug, Serialize, Deserialize, uniffi::Record)]
80+
pub struct WidgetCreateParams {
81+
pub id_base: WidgetTypeId,
82+
pub sidebar: String,
83+
#[serde(skip_serializing_if = "Option::is_none")]
84+
pub instance: Option<WidgetInstanceParams>,
85+
#[serde(skip_serializing_if = "Option::is_none")]
86+
pub form_data: Option<String>,
87+
}
88+
89+
#[derive(Debug, Serialize, Deserialize, uniffi::Enum)]
90+
#[serde(untagged)]
91+
pub enum WidgetInstanceParams {
92+
Raw { raw: HashMap<String, JsonValue> },
93+
Encoded { encoded: String, hash: String },
94+
}
95+
96+
#[derive(Debug, Default, Serialize, uniffi::Record)]
97+
pub struct WidgetUpdateParams {
98+
// Updating widget type's with `id_base` is not supported by the backend even though the field
99+
// is listed in the documentation: https://developer.wordpress.org/rest-api/reference/widgets/#update-a-widget
100+
// The field is omitted from the type to avoid confusion.
101+
#[serde(skip_serializing_if = "Option::is_none")]
102+
pub sidebar: Option<String>,
103+
#[serde(skip_serializing_if = "Option::is_none")]
104+
pub instance: Option<WidgetInstanceParams>,
105+
#[serde(skip_serializing_if = "Option::is_none")]
106+
pub form_data: Option<String>,
107+
}
108+
109+
#[derive(Debug, Serialize, Deserialize, uniffi::Record)]
110+
pub struct WidgetDeleteResponse {
111+
pub deleted: bool,
112+
pub previous: WidgetWithEditContext,
113+
}

wp_api_integration_tests/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ pub const POST_TEMPLATE_SINGLE_WITH_SIDEBAR: &str = "single-with-sidebar";
7979
pub const THEME_TWENTY_TWENTY_FIVE: &str = "twentytwentyfive";
8080
pub const THEME_TWENTY_TWENTY_FOUR: &str = "twentytwentyfour";
8181
pub const THEME_TWENTY_TWENTY_THREE: &str = "twentytwentythree";
82+
pub const WIDGET_ID_BLOCK_2: &str = "block-2";
83+
pub const WIDGET_INACTIVE_WIDGETS_SIDEBAR: &str = "wp_inactive_widgets";
84+
pub const WIDGET_TYPE_TEXT: &str = "text";
8285

8386
pub fn api_client() -> WpApiClient {
8487
WpApiClient::new(

0 commit comments

Comments
 (0)