Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
14 changes: 14 additions & 0 deletions dev/init-wordpress.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'
<?php
add_action('rest_api_init', function () {
register_post_meta('post', 'wordpresspcl_test_meta', [
'type' => 'string',
'single' => true,
'show_in_rest' => true,
'default' => '',
]);
});
EOF

touch "$WP_READY_FILE"
echo "WordPress test environment is ready."
4 changes: 2 additions & 2 deletions docs/v2/entities/posts.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ if (await client.Auth.IsValidJWTokenAsync())
var post = new Post
{
Id = 123,
Meta = new Dictionary<string, string>
Meta = JsonSerializer.SerializeToElement(new Dictionary<string, object?>
{
["my-custom-key"] = "some value"
},
}),
};

await client.Posts.UpdateAsync(post);
Expand Down
196 changes: 196 additions & 0 deletions docs/v3/entities/customFields.md
Original file line number Diff line number Diff line change
@@ -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` |

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This section claims the Meta property is available only on the listed models, but the codebase also exposes Meta on term models like Category and Tag. Either expand the list to include those models/endpoints (term meta) or clarify that the list is limited to the primary entities covered by this doc.

Suggested change
| `User` | `/wp/v2/users` |
| `User` | `/wp/v2/users` |
| `Category` | `/wp/v2/categories` |
| `Tag` | `/wp/v2/tags` |

Copilot uses AI. Check for mistakes.

Comment on lines +76 to +78

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The table lists the PostRevision endpoint as /wp/v2/revisions, but this library’s revisions client routes under the parent post (e.g. wp/v2/posts/{postId}/revisions). Please update the endpoint column (and consider adding a short note that revisions are nested under posts).

Suggested change
| `PostRevision` | `/wp/v2/revisions` |
| `User` | `/wp/v2/users` |
| `PostRevision` | `/wp/v2/posts/{postId}/revisions` |
| `User` | `/wp/v2/users` |
> **Note:** Revisions are nested under their parent post, so the revisions route includes the post ID.

Copilot uses AI. Check for mistakes.
## 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<PostMeta>();
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<string, object?>
{
["my_color"] = "blue"
}),
};

await client.Posts.UpdateAsync(post);
```

### Update multiple meta keys

```csharp
Post post = new Post
{
Id = 123,
Meta = JsonSerializer.SerializeToElement(new Dictionary<string, object?>
{
["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<PostWithAcf>("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)
6 changes: 4 additions & 2 deletions docs/v3/entities/posts.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,10 @@ if (await client.IsValidJWTokenAsync())
Post post = new Post
{
Id = 123,
Meta = new Dictionary<string, string>
Meta = JsonSerializer.SerializeToElement(new Dictionary<string, object?>
{
["my-custom-key"] = "some value"
},
}),
};

await client.Posts.UpdateAsync(post);
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -35,7 +36,7 @@


Assert.AreEqual(post.Content!.Raw, createdPost.Content!.Raw);
Assert.Contains(post.Content.Rendered, createdPost.Content!.Rendered);

Check warning on line 39 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Possible null reference argument for parameter 'value' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 39 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Possible null reference argument for parameter 'substring' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 39 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Possible null reference argument for parameter 'value' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 39 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Possible null reference argument for parameter 'substring' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 39 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'value' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 39 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'substring' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 39 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'value' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 39 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'substring' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 39 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'value' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 39 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'substring' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.
}

[TestMethod]
Expand All @@ -62,7 +63,7 @@
Assert.Contains(x => x.Title!.Rendered == title, postsTask);

Assert.AreEqual(post.Content!.Raw, createdPost.Content!.Raw);
Assert.Contains(post.Content.Rendered, createdPost.Content!.Rendered);

Check warning on line 66 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Possible null reference argument for parameter 'substring' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 66 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Possible null reference argument for parameter 'value' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 66 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Possible null reference argument for parameter 'substring' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 66 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'value' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 66 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'substring' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 66 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'substring' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 66 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'value' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 66 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'substring' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.
}

[TestMethod]
Expand Down Expand Up @@ -136,7 +137,7 @@
post.Content!.Raw = testContent;
Post updatedPost = await _clientAuth.Posts.UpdateAsync(post, TestContext.CancellationToken);
Assert.AreEqual(updatedPost.Content!.Raw, testContent);
Assert.Contains(testContent, updatedPost.Content!.Rendered);

Check warning on line 140 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

Possible null reference argument for parameter 'value' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 140 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'value' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.

Check warning on line 140 in tests/WordPressPCL.Tests.Selfhosted/Posts_Tests.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'value' in 'void Assert.Contains(string substring, string value, string? message = "", string substringExpression = "", string valueExpression = "")'.
}

[TestMethod]
Expand Down Expand Up @@ -227,5 +228,65 @@
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<string, object?>
{
["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);

Comment on lines +246 to +249

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetByIdAsync defaults to useAuth: false and also doesn’t let you request context=edit. Since meta fields are often only returned in edit context for authenticated requests, this test can intermittently read an empty/omitted Meta even though the write succeeded. Consider fetching the created post via Posts.QueryAsync with new PostsQueryBuilder { Include = [createdPost.Id], Context = Context.Edit } and useAuth: true (or use CustomRequest against wp/v2/posts/{id}?context=edit with useAuth: true).

Copilot uses AI. Check for mistakes.
// 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");
Comment on lines +251 to +253

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchedPost.Meta.Value.GetProperty("wordpresspcl_test_meta") will throw if WordPress returns an empty {} (or the key is absent) even when Meta itself is non-null. To make failures clearer and avoid an unhandled KeyNotFoundException, use TryGetProperty and assert the property exists before reading the string value.

Copilot uses AI. Check for mistakes.
}

[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<string, object?>
{
["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<string, object?>
{
["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);

Copilot AI Apr 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as above: the verification fetch uses GetByIdAsync without useAuth: true and can’t set context=edit, which may prevent the updated meta from being returned. Prefer querying by Include with Context = Context.Edit and useAuth: true (or a CustomRequest to ...?context=edit).

Suggested change
Post fetchedPost = await _clientAuth.Posts.GetByIdAsync(createdPost.Id, cancellationToken: TestContext.CancellationToken);
List<Post> fetchedPosts = (await _clientAuth.Posts.QueryAsync(new PostsQueryBuilder
{
Include = new List<int> { createdPost.Id },
Context = Context.Edit
}, useAuth: true, cancellationToken: TestContext.CancellationToken)).ToList();
Post fetchedPost = fetchedPosts.Single();

Copilot uses AI. Check for mistakes.
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!;
}
Loading