Skip to content

Commit dd157d6

Browse files
Release v6.2.0 (#825)
* Version 6.1.5 * Update abilities guide with examples for other object references (#827) * Protect against Notes_Helper fatal errors during plugin updates (#826) * Ability Rest API Integration (#828) * Ability Rest API Integration * Use wp_get_ability * Test updates * Update namespaces * Tests * More tests * Tests * Finish tests * Update implementation guide * Remove unused param * Correctly set schema * Update changelog * Change permission callback * Release v6.2.0 (#829) * Version 6.2.0 * Adjust changelog --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Ashley Gibson <agibson@godaddy.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Ashley Gibson <99189195+agibson-godaddy@users.noreply.github.com> Co-authored-by: Ashley Gibson <agibson@godaddy.com>
1 parent d4e8d45 commit dd157d6

185 files changed

Lines changed: 2542 additions & 619 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/guides/ABILITIES-IMPLEMENTATION-GUIDE.md

Lines changed: 291 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Instructions and examples for adding WordPress Abilities API support to SkyVerge
1212
- A Provider class that registers ability categories and classes
1313
- One ability class per operation (get, list, delete, search, etc.)
1414
- JSON serializers for domain objects
15+
- Optional REST endpoint configuration via `RestConfig` (with optional input/output adapters)
1516
- Unit tests for all of the above
1617
- A QA document with copy-and-pasteable verification snippets
1718

@@ -25,11 +26,12 @@ Instructions and examples for adding WordPress Abilities API support to SkyVerge
2526
4. [Step 1: JSON Serialization](#step-1-json-serialization)
2627
5. [Step 2: Provider](#step-2-provider)
2728
6. [Step 3: Individual Abilities](#step-3-individual-abilities)
28-
7. [Step 4: Test Infrastructure](#step-4-test-infrastructure)
29-
8. [Step 5: Unit Tests](#step-5-unit-tests)
30-
9. [Step 6: QA Steps](#step-6-qa-steps)
31-
10. [Annotations Reference](#annotations-reference)
32-
11. [Checklist](#checklist)
29+
7. [Step 4: REST Endpoints (Optional)](#step-4-rest-endpoints-optional)
30+
8. [Step 5: Test Infrastructure](#step-5-test-infrastructure)
31+
9. [Step 6: Unit Tests](#step-6-unit-tests)
32+
10. [Step 7: QA Steps](#step-7-qa-steps)
33+
11. [Annotations Reference](#annotations-reference)
34+
12. [Checklist](#checklist)
3335

3436
---
3537

@@ -65,8 +67,11 @@ src/
6567
│ │ ├── Delete{Entity}.php # Destructive ability
6668
│ │ └── Search{Entities}By{Field}.php # Search/filter ability
6769
│ ├── Adapters/
68-
│ │ └── JsonSerializers/
69-
│ │ └── {Entity}Serializer.php # Converts domain object → array + schema
70+
│ │ ├── JsonSerializers/
71+
│ │ │ └── {Entity}Serializer.php # Converts domain object → array + schema
72+
│ │ └── Rest/ # Optional REST adapters
73+
│ │ ├── {Action}InputAdapter.php # Transforms REST request → ability input
74+
│ │ └── {Action}OutputAdapter.php # Transforms ability output → REST response
7075
│ └── Exceptions/ # Domain-specific exceptions (optional)
7176
tests/
7277
├── bootstrap.php
@@ -148,7 +153,8 @@ new Ability(
148153
array $inputSchema = [], // JSON Schema for input
149154
array $outputSchema = [], // JSON Schema for output
150155
?AbilityAnnotations $annotations = null,
151-
bool $showInRest = true
156+
bool $showInRest = true,
157+
?RestConfig $restConfig = null // Optional REST endpoint configuration
152158
);
153159

154160
// AbilityAnnotations constructor:
@@ -165,6 +171,16 @@ new AbilityCategory(
165171
string $description,
166172
array $meta = []
167173
);
174+
175+
// RestConfig constructor (optional — see Step 4):
176+
new RestConfig(
177+
string $path, // REST route path, e.g. '/entities/(?P<id>\d+)'
178+
?string $namespace = null, // Namespace prefix, or null to auto-derive from ability name
179+
string $version = 'v1', // Version segment
180+
?string $method = null, // HTTP method, or null to infer from annotations
181+
?string $inputAdapter = null, // class-string<RestInputAdapterContract>
182+
?string $outputAdapter = null // class-string<RestOutputAdapterContract>
183+
);
168184
```
169185

170186
---
@@ -282,6 +298,49 @@ class WC_My_Plugin_Entity implements JsonSerializable
282298
- 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.
283299
- Descriptions should be short, practical, and include format hints (e.g. `"Country code (e.g. \"US\")."`).
284300

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+
285344
---
286345

287346
## Step 2: Provider
@@ -597,7 +656,224 @@ class SearchEntitiesByAddress implements MakesAbilityContract
597656

598657
---
599658

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+)'
713+
?string $namespace = null, // Namespace prefix. Null = auto-derived from ability name.
714+
string $version = 'v1', // Version segment appended to namespace.
715+
?string $method = null, // HTTP method. Null = inferred from annotations.
716+
?string $inputAdapter = null, // Class-string implementing RestInputAdapterContract.
717+
?string $outputAdapter = null // Class-string implementing RestOutputAdapterContract.
718+
);
719+
```
720+
721+
### Namespace resolution
722+
723+
When `$namespace` is null, the framework derives it from the ability name:
724+
- Extracts the segment before the first `/` in the ability name
725+
- Shortens `woocommerce-` prefix to `wc-`
726+
- Appends `/$version`
727+
728+
Examples:
729+
730+
| Ability name | Derived namespace |
731+
|---|---|
732+
| `woocommerce-memberships-for-teams/teams-get` | `wc-memberships-for-teams/v1` |
733+
| `woocommerce-local-pickup-plus/pickup-locations-list` | `wc-local-pickup-plus/v1` |
734+
| `my-custom-plugin/widgets-create` | `my-custom-plugin/v1` |
735+
736+
Override with an explicit namespace when the default isn't appropriate:
737+
738+
```php
739+
new RestConfig('/entities', 'my-custom-ns', 'v2')
740+
// Registers at: my-custom-ns/v2/entities
741+
```
742+
743+
### HTTP method inference
744+
745+
When `$method` is null, the framework infers it from `AbilityAnnotations`:
746+
747+
| Annotation | Inferred method |
748+
|---|---|
749+
| `readonly = true` | `GET` |
750+
| `destructive = true` | `DELETE` |
751+
| Neither (default) | `POST` |
752+
753+
Set `$method` explicitly when the inference doesn't match your needs:
754+
755+
```php
756+
new RestConfig('/entities/(?P<id>\d+)', null, 'v1', 'PUT')
757+
```
758+
759+
### Default input extraction
760+
761+
When no input adapter is provided, the registrar extracts input from the request based on the ability's `inputSchema`:
762+
763+
- **Object-type schema** (`'type' => 'object'`): passes `$request->get_params()` (all params as array).
764+
- **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
781+
{
782+
public function adapt(WP_REST_Request $request)
783+
{
784+
return [
785+
'name' => $request->get_param('name'),
786+
'type' => $request->get_param('type'),
787+
'options' => $request->get_param('options') ?? [],
788+
];
789+
}
790+
}
791+
```
792+
793+
**Output adapter example:**
794+
795+
```php
796+
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
863+
```
864+
865+
**Delete by ID (scalar input):**
866+
867+
```php
868+
// Route: DELETE /wc-your-plugin/v1/entities/(?P<entity_id>\d+)
869+
new RestConfig('/entities/(?P<entity_id>\d+)')
870+
// Input schema: ['type' => 'integer'] → extracts entity_id as int
871+
// Annotations: destructive → DELETE
872+
```
873+
874+
---
875+
876+
## Step 5: Test Infrastructure
601877

602878
### bootstrap.php
603879

@@ -701,7 +977,7 @@ trait CanAssertAbilityPermissionCallbackTrait
701977

702978
---
703979

704-
## Step 5: Unit Tests
980+
## Step 6: Unit Tests
705981

706982
Every ability needs three types of test methods, plus the Provider gets its own test.
707983

@@ -939,7 +1215,7 @@ final class EntitySerializerTest extends TestCase
9391215

9401216
---
9411217

942-
## Step 6: QA Steps
1218+
## Step 7: QA Steps
9431219

9441220
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.
9451221

@@ -1124,6 +1400,10 @@ When adding abilities to a plugin, verify each item:
11241400
- [ ] All abilities use `current_user_can()` in their permission callback
11251401
- [ ] Unless explicitly otherwise specified, permission callback should require `manage_woocommerce` capability
11261402
- [ ] `showInRest` is `true` for all abilities exposed to the REST API
1403+
- [ ] (If REST) Abilities that need HTTP endpoints have a `RestConfig` as the last `Ability` constructor argument
1404+
- [ ] (If REST) `RestConfig` path uses WordPress regex capture groups for URL params, e.g. `(?P<entity_id>\d+)`
1405+
- [ ] (If REST) HTTP method is correctly inferred from annotations or explicitly set
1406+
- [ ] (If REST) Adapter classes (if any) implement `RestInputAdapterContract` / `RestOutputAdapterContract` and have no-arg constructors
11271407
- [ ] `bootstrap.php` loads domain classes with `mockStaticMethod`-able statics **after** Patchwork init
11281408
- [ ] `WP_Error` mock exists in `tests/Mocks/`
11291409
- [ ] `CanAssertAbilityPermissionCallbackTrait` exists and is used

composer.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "skyverge/wc-plugin-framework",
33
"description": "The official SkyVerge WooCommerce plugin framework",
4-
"version": "6.1.4",
4+
"version": "6.2.0",
55
"license": "GPL-3.0",
66
"minimum-stability": "dev",
77
"prefer-stable": true,
@@ -35,12 +35,12 @@
3535
"woocommerce/class-sv-wp-admin-message-handler.php"
3636
],
3737
"psr-4": {
38-
"SkyVerge\\WooCommerce\\PluginFramework\\v6_1_4\\": "woocommerce/"
38+
"SkyVerge\\WooCommerce\\PluginFramework\\v6_2_0\\": "woocommerce/"
3939
}
4040
},
4141
"autoload-dev": {
4242
"psr-4": {
43-
"SkyVerge\\WooCommerce\\PluginFramework\\v6_1_4\\Tests\\": "tests/"
43+
"SkyVerge\\WooCommerce\\PluginFramework\\v6_2_0\\Tests\\": "tests/"
4444
}
4545
},
4646
"config": {

0 commit comments

Comments
 (0)