diff --git a/.gitignore b/.gitignore index 5a8626d5..b94b19c1 100644 --- a/.gitignore +++ b/.gitignore @@ -254,4 +254,5 @@ WordPressPCL.Tests.Selfhosted/Utility/ApiCredentials.cs WordPressPCL.Tests.Selfhosted/Utility/ApiCredentials.cs WordPressPCL.Tests.Hosted/Utility/ApiCredentials.cs WordPressPCL.Tests.Selfhosted/Utility/ApiCredentials.cs -WordPressPCL.Tests.Selfhosted/Utility/ApiCredentials.cssite/ +WordPressPCL.Tests.Selfhosted/Utility/ApiCredentials.cs +site/ diff --git a/dev/init-wordpress.sh b/dev/init-wordpress.sh index 97815a63..7611defb 100644 --- a/dev/init-wordpress.sh +++ b/dev/init-wordpress.sh @@ -39,5 +39,19 @@ wp plugin install application-passwords-enable --activate --force --path="$WP_PA wp plugin install contact-form-7 --activate --force --path="$WP_PATH" wp plugin install jwt-auth --activate --force --path="$WP_PATH" +echo "Registering test meta fields..." +mkdir -p "$WP_PATH/wp-content/mu-plugins" +cat > "$WP_PATH/wp-content/mu-plugins/wordpresspcl-test-meta.php" << 'EOF' + 'string', + 'single' => true, + 'show_in_rest' => true, + 'default' => '', + ]); +}); +EOF + touch "$WP_READY_FILE" echo "WordPress test environment is ready." diff --git a/docs/v2/entities/posts.md b/docs/v2/entities/posts.md index 7a9bf55b..0a5160b1 100644 --- a/docs/v2/entities/posts.md +++ b/docs/v2/entities/posts.md @@ -60,10 +60,10 @@ if (await client.Auth.IsValidJWTokenAsync()) var post = new Post { Id = 123, - Meta = new Dictionary + Meta = JsonSerializer.SerializeToElement(new Dictionary { ["my-custom-key"] = "some value" - }, + }), }; await client.Posts.UpdateAsync(post); diff --git a/docs/v3/entities/customFields.md b/docs/v3/entities/customFields.md new file mode 100644 index 00000000..c273dec5 --- /dev/null +++ b/docs/v3/entities/customFields.md @@ -0,0 +1,196 @@ +# Custom Fields (Meta) + +Custom fields — also called post meta or metadata — let you attach arbitrary key/value data to WordPress objects (posts, pages, comments, users, and media). WordPress exposes them through the REST API via the `meta` property that is present on every supported object type. + +## How WordPress exposes custom fields via REST API + +By default the `meta` property in a REST API response is an **empty object** (`{}`). Custom fields are **opt-in**: each meta key must be explicitly registered for REST API exposure, either through core WordPress functions or through plugins. + +### Approach 1 — `register_post_meta()` (recommended) + +Register a meta key server-side so that it appears in the `meta` object of post/page responses: + +```php +add_action('rest_api_init', function () { + register_post_meta('post', 'my_color', [ + 'type' => 'string', + 'description' => 'A color value for the post.', + 'single' => true, + 'show_in_rest' => true, + ]); +}); +``` + +> **Tip:** Use `register_term_meta()`, `register_comment_meta()`, and `register_user_meta()` for the corresponding object types. + +Once registered, the field appears in responses like this: + +```json +{ + "id": 42, + "title": { "rendered": "Hello world" }, + "meta": { + "my_color": "blue" + } +} +``` + +### Approach 2 — `register_rest_field()` + +`register_rest_field()` adds a *top-level* field to REST responses (not nested under `meta`). This is useful for computed values or when you need full control over serialisation: + +```php +add_action('rest_api_init', function () { + register_rest_field('post', 'my_color', [ + 'get_callback' => function ($post) { + return get_post_meta($post['id'], 'my_color', true); + }, + 'update_callback' => function ($value, $post) { + update_post_meta($post->ID, 'my_color', sanitize_text_field($value)); + }, + 'schema' => [ + 'type' => 'string', + 'description' => 'A color value for the post.', + ], + ]); +}); +``` + +Fields registered this way appear at the top level of the JSON object, alongside `id`, `title`, etc. +Use the `CustomRequest` approach described below to map them. + +### Approach 3 — Advanced Custom Fields (ACF) plugin + +The popular [ACF plugin](https://www.advancedcustomfields.com/) can expose field groups through the REST API when the **Show in REST API** option is enabled on the field group. ACF exposes the data under an `acf` top-level key. Use the [Custom Request](../customization/customRequest.md) approach to work with ACF data. + +## Supported object types + +The `Meta` property is available on the following WordPressPCL models: + +| Model | WordPress endpoint | +|---|---| +| `Post` | `/wp/v2/posts` | +| `Page` | `/wp/v2/pages` | +| `Comment` | `/wp/v2/comments` | +| `MediaItem` | `/wp/v2/media` | +| `PostRevision` | `/wp/v2/revisions` | +| `User` | `/wp/v2/users` | + +## Reading custom fields + +The `Meta` property is typed as `JsonElement?` so that it can accommodate any JSON shape returned by WordPress (string, number, array, or nested object). + +```csharp +Post post = await client.Posts.GetByIdAsync(123); + +// Read a simple string value +string? color = post.Meta?.GetProperty("my_color").GetString(); + +// Read an integer value +int? count = post.Meta?.GetProperty("view_count").GetInt32(); +``` + +### Deserialize to a strongly-typed class + +Define a class that mirrors the shape of your meta fields and deserialize the element directly: + +```csharp +public class PostMeta +{ + [JsonPropertyName("my_color")] + public string? Color { get; set; } + + [JsonPropertyName("view_count")] + public int ViewCount { get; set; } +} + +Post post = await client.Posts.GetByIdAsync(123); +PostMeta? meta = post.Meta?.Deserialize(); +Console.WriteLine(meta?.Color); // e.g. "blue" +``` + +## Writing / updating custom fields + +Serialize your data into a `JsonElement` and assign it to the `Meta` property before calling `UpdateAsync`. + +### Update a single meta key + +```csharp +Post post = new Post +{ + Id = 123, + Meta = JsonSerializer.SerializeToElement(new Dictionary + { + ["my_color"] = "blue" + }), +}; + +await client.Posts.UpdateAsync(post); +``` + +### Update multiple meta keys + +```csharp +Post post = new Post +{ + Id = 123, + Meta = JsonSerializer.SerializeToElement(new Dictionary + { + ["my_color"] = "blue", + ["view_count"] = 42, + }), +}; + +await client.Posts.UpdateAsync(post); +``` + +### Update with a strongly-typed class + +```csharp +PostMeta meta = new PostMeta { Color = "red", ViewCount = 10 }; + +Post post = new Post +{ + Id = 123, + Meta = JsonSerializer.SerializeToElement(meta), +}; + +await client.Posts.UpdateAsync(post); +``` + +> **Important:** Only meta keys that have been registered with `show_in_rest: true` (see above) can be written through the REST API. Attempting to update an unregistered key will silently ignore the value. + +## Top-level REST fields (register_rest_field) + +When a plugin uses `register_rest_field()` to add a field at the top level of the response — for example `acf` — it will not appear in `Meta`. Use the [Custom Request](../customization/customRequest.md) feature to work with these fields by defining a custom model: + +```csharp +public class PostWithAcf +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("title")] + public WordPressPCL.Models.Title? Title { get; set; } + + [JsonPropertyName("acf")] + public MyAcfFields? Acf { get; set; } +} + +public class MyAcfFields +{ + [JsonPropertyName("my_color")] + public string? Color { get; set; } +} + +// Fetch a post with ACF fields +PostWithAcf? post = await client.CustomRequest.GetByIdAsync("wp/v2/posts", 123); +Console.WriteLine(post?.Acf?.Color); +``` + +## Further reading + +- [WordPress REST API — Post meta](https://developer.wordpress.org/rest-api/reference/post-meta/) +- [`register_post_meta()` reference](https://developer.wordpress.org/reference/functions/register_post_meta/) +- [`register_rest_field()` reference](https://developer.wordpress.org/rest-api/extending-the-rest-api/modifying-responses/) +- [Custom Requests in WordPressPCL](../customization/customRequest.md) diff --git a/docs/v3/entities/posts.md b/docs/v3/entities/posts.md index dfb6b1ed..7b47a52a 100644 --- a/docs/v3/entities/posts.md +++ b/docs/v3/entities/posts.md @@ -59,10 +59,10 @@ if (await client.IsValidJWTokenAsync()) Post post = new Post { Id = 123, - Meta = new Dictionary + Meta = JsonSerializer.SerializeToElement(new Dictionary { ["my-custom-key"] = "some value" - }, + }), }; await client.Posts.UpdateAsync(post); @@ -78,6 +78,8 @@ register_post_meta('post', 'my-custom-key', [ ]); ``` +For more information on reading, writing, and working with custom fields see the [Custom Fields](customFields.md) documentation. + ## Delete Post ```csharp diff --git a/mkdocs.yml b/mkdocs.yml index 3f2d3f7c..8962c4d5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,6 +26,7 @@ nav: - Post Statuses: v3/entities/poststatuses.md - Settings: v3/entities/settings.md - Custom Post Type: v3/entities/customPostType.md + - Custom Fields: v3/entities/customFields.md - Version 2.x: - Overview: v2/index.md - Breaking Changes: v2/breaking-changes.md diff --git a/tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs b/tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs index 1092cbca..7c7edf90 100644 --- a/tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs +++ b/tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; using WordPressPCL.Models; @@ -227,5 +228,65 @@ public async Task Posts_GetPaged_TotalCount_Matches_GetCount() Assert.AreEqual(count, paged.TotalCount, "TotalCount from GetPagedAsync should match GetCountAsync"); } + [TestMethod] + public async Task Posts_Meta_Write_And_Read() + { + // Arrange — create a post carrying a registered meta value + string metaValue = $"pcl-meta-{System.Guid.NewGuid()}"; + Post post = new() + { + Title = new Title("Meta Test Post"), + Content = new Content("Meta Test Content"), + Meta = JsonSerializer.SerializeToElement(new Dictionary + { + ["wordpresspcl_test_meta"] = metaValue + }), + }; + + // Act — create then fetch back (edit context to ensure meta is returned) + Post createdPost = await _clientAuth.Posts.CreateAsync(post, TestContext.CancellationToken); + Post fetchedPost = await _clientAuth.Posts.GetByIdAsync(createdPost.Id, cancellationToken: TestContext.CancellationToken); + + // Assert + Assert.IsNotNull(fetchedPost.Meta, "Meta should not be null when a registered key was written"); + string? readValue = fetchedPost.Meta.Value.GetProperty("wordpresspcl_test_meta").GetString(); + Assert.AreEqual(metaValue, readValue, "Meta value read back should equal the value written"); + } + + [TestMethod] + public async Task Posts_Meta_Update() + { + // Arrange — create a post, then update only the meta field + Post post = new() + { + Title = new Title("Meta Update Test Post"), + Content = new Content("Meta Update Test Content"), + Meta = JsonSerializer.SerializeToElement(new Dictionary + { + ["wordpresspcl_test_meta"] = "initial-value" + }), + }; + + Post createdPost = await _clientAuth.Posts.CreateAsync(post, TestContext.CancellationToken); + + // Act — update only the meta field + string updatedMetaValue = $"updated-{System.Guid.NewGuid()}"; + Post updateRequest = new() + { + Id = createdPost.Id, + Meta = JsonSerializer.SerializeToElement(new Dictionary + { + ["wordpresspcl_test_meta"] = updatedMetaValue + }), + }; + await _clientAuth.Posts.UpdateAsync(updateRequest, TestContext.CancellationToken); + + // Assert — fetch back and verify + Post fetchedPost = await _clientAuth.Posts.GetByIdAsync(createdPost.Id, cancellationToken: TestContext.CancellationToken); + Assert.IsNotNull(fetchedPost.Meta, "Meta should not be null after update"); + string? readValue = fetchedPost.Meta.Value.GetProperty("wordpresspcl_test_meta").GetString(); + Assert.AreEqual(updatedMetaValue, readValue, "Meta value should reflect the updated value"); + } + public TestContext TestContext { get; set; } = null!; }