You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
@@ -282,6 +298,49 @@ class WC_My_Plugin_Entity implements JsonSerializable
282
298
- Use `minimum`, `minProperties`, `additionalProperties` where appropriate to enforce constraints. Arguments that accept any WP_Query arg should document "VIP" properties and then set `additionalProperties` to `true` to indicate any WP_Query arg can be used.
283
299
- Descriptions should be short, practical, and include format hints (e.g. `"Country code (e.g. \"US\")."`).
284
300
301
+
### Nested and sub-object serialization
302
+
303
+
When your primary serializable object references other objects (e.g. an entity has rules, items, or addresses), the first consideration should be making those sub-objects serializable as well. This keeps serialization logic co-located with the class that understands its own structure.
304
+
305
+
**Avoid** inline serialization of sub-objects:
306
+
307
+
```php
308
+
public function jsonSerialize()
309
+
{
310
+
return [
311
+
'id' => $this->get_id(),
312
+
'rules' => array_map(function ($rule) {
313
+
return [
314
+
'property' => $rule->get_property(),
315
+
'operator' => $rule->get_operator(),
316
+
'values' => $rule->get_values(),
317
+
];
318
+
}, $this->get_rules()),
319
+
];
320
+
}
321
+
```
322
+
323
+
**Instead**, make the sub-object serializable and delegate to it:
324
+
325
+
```php
326
+
public function jsonSerialize()
327
+
{
328
+
return [
329
+
'id' => $this->get_id(),
330
+
'rules' => array_map(function ($rule) {
331
+
return $rule->jsonSerialize();
332
+
}, $this->get_rules()),
333
+
];
334
+
}
335
+
```
336
+
337
+
This applies to both Option A and Option B. If the sub-object class has an abstract base class (e.g. a `Rule` base with `CartSubtotal`, `ProductOrCategory` concrete types), implement `JsonSerializable` on the base class with sensible defaults so that:
338
+
339
+
-**Concrete subclasses** override only when their structure differs from the default (e.g. a rule with min/max values instead of a generic property/operator/values shape).
340
+
-**Third-party subclasses** (from extensions or filters) get working serialization for free without needing to know about the contract.
341
+
342
+
The same principle applies to `getJsonSchema()` — put defaults on the base class and only override where the subclass has genuinely different behavior.
343
+
285
344
---
286
345
287
346
## Step 2: Provider
@@ -597,7 +656,224 @@ class SearchEntitiesByAddress implements MakesAbilityContract
597
656
598
657
---
599
658
600
-
## Step 4: Test Infrastructure
659
+
## Step 4: REST Endpoints (Optional)
660
+
661
+
Abilities can optionally be exposed as WordPress REST API endpoints by attaching a `RestConfig` to the `Ability` data object. When present, the framework automatically registers a `register_rest_route()` call that executes the ability through `WP_Ability::execute()`, so WP core handles input/output validation, permission checks, and lifecycle hooks.
662
+
663
+
**When to use this:** When clients (e.g. admin UIs, external integrations, or block editors) need to invoke an ability over HTTP rather than through PHP's `wp_get_ability()->execute()`.
664
+
665
+
**Key design points:**
666
+
- One ability = one HTTP method = one REST endpoint. If you need both GET and DELETE for an entity, those are separate abilities with separate `RestConfig` instances.
667
+
- The ability's existing `permissionCallback` is reused as the route's `permission_callback` — no duplicate auth logic.
668
+
- Input/output schemas from the ability are propagated to the REST route as `args` and `schema`.
669
+
670
+
### Adding RestConfig to an ability
671
+
672
+
Pass a `RestConfig` as the last argument to the `Ability` constructor:
673
+
674
+
```php
675
+
use SkyVerge\WooCommerce\PluginFramework\v6_1_2\Abilities\DataObjects\Ability;
676
+
use SkyVerge\WooCommerce\PluginFramework\v6_1_2\Abilities\DataObjects\AbilityAnnotations;
677
+
use SkyVerge\WooCommerce\PluginFramework\v6_1_2\Abilities\DataObjects\RestConfig;
678
+
679
+
class GetEntity implements MakesAbilityContract
680
+
{
681
+
const NAME = 'your-plugin-slug/entities-get';
682
+
683
+
public function makeAbility(): Ability
684
+
{
685
+
return new Ability(
686
+
static::NAME,
687
+
__('Get Entity', 'your-text-domain'),
688
+
__('Retrieves an entity by ID.', 'your-text-domain'),
689
+
Provider::ENTITY_CATEGORY_SLUG,
690
+
function (int $entityId) {
691
+
// ... execute logic ...
692
+
},
693
+
function () {
694
+
return current_user_can('manage_woocommerce');
695
+
},
696
+
$this->getInputSchema(),
697
+
WC_My_Plugin_Entity::getJsonSchema(),
698
+
new AbilityAnnotations(true, false, true),
699
+
true,
700
+
new RestConfig('/entities/(?P<entity_id>\d+)')
701
+
);
702
+
}
703
+
}
704
+
```
705
+
706
+
This registers a route at `GET /wc-your-plugin/v1/entities/(?P<entity_id>\d+)`. The HTTP method is inferred from the annotations (`readonly` = GET).
707
+
708
+
### RestConfig parameters
709
+
710
+
```php
711
+
new RestConfig(
712
+
string $path, // Required. REST route path, e.g. '/entities' or '/entities/(?P<id>\d+)'
-**Scalar-type schema** (e.g. `'type' => 'integer'`): extracts the first URL param and casts it. For example, a route with `(?P<entity_id>\d+)` and an integer input schema passes the entity ID as an `int` to the execute callback.
765
+
766
+
### Input and output adapters
767
+
768
+
For cases where the default input/output handling isn't sufficient, provide adapter class-strings. These must implement `RestInputAdapterContract` or `RestOutputAdapterContract`.
769
+
770
+
**When to use adapters:**
771
+
- The REST request shape differs from what the execute callback expects (e.g. combining URL params and body params into a specific structure).
772
+
- The ability result needs reshaping before becoming a REST response (e.g. wrapping in a pagination envelope, or converting domain objects to a different format).
773
+
774
+
**Input adapter example:**
775
+
776
+
```php
777
+
use SkyVerge\WooCommerce\PluginFramework\v6_1_2\Abilities\Contracts\RestInputAdapterContract;
778
+
use WP_REST_Request;
779
+
780
+
class CreateEntityInputAdapter implements RestInputAdapterContract
use SkyVerge\WooCommerce\PluginFramework\v6_1_2\Abilities\Contracts\RestOutputAdapterContract;
797
+
798
+
class ListEntitiesOutputAdapter implements RestOutputAdapterContract
799
+
{
800
+
public function adapt($result)
801
+
{
802
+
return [
803
+
'items' => array_map(function ($entity) {
804
+
return $entity->jsonSerialize();
805
+
}, $result),
806
+
'total' => count($result),
807
+
];
808
+
}
809
+
}
810
+
```
811
+
812
+
**Wiring adapters into RestConfig:**
813
+
814
+
```php
815
+
new RestConfig(
816
+
'/entities',
817
+
null,
818
+
'v1',
819
+
'POST',
820
+
CreateEntityInputAdapter::class,
821
+
null
822
+
)
823
+
```
824
+
825
+
Adapter classes are instantiated by the registrar at request time. They must have a no-argument constructor.
826
+
827
+
### Default output serialization
828
+
829
+
When no output adapter is provided, the registrar handles serialization automatically:
830
+
-`JsonSerializable` objects are serialized via `->jsonSerialize()`.
831
+
- Arrays of `JsonSerializable` objects are mapped through `->jsonSerialize()`.
832
+
- Scalar values and plain arrays are passed through unchanged.
833
+
834
+
This means abilities that already return `JsonSerializable` domain objects (as recommended in [Step 1](#step-1-json-serialization)) need no output adapter.
835
+
836
+
### Common patterns
837
+
838
+
**Get by ID (scalar input, single object output):**
839
+
840
+
```php
841
+
// Route: GET /wc-your-plugin/v1/entities/(?P<entity_id>\d+)
842
+
new RestConfig('/entities/(?P<entity_id>\d+)')
843
+
// Input schema: ['type' => 'integer'] → extracts entity_id as int
844
+
// Annotations: readonly → GET
845
+
```
846
+
847
+
**List with query params (object input, array output):**
848
+
849
+
```php
850
+
// Route: GET /wc-your-plugin/v1/entities
851
+
new RestConfig('/entities')
852
+
// Input schema: ['type' => 'object', 'properties' => [...]] → passes all params as array
853
+
// Annotations: readonly → GET
854
+
```
855
+
856
+
**Create (object input, single object output):**
857
+
858
+
```php
859
+
// Route: POST /wc-your-plugin/v1/entities
860
+
new RestConfig('/entities')
861
+
// Input schema: ['type' => 'object', ...] → passes body params as array
862
+
// Annotations: not readonly, not destructive → POST
Every ability needs three types of test methods, plus the Provider gets its own test.
707
983
@@ -939,7 +1215,7 @@ final class EntitySerializerTest extends TestCase
939
1215
940
1216
---
941
1217
942
-
## Step 6: QA Steps
1218
+
## Step 7: QA Steps
943
1219
944
1220
Write a `QA.md` file in the plugin root. The purpose is to provide copy-and-pasteable PHP snippets that can be included in a GitHub PR for manual testing. Each ability section should cover a few logical scenarios (happy path, error paths, notable edge cases) without being excessive.
945
1221
@@ -1124,6 +1400,10 @@ When adding abilities to a plugin, verify each item:
1124
1400
-[ ] All abilities use `current_user_can()` in their permission callback
0 commit comments