diff --git a/.github/workflows/WP_6_6.yaml b/.github/workflows/WP_6_6.yaml index a573612..38d5ace 100644 --- a/.github/workflows/WP_6_6.yaml +++ b/.github/workflows/WP_6_6.yaml @@ -63,5 +63,9 @@ jobs: environment_github: true run: composer all - - name: Codecov - run: bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_REST }} + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_REST }} + files: ./clover.xml + fail_ci_if_error: false diff --git a/.github/workflows/WP_6_7.yaml b/.github/workflows/WP_6_7.yaml index 1fe869b..906e9b4 100644 --- a/.github/workflows/WP_6_7.yaml +++ b/.github/workflows/WP_6_7.yaml @@ -63,5 +63,9 @@ jobs: environment_github: true run: composer all - - name: Codecov - run: bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_REST }} + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_REST }} + files: ./clover.xml + fail_ci_if_error: false diff --git a/.github/workflows/WP_6_8.yaml b/.github/workflows/WP_6_8.yaml index 061fe20..e51fd3b 100644 --- a/.github/workflows/WP_6_8.yaml +++ b/.github/workflows/WP_6_8.yaml @@ -63,5 +63,9 @@ jobs: environment_github: true run: composer all - - name: Codecov - run: bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_REST }} + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_REST }} + files: ./clover.xml + fail_ci_if_error: false diff --git a/.github/workflows/WP_6_9.yaml b/.github/workflows/WP_6_9.yaml index e2ab468..1de282b 100644 --- a/.github/workflows/WP_6_9.yaml +++ b/.github/workflows/WP_6_9.yaml @@ -63,5 +63,9 @@ jobs: environment_github: true run: composer all - - name: Codecov - run: bash <(curl -s https://codecov.io/bash) -t ${{ secrets.CODECOV_REST }} + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_REST }} + files: ./clover.xml + fail_ci_if_error: false diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..f45b34a --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,85 @@ +checks: + php: + code_rating: true + duplication: true + fix_php_opening_tag: false + remove_php_closing_tag: false + one_class_per_file: false + side_effects_or_types: false + no_mixed_inline_html: false + require_braces_around_control_structures: false + php5_style_constructor: false + no_global_keyword: false + avoid_usage_of_logical_operators: false + psr2_class_declaration: false + no_underscore_prefix_in_properties: false + no_underscore_prefix_in_methods: false + blank_line_after_namespace_declaration: false + single_namespace_per_use: false + psr2_switch_declaration: false + psr2_control_structure_declaration: false + avoid_superglobals: false + security_vulnerabilities: false + no_exit: false + +build: + dependencies: + override: + - 'composer install --no-interaction --prefer-dist' + nodes: + analysis: + project_setup: + override: + - 'true' + tests: + override: + - php-scrutinizer-run + +tools: + php_analyzer: + enabled: true + filter: + excluded_paths: ['tests/*', 'docs/*', 'template/*', 'node_modules/*', 'vendor/*'] + config: + checkstyle: + enabled: true + naming: + isser_method_name: ^.*$ + utility_class_name: ^.*$ + doc_comment_fixes: + enabled: false + reflection_fixes: + enabled: false + use_statement_fixes: + enabled: false + simplify_boolean_return: + enabled: true + php_changetracking: true + php_cpd: true + php_cs_fixer: false + php_mess_detector: true + php_pdepend: true + sensiolabs_security_checker: true + +filter: + paths: + - 'src/*' + excluded_paths: + - 'tests/*' + - 'docs/*' + - 'docs-gen/*' + - 'node_modules/*' + - 'vendor/*' + - 'template/*' + +coding_style: + php: + indentation: + general: + use_tabs: true + size: 4 + spaces: + before_parentheses: + closure_definition: true + around_operators: + concatenation: true diff --git a/README.md b/README.md index 9fb036b..5b132d7 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ A fluent PHP builder for WordPress REST API JSON schemas. Converts verbose neste [![WordPress 6.7 Test Suite [PHP8.0-8.4]](https://github.com/Pink-Crab/WP_Rest_Schema/actions/workflows/WP_6_7.yaml/badge.svg)](https://github.com/Pink-Crab/WP_Rest_Schema/actions/workflows/WP_6_7.yaml) [![WordPress 6.8 Test Suite [PHP8.0-8.4]](https://github.com/Pink-Crab/WP_Rest_Schema/actions/workflows/WP_6_8.yaml/badge.svg)](https://github.com/Pink-Crab/WP_Rest_Schema/actions/workflows/WP_6_8.yaml) [![WordPress 6.9 Test Suite [PHP8.0-8.4]](https://github.com/Pink-Crab/WP_Rest_Schema/actions/workflows/WP_6_9.yaml/badge.svg)](https://github.com/Pink-Crab/WP_Rest_Schema/actions/workflows/WP_6_9.yaml) +[![codecov](https://codecov.io/gh/Pink-Crab/WP_Rest_Schema/graph/badge.svg?token=KER9nANRi2)](https://codecov.io/gh/Pink-Crab/WP_Rest_Schema) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/Pink-Crab/WP_Rest_Schema/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/Pink-Crab/WP_Rest_Schema/?branch=master) **** @@ -74,11 +76,13 @@ $ composer require pinkcrab/wp-rest-schema | Boolean | `Boolean_Type` | *No type-specific methods* | | Null | `Null_Type` | *No type-specific methods* | | Array | `Array_Type` | `string_item()`, `integer_item()`, `min_items()`, `max_items()`, `unique_items()`, `any_of()`, `one_of()` | -| Object | `Object_Type` | `string_property()`, `integer_property()`, `additional_properties()`, `pattern_properties()`, `min_properties()`, `max_properties()` | +| Object | `Object_Type` | `string_property()`, `integer_property()`, `additional_properties()`, `pattern_properties()`, `min_properties()`, `max_properties()`, `required_properties()` | +| OneOf | `One_Of_Type` | `variant( Argument )` — schema-root `oneOf` combinator (exactly one variant must match) | +| AnyOf | `Any_Of_Type` | `variant( Argument )` — schema-root `anyOf` combinator (any variant matches) | > `Boolean_Type` and `Null_Type` inherit all shared methods from the base `Argument` class but have no additional type-specific methods. See [Argument](docs/Argument.md) for the full shared API. -All types share a common set of methods for `description`, `default`, `required`, `readonly`, `title`, `format`, `expected` (enum), `context`, `validation`, `sanitization`, and `union types`. See [Argument (Base Class)](docs/Argument.md) for details. +All types share a common set of methods for `description`, `default`, `required`, `readonly`, `title`, `format`, `expected` (enum, accepts mixed), `context`, `validation`, `sanitization`, `arg_options` (WP controller pass-through), and `union types`. See [Argument (Base Class)](docs/Argument.md) for details. **** @@ -211,5 +215,6 @@ http://www.opensource.org/licenses/mit-license.html ## Change Log +* 1.0.0 - Combinators (`One_Of_Type` / `Any_Of_Type`), null/falsy defaults, array items last-wins, `arg_options`, `required_properties`, mixed enums, `Schema::get_context_param()`, output-keys canary test; deprecated `name()` and `all_of()`. * 1.0.0-RC1 - Added Schema builder, readonly/title support, field() alias, for_route() helper, additionalProperties redesign, FORMAT_URI fix, PHPStan level 9, WPCS 3, PHP 8.0+/WP 6.6+ * 0.1.0 - Initial version diff --git a/docs/Argument.md b/docs/Argument.md index c0df85d..8354eda 100644 --- a/docs/Argument.md +++ b/docs/Argument.md @@ -53,6 +53,39 @@ $arg = String_Type::field( 'value' ) // type becomes ['string', 'null'] ``` +### Combinators vs unions — `One_Of_Type` / `Any_Of_Type` + +Use `union_with_type()` when the value can be one of several primitive types but shares the SAME attributes (`minLength`, `minimum`, etc.). Use `One_Of_Type` / `Any_Of_Type` when each variant needs its OWN distinct attributes. + +**Union — shared attributes, multi-primitive type array:** + +```php +String_Type::on( 'slug' ) + ->min_length( 3 ) + ->union_with_type( 'null' ); + +// Emits: ['slug' => ['type' => ['string','null'], 'minLength' => 3]] +``` + +Here `minLength: 3` applies to both members of the union. + +**Combinator — distinct per-variant attributes:** + +```php +One_Of_Type::on( 'thing' ) + ->variant( String_Type::on( 'thing' )->min_length( 3 ) ) + ->variant( Integer_Type::on( 'thing' )->minimum( 1 ) ); + +// Emits: ['thing' => ['oneOf' => [ +// ['type' => 'string', 'minLength' => 3], +// ['type' => 'integer', 'minimum' => 1], +// ]]] +``` + +`minLength` only applies to the string variant; `minimum` only to the integer variant. Union can't express that. + +`One_Of_Type` requires EXACTLY ONE variant to match (`rest_find_one_matching_schema`). `Any_Of_Type` accepts ANY variant that matches (`rest_find_any_matching_schema`). Both emit with no outer `type` — the combinator keyword sits at the schema root. + ### `description( string $description ): self` Set a human-readable description for the argument. @@ -64,13 +97,27 @@ $arg = String_Type::field( 'email' ) ### `default( mixed $default ): self` -Set a default value for the argument. +Set a default value for the argument. Accepts `string`, `int`, `float`, `bool`, `array`, `object`, or `null` — whatever matches the schema's declared `type` (or one of its union members). ```php $arg = Integer_Type::field( 'page' ) ->default( 1 ); ``` +Falsy and null values are fully supported — `default(false)`, `default(0)`, `default('')`, `default(null)` all count as explicit defaults and emit as `'default' => ` in the parsed schema. `has_default()` will return `true` after any of them. + +```php +$flag = Boolean_Type::field( 'enabled' )->default( false ); +// Emits: ['enabled' => ['type' => 'boolean', 'default' => false]] + +$nullable = String_Type::field( 'slug' ) + ->union_with_type( Argument::TYPE_NULL ) + ->default( null ); +// Emits: ['slug' => ['type' => ['string','null'], 'default' => null]] +``` + +An argument with no `default()` call will NOT emit a `default` key at all. + ### `required( bool $required = true ): self` Mark the argument as required. Defaults to `true` when called without a parameter. @@ -109,10 +156,17 @@ $arg = String_Type::field( 'email' ) Set the allowed values (maps to `enum` in the schema). Multiple calls are additive. +Accepts any JSON-comparable value — strings, ints, floats, booleans, `null`, arrays, and objects — matching WP's `in_array(..., $enum, true)` semantics. + ```php $arg = String_Type::field( 'status' ) ->expected( 'publish', 'draft', 'pending' ); +// Null + string mix (for union schemas that include 'null'): +$arg = String_Type::field( 'slug' ) + ->union_with_type( 'null' ) + ->expected( 'home', 'about', null ); + // Can also be called multiple times $arg = String_Type::field( 'status' ) ->expected( 'publish' ) @@ -128,15 +182,28 @@ $arg = Integer_Type::field( 'id' ) ->context( 'view', 'edit', 'embed' ); ``` -### `name( string $name ): self` +### ~~`name( string $name ): self`~~ *(deprecated)* + +> **Deprecated in 1.0.0** — calling `name()` triggers `E_USER_DEPRECATED` and does nothing. +> +> `name` was never a valid JSON Schema / WP REST keyword and was leaking from internal child-indexing into the parsed output. Removed from emission; the setter is kept as a no-op for backwards compatibility and will be removed in a future release. For user-facing display labels use [`title()`](#title-string-title-self) instead. + +### `arg_options( array $options ): self` -Set a display name for the argument. +Attach a raw `arg_options` array to the argument. The array is emitted verbatim under the `arg_options` key in the parsed schema, letting WP REST controllers override `sanitize_callback` / `validate_callback` (or any other WP arg option) per property without replacing the callbacks on the Argument itself. ```php -$arg = String_Type::field( 'email' ) - ->name( 'Email Address' ); +$arg = String_Type::field( 'slug' ) + ->arg_options( + array( + 'sanitize_callback' => 'sanitize_title', + 'validate_callback' => 'rest_validate_request_arg', + ) + ); ``` +See `WP_REST_Controller::add_additional_fields_schema()` in WP core for how this merges into route args. + ### `validation( callable $callback ): self` Set a custom validation callback. Maps to `validate_callback` in the schema. diff --git a/docs/Array-Type.md b/docs/Array-Type.md index 7cd0705..0af35a9 100644 --- a/docs/Array-Type.md +++ b/docs/Array-Type.md @@ -91,6 +91,15 @@ $custom = String_Type::field( 'value' )->min_length( 1 ); $list = Array_Type::field( 'items' )->item( $custom ); ``` +### Multiple item calls — last wins or combinator + +WP treats `items` as a SINGLE schema applied to every element; JSON Schema tuple form is not honoured. The library therefore handles repeated `*_item()` calls as follows: + +- **No combinator set (default `allOf`):** the LAST-added item wins and `items` is emitted as a single schema. Prior item calls are discarded. +- **Combinator set (`one_of()` / `any_of()`):** all item schemas are kept and emitted as a list under the combinator key, producing valid WP output such as `'items' => [ 'oneOf' => [ {schemaA}, {schemaB} ] ]`. + +For heterogeneous arrays at the SCHEMA ROOT (not nested inside `items`), use [`One_Of_Type`](Argument.md#combinators-vs-unions--one_of_type--any_of_type) / [`Any_Of_Type`](Argument.md#combinators-vs-unions--one_of_type--any_of_type) instead. + --- ## Constraint Methods diff --git a/docs/Object-Type.md b/docs/Object-Type.md index fccef62..9789c9c 100644 --- a/docs/Object-Type.md +++ b/docs/Object-Type.md @@ -165,15 +165,36 @@ $obj = Object_Type::field( 'settings' ) ->max_properties( 10 ); ``` +### `required_properties( string ...$names ): self` + +Mark specific property names as required at the parent-object level, producing the draft-4 style `required: ['a','b']` array used by most WP core `get_item_schema()` implementations. + +```php +$obj = Object_Type::field( 'user' ) + ->integer_property( 'id' ) + ->string_property( 'email' ) + ->string_property( 'nickname' ) + ->required_properties( 'id', 'email' ); + +// Emits: +// ['user' => [ +// 'type' => 'object', +// 'properties' => [ 'id' => [...], 'email' => [...], 'nickname' => [...] ], +// 'required' => [ 'id', 'email' ], +// ]] +``` + +Coexists with per-property `->required(true)` booleans — both forms are honoured by WP and may appear side-by-side if both are set. + --- ## Element Relationships Control how properties relate to each other. The default relationship is `allOf`. -### `all_of(): self` +### ~~`all_of(): self`~~ *(deprecated)* -All property schemas must match (default). +> **Deprecated in 1.0.0** — no-op. `allOf` is already the default relationship, and WP's REST validator does not honour `allOf` as a combinator anyway. Use `one_of()` / `any_of()` or the root-level [`One_Of_Type` / `Any_Of_Type`](Argument.md#combinators-vs-unions--one_of_type--any_of_type) classes instead. ### `any_of(): self` diff --git a/docs/Schema.md b/docs/Schema.md index e17aaf2..0c2b8f6 100644 --- a/docs/Schema.md +++ b/docs/Schema.md @@ -64,6 +64,39 @@ $schema = Schema::on( 'settings' ) ->additional_properties_schema( String_Type::on( 'value' ) ); ``` +### `required_properties( string ...$names ): self` + +Mark property names as required at the parent-object level. Forwards to [`Object_Type::required_properties()`](Object-Type.md#required_properties-string-names-self). Emits a draft-4 style `required: ['a','b']` array in the parsed schema. + +```php +$schema = Schema::on( 'user' ) + ->integer_property( 'id' ) + ->string_property( 'email' ) + ->required_properties( 'id', 'email' ); +``` + +### `get_context_param( array $args = array() ): array` + +Build the `context` collection param descriptor, mirroring `WP_REST_Controller::get_context_param()`. Derives `enum` from the union of `context` values set on the schema's properties (unique, reverse-sorted) and returns the standard param shape. + +```php +$schema = Schema::on( 'post' ) + ->integer_property( 'id', fn( $p ) => $p->context( 'view', 'edit', 'embed' ) ) + ->string_property( 'title', fn( $p ) => $p->context( 'view', 'edit' ) ); + +$param = $schema->get_context_param(); +// [ +// 'description' => 'Scope under which the request is made; ...', +// 'type' => 'string', +// 'sanitize_callback' => 'sanitize_key', +// 'validate_callback' => 'rest_validate_request_arg', +// 'enum' => ['view', 'embed', 'edit'], +// ] + +// With overrides: +$param = $schema->get_context_param( array( 'default' => 'view' ) ); +``` + ### `to_array(): array` Convert the schema to the array format WordPress expects. diff --git a/src/Argument/Any_Of_Type.php b/src/Argument/Any_Of_Type.php new file mode 100644 index 0000000..aa0a922 --- /dev/null +++ b/src/Argument/Any_Of_Type.php @@ -0,0 +1,38 @@ +variant( String_Type::on('thing') ) + * ->variant( Integer_Type::on('thing') ); + * // Emits: ['thing' => ['anyOf' => [{type:string}, {type:integer}]]] + * ``` + * + * @package PinkCrab\WP_Rest_Schema + * @author Glynn Quelch glynn@pinkcrab.co.uk + * @since 0.3.0 + */ + +namespace PinkCrab\WP_Rest_Schema\Argument; + +class Any_Of_Type extends Combinator_Type { + + /** + * {@inheritdoc} + * + * @var string + */ + protected $combinator_key = 'anyOf'; +} diff --git a/src/Argument/Argument.php b/src/Argument/Argument.php index ac9c8a9..7ecf03e 100644 --- a/src/Argument/Argument.php +++ b/src/Argument/Argument.php @@ -108,10 +108,31 @@ class Argument { /** * The default value * - * @var string|int|float|bool|null + * @var string|int|float|bool|array|object|null */ protected $default; + /** + * Whether a default has been explicitly set. + * + * Tracks the set-ness separately from the value so that null can be + * emitted as a default (valid when type includes 'null'). + * + * @var bool + */ + protected $has_default_set = false; + + /** + * Raw arg_options pass-through for WP REST controllers. + * + * Emitted verbatim in the parsed output under the `arg_options` key so + * controllers can override `sanitize_callback` / `validate_callback` per + * property. See `WP_REST_Controller::add_additional_fields_schema()`. + * + * @var array|null + */ + protected $arg_options; + /** * Optional format to expect value. * @@ -120,9 +141,13 @@ class Argument { protected $format; /** - * Enum of all accepted values + * Enum of all accepted values. + * + * WP compares enum entries via `in_array(..., $enum, true)` so any + * JSON-comparable value is valid — including `null`, arrays, and objects + * for schemas whose `type` accommodates them. * - * @var array|null + * @var array|null */ protected $expected; @@ -204,7 +229,7 @@ public function get_validation(): ?callable { * @return static */ public function validation( callable $validation ): self { - $this->validation = $validation; // @phpstan-ignore assign.propertyType + $this->validation = $validation; return $this; } @@ -233,7 +258,7 @@ public function sanitization( callable $sanitization ): self { /** * Get the default value * - * @return string|int|float|bool|null + * @return string|int|float|bool|array|object|null */ public function get_default() { return $this->default; @@ -245,17 +270,18 @@ public function get_default() { * @return bool */ public function has_default(): bool { - return ! is_null( $this->default ); + return $this->has_default_set; } /** * Set the default value * - * @param string|int|float|bool $default_value The default value + * @param string|int|float|bool|array|object|null $default_value The default value * @return static */ public function default( $default_value ): self { - $this->default = $default_value; + $this->default = $default_value; + $this->has_default_set = true; return $this; } @@ -421,24 +447,26 @@ public function get_attribute( string $key, $fallback = null ) { /** * Get expected of all accepted values * - * @return array|null + * @return array|null */ public function get_expected(): ?array { return $this->expected; } /** - * Set expected of all accepted values + * Set expected of all accepted values. * - * @param mixed ...$expected Accept value for argument. + * Any JSON-comparable value is accepted (null, arrays, objects included) + * since WP compares enum entries with `in_array(..., $enum, true)`. + * + * @param mixed ...$expected Accepted values for the argument. * @return static */ public function expected( ...$expected ): self { - /** @var array $merged */ $merged = is_array( $this->expected ) ? array_merge( $this->expected, $expected ) : $expected; - $this->expected = $merged; + $this->expected = array_values( $merged ); return $this; } @@ -453,13 +481,26 @@ public function get_name(): ?string { } /** - * Sets the max length of the value + * No-op setter kept for backwards compatibility. + * + * `name` is not in `rest_get_allowed_schema_keywords()` and was leaking + * from the internal child-indexing machinery into the parsed output. + * Calling this method now triggers an `E_USER_DEPRECATED` notice and + * does nothing. + * + * @deprecated 1.0.0 Removed from schema output. Will be removed in a + * future release. * - * @param string $name + * @param string $name Ignored. * @return static */ - public function name( string $name ): self { - return $this->add_attribute( 'name', $name ); + public function name( string $name ): self { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + trigger_error( + 'Argument::name() is deprecated and no longer affects schema output. It will be removed in a future release.', + E_USER_DEPRECATED + ); + return $this; } @@ -522,4 +563,28 @@ public function title( string $title ): self { $this->title = $title; return $this; } + + /** + * Get the raw arg_options pass-through array. + * + * @return array|null + */ + public function get_arg_options(): ?array { + return $this->arg_options; + } + + /** + * Set arg_options for WP REST controller pass-through. + * + * The array is emitted verbatim under the `arg_options` key in the parsed + * schema, allowing controllers to override `sanitize_callback` / + * `validate_callback` or any other WP arg option per-property. + * + * @param array $options Raw arg_options array to attach. + * @return static + */ + public function arg_options( array $options ): self { + $this->arg_options = $options; + return $this; + } } diff --git a/src/Argument/Attribute/Element_Requirements.php b/src/Argument/Attribute/Element_Requirements.php index f823b55..4b61d0e 100644 --- a/src/Argument/Attribute/Element_Requirements.php +++ b/src/Argument/Attribute/Element_Requirements.php @@ -23,7 +23,12 @@ trait Element_Requirements { protected $relationship = 'allOf'; /** - * Sets if all of the elements are present. + * Sets the relationship to 'allOf'. + * + * @deprecated 1.0.0 No-op. `allOf` is already the default relationship, and + * WP's REST validator does not honour `allOf` as a + * combinator anyway. Use `one_of()` / `any_of()` or the + * dedicated `One_Of_Type` / `Any_Of_Type` classes. * * @return self */ diff --git a/src/Argument/Combinator_Type.php b/src/Argument/Combinator_Type.php new file mode 100644 index 0000000..3ef27ea --- /dev/null +++ b/src/Argument/Combinator_Type.php @@ -0,0 +1,73 @@ + + */ + protected $variants = array(); + + /** + * Add a variant sub-schema. + * + * @param Argument $variant A complete Argument schema (any type). + * @return self + */ + public function variant( Argument $variant ): self { + $this->variants[] = $variant; + return $this; + } + + /** + * Get the list of variant sub-schemas. + * + * @return array + */ + public function get_variants(): array { + return $this->variants; + } + + /** + * Get the combinator key (e.g. 'oneOf'). + * + * @return string + */ + public function get_combinator_key(): string { + return $this->combinator_key; + } +} diff --git a/src/Argument/Object_Type.php b/src/Argument/Object_Type.php index 6a424b5..e96b6bb 100644 --- a/src/Argument/Object_Type.php +++ b/src/Argument/Object_Type.php @@ -88,6 +88,40 @@ public function get_max_properties(): ?int { return is_int( $value ) ? $value : null; } + /** + * Mark property names as required at the parent-object level. + * + * Emits JSON-Schema draft-04 style `required: ['a','b']` on the parsed + * object schema. Matches the convention used by most WP core + * `get_item_schema()` implementations. Coexists with per-property + * `required: true` booleans. + * + * @param string ...$names One or more property names to mark required. + * @return self + */ + public function required_properties( string ...$names ): self { + return $this->add_attribute( 'required_properties', $names ); + } + + /** + * Get the list of property names marked as required at the object level. + * + * @return array + */ + public function get_required_properties(): array { + $value = $this->get_attribute( 'required_properties' ); + if ( ! is_array( $value ) ) { + return array(); + } + $names = array(); + foreach ( $value as $name ) { + if ( is_string( $name ) ) { + $names[] = $name; + } + } + return $names; + } + /** * Regular Properties. @@ -102,7 +136,7 @@ public function get_max_properties(): ?int { * @return static */ protected function add_property( string $name, string $type, ?callable $config = null ): self { - $item = $this->create_child( $name, $type )->name( $name ); + $item = $this->create_child( $name, $type ); $this->properties[ $name ] = is_null( $config ) ? $item : $config( $item ); return $this; } @@ -268,7 +302,7 @@ public function has_additional_properties(): bool { * @return static */ protected function add_pattern_property( string $pattern, string $type, ?callable $config = null ): self { - $item = $this->create_child( $pattern, $type )->name( $pattern ); + $item = $this->create_child( $pattern, $type ); $this->pattern_properties[ $pattern ] = is_null( $config ) ? $item : $config( $item ); return $this; } diff --git a/src/Argument/One_Of_Type.php b/src/Argument/One_Of_Type.php new file mode 100644 index 0000000..589ce40 --- /dev/null +++ b/src/Argument/One_Of_Type.php @@ -0,0 +1,37 @@ +variant( String_Type::on('thing')->min_length(3) ) + * ->variant( Integer_Type::on('thing')->minimum(1) ); + * // Emits: ['thing' => ['oneOf' => [{type:string,minLength:3}, {type:integer,minimum:1}]]] + * ``` + * + * @package PinkCrab\WP_Rest_Schema + * @author Glynn Quelch glynn@pinkcrab.co.uk + * @since 0.3.0 + */ + +namespace PinkCrab\WP_Rest_Schema\Argument; + +class One_Of_Type extends Combinator_Type { + + /** + * {@inheritdoc} + * + * @var string + */ + protected $combinator_key = 'oneOf'; +} diff --git a/src/Parser/Argument_Parser.php b/src/Parser/Argument_Parser.php index 322743e..618e865 100644 --- a/src/Parser/Argument_Parser.php +++ b/src/Parser/Argument_Parser.php @@ -154,7 +154,7 @@ public function shared_attributes( array $attributes = array() ): array { $attributes['description'] = $this->argument->get_description(); } - if ( ! is_null( $this->argument->get_default() ) ) { + if ( $this->argument->has_default() ) { $attributes['default'] = $this->argument->get_default(); } @@ -162,10 +162,6 @@ public function shared_attributes( array $attributes = array() ): array { $attributes['format'] = $this->argument->get_format(); } - if ( ! is_null( $this->argument->get_name() ) ) { - $attributes['name'] = $this->argument->get_name(); - } - if ( is_array( $this->argument->get_expected() ) && ! empty( $this->argument->get_expected() ) ) { $attributes['enum'] = $this->argument->get_expected(); } @@ -182,6 +178,10 @@ public function shared_attributes( array $attributes = array() ): array { $attributes['title'] = $this->argument->get_title(); } + if ( ! is_null( $this->argument->get_arg_options() ) ) { + $attributes['arg_options'] = $this->argument->get_arg_options(); + } + return $attributes; } @@ -197,6 +197,12 @@ public function shared_attributes( array $attributes = array() ): array { * @return array */ protected function get_type_attributes(): array { + // Schema-root combinators (One_Of_Type, Any_Of_Type) have no primitive + // `type` and are dispatched by class rather than by the type switch. + if ( is_a( $this->argument, \PinkCrab\WP_Rest_Schema\Argument\Combinator_Type::class ) ) { + return Combinator_Attribute_Parser::parse( $this->argument ); + } + switch ( $this->argument->get_type() ) { case Argument::TYPE_STRING: return String_Attribute_Parser::parse( $this->argument ); diff --git a/src/Parser/Array_Attribute_Parser.php b/src/Parser/Array_Attribute_Parser.php index 94e2898..17d5826 100644 --- a/src/Parser/Array_Attribute_Parser.php +++ b/src/Parser/Array_Attribute_Parser.php @@ -63,27 +63,45 @@ public function parse_attributes(): array { } /** - * Parses the arrays items + * Parses the array's items. + * + * WP treats `items` as a single schema applied to every element. JSON + * Schema tuple-form (`items: [schemaA, schemaB]`) is not honoured. + * + * - If no combinator is set (default `allOf`) and multiple item schemas + * have been added, the LAST item wins and `items` is emitted as a + * single schema. + * - If a combinator (`oneOf` / `anyOf`) is set, all added item schemas + * are emitted as a list under that key — producing valid WP schema + * (`items: { oneOf: [schemaA, schemaB] }`). * * @param Array_Type $argument - * @return array + * @return array */ protected function parse_array_items( Array_Type $argument ): array { - $items = array(); - if ( ! $argument->has_items() ) { - return $items; + return array(); } - // If we only have 1 item, return as a simple array. - if ( $argument->item_count() === 1 ) { - $items = Argument_Parser::as_list( array_values( $argument->get_items() )[0] ); // @phpstan-ignore-line, already checked if array empty. - } else { - foreach ( $argument->get_items() ?? array() as $key => $value ) { - $items[] = Argument_Parser::as_list( $value ); + /** @var array $raw_items */ + $raw_items = array_values( $argument->get_items() ?? array() ); + $relationship = $argument->get_relationship(); + + // Combinator set (oneOf / anyOf) — keep the full list so it can be + // wrapped under the combinator key by the caller. + if ( 'allOf' !== $relationship ) { + $items = array(); + foreach ( $raw_items as $item ) { + $items[] = Argument_Parser::as_list( $item ); } + return $items; } - return $items; + // No combinator — WP only honours a single items schema. Last wins. + $last_item = end( $raw_items ); + if ( false === $last_item ) { + return array(); + } + return Argument_Parser::as_list( $last_item ); } } diff --git a/src/Parser/Combinator_Attribute_Parser.php b/src/Parser/Combinator_Attribute_Parser.php new file mode 100644 index 0000000..b5c5afd --- /dev/null +++ b/src/Parser/Combinator_Attribute_Parser.php @@ -0,0 +1,45 @@ + + */ + public function parse_attributes(): array { + if ( ! is_a( $this->argument, Combinator_Type::class ) ) { + return array(); + } + + /** @var Combinator_Type $argument */ + $argument = $this->argument; + + $variants = array(); + foreach ( $argument->get_variants() as $variant ) { + $variants[] = Argument_Parser::as_list( $variant ); + } + + return array( + $argument->get_combinator_key() => $variants, + ); + } +} diff --git a/src/Parser/Object_Attribute_Parser.php b/src/Parser/Object_Attribute_Parser.php index 807e321..4189d8d 100644 --- a/src/Parser/Object_Attribute_Parser.php +++ b/src/Parser/Object_Attribute_Parser.php @@ -69,6 +69,12 @@ public function parse_attributes(): array { $attributes['maxProperties'] = $argument->get_max_properties(); } + // Parent-level required property names (draft-4 style array). + $required_names = $argument->get_required_properties(); + if ( ! empty( $required_names ) ) { + $attributes['required'] = $required_names; + } + return $attributes; } diff --git a/src/Schema.php b/src/Schema.php index 0dde8d9..085efab 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -189,6 +189,20 @@ public function object_property( string $name, ?callable $config = null ): self return $this; } + /** + * Mark property names as required at the parent-object level. + * + * Forwards to `Object_Type::required_properties()`. Emits a draft-4 + * style `required: ['a','b']` array in the parsed schema. + * + * @param string ...$names + * @return static + */ + public function required_properties( string ...$names ): self { + $this->object->required_properties( ...$names ); + return $this; + } + /** * Set additional properties (boolean or schema). * @@ -253,4 +267,41 @@ public function to_array(): array { return $schema; } + + /** + * Build the `context` collection param descriptor. + * + * Mirrors `WP_REST_Controller::get_context_param()`: derives the `enum` of + * allowed contexts from the union of `context` values set on the schema's + * properties, unique and reverse-sorted. Returns the standard param shape + * (type/description/sanitize_callback/validate_callback) merged with any + * caller-provided overrides. + * + * @param array $args Caller overrides merged on top. + * @return array + */ + public function get_context_param( array $args = array() ): array { + $param_details = array( + 'description' => 'Scope under which the request is made; determines fields present in response.', + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + 'validate_callback' => 'rest_validate_request_arg', + ); + + $contexts = array(); + foreach ( $this->object->get_properties() as $property ) { + $property_contexts = $property->get_context(); + if ( ! empty( $property_contexts ) ) { + $contexts = array_merge( $contexts, $property_contexts ); + } + } + + if ( ! empty( $contexts ) ) { + $enum = array_values( array_unique( $contexts ) ); + rsort( $enum ); + $param_details['enum'] = $enum; + } + + return array_merge( $param_details, $args ); + } } diff --git a/tests/Argument/Parser/Abstract_Parser_Testcase.php b/tests/Argument/Parser/Abstract_Parser_Testcase.php index 483ca5b..1b27f75 100644 --- a/tests/Argument/Parser/Abstract_Parser_Testcase.php +++ b/tests/Argument/Parser/Abstract_Parser_Testcase.php @@ -131,22 +131,30 @@ public function test_description(): void { ); } - /** @testdox When parsing the argument, the name should be listed if defined. */ + /** @testdox name() is deprecated — calling it triggers E_USER_DEPRECATED and does not leak into the parsed output. */ public function test_name(): void { - $expected = array( - 'arg-name' => array( - 'type' => $this->type_name(), - 'name' => 'some name', - ), + $triggered = false; + set_error_handler( + function ( $errno ) use ( &$triggered ) { + if ( $errno === E_USER_DEPRECATED ) { + $triggered = true; + return true; + } + return false; + }, + E_USER_DEPRECATED ); $model = $this->type_class()::on( 'arg-name' ) ->name( 'some name' ); - $this->assertSame( - $expected, - Argument_Parser::as_array( $model ) - ); + restore_error_handler(); + + $this->assertTrue( $triggered, 'Expected ::name() to trigger E_USER_DEPRECATED.' ); + + // No `name` key should appear in the parsed output. + $parsed = Argument_Parser::as_array( $model ); + $this->assertArrayNotHasKey( 'name', $parsed['arg-name'] ); } /** @testdox When parsing the argument, the default should be listed if defined. */ diff --git a/tests/Argument/Parser/Test_Array_Type_Parser.php b/tests/Argument/Parser/Test_Array_Type_Parser.php index bfbff84..a4078f7 100644 --- a/tests/Argument/Parser/Test_Array_Type_Parser.php +++ b/tests/Argument/Parser/Test_Array_Type_Parser.php @@ -62,18 +62,15 @@ public function test_single_depth_items() { } - /** @testdox It should be possible to allow multiple types of an arrays contents. */ - public function test_multiple_types(): void { + /** + * @testdox When multiple item types are added, the last one wins and items is emitted as a single schema (WP does not honour tuple-form items). + */ + public function test_multiple_item_calls_last_wins(): void { $expected = array( 'arg-name' => array( 'type' => 'array', 'items' => array( - array( - 'type' => 'string', - ), - array( - 'type' => 'null', - ), + 'type' => 'null', ), ), ); @@ -88,6 +85,29 @@ public function test_multiple_types(): void { ); } + /** @testdox A three-way tuple collapses to the last-added item schema. */ + public function test_three_item_calls_last_wins(): void { + $expected = array( + 'arg-name' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'integer', + 'minimum' => 1, + ), + ), + ); + + $model = Array_Type::on( 'arg-name' ) + ->string_item() + ->null_item() + ->integer_item( fn( $i ) => $i->minimum( 1 ) ); + + $this->assertSame( + $expected, + Argument_Parser::as_array( $model ) + ); + } + /** @testdox It should be possible to define an array that has an array as its items. */ public function test_array_of_array(): void { $expected = array( @@ -123,10 +143,8 @@ public function test_deep_nested_arrays(): void { 'type' => 'array', 'items' => array( 'type' => 'array', - 'name' => '1st', 'items' => array( 'type' => 'array', - 'name' => '2nd', 'items' => array( 'type' => 'string', ), @@ -139,11 +157,9 @@ public function test_deep_nested_arrays(): void { ->array_item( function( Array_Type $first_generation ): Array_Type { return $first_generation - ->name( '1st' ) ->array_item( function ( Array_Type $second_generation ): Array_Type { return $second_generation - ->name( '2nd' ) ->string_item(); } ); diff --git a/tests/Argument/Parser/Test_Object_Type_Parser.php b/tests/Argument/Parser/Test_Object_Type_Parser.php index 4050065..5ce87a8 100644 --- a/tests/Argument/Parser/Test_Object_Type_Parser.php +++ b/tests/Argument/Parser/Test_Object_Type_Parser.php @@ -36,11 +36,9 @@ public function test_can_parse_regular_properties(): void { 'properties' => array( 'foo' => array( 'type' => 'string', - 'name' => 'foo', ), 'bar' => array( 'type' => 'boolean', - 'name' => 'bar', ), ), ), @@ -64,7 +62,6 @@ public function test_can_parse_single_property(): void { 'properties' => array( 'foo' => array( 'type' => 'string', - 'name' => 'foo', ), ), ), @@ -143,7 +140,6 @@ public function test_can_parse_pattern_properties(): void { 'patternProperties' => array( '^\\w+$' => array( 'type' => 'string', - 'name' => '^\\w+$', ), ), ), @@ -203,11 +199,9 @@ public function test_can_parse_one_of_relationship(): void { 'oneOf' => array( 'foo' => array( 'type' => 'string', - 'name' => 'foo', ), 'bar' => array( 'type' => 'integer', - 'name' => 'bar', ), ), ), diff --git a/tests/Argument/Parser/Test_Parser_Type_Guards.php b/tests/Argument/Parser/Test_Parser_Type_Guards.php new file mode 100644 index 0000000..5fcdf4c --- /dev/null +++ b/tests/Argument/Parser/Test_Parser_Type_Guards.php @@ -0,0 +1,52 @@ +assertSame( array(), String_Attribute_Parser::parse( Integer_Type::on( 'x' ) ) ); + } + + /** @testdox Array_Attribute_Parser returns an empty array when given a non-Array argument. */ + public function test_array_parser_guard(): void { + $this->assertSame( array(), Array_Attribute_Parser::parse( String_Type::on( 'x' ) ) ); + } + + /** @testdox Object_Attribute_Parser returns an empty array when given a non-Object argument. */ + public function test_object_parser_guard(): void { + $this->assertSame( array(), Object_Attribute_Parser::parse( String_Type::on( 'x' ) ) ); + } + + /** @testdox Combinator_Attribute_Parser returns an empty array when given a non-Combinator argument. */ + public function test_combinator_parser_guard(): void { + $this->assertSame( array(), Combinator_Attribute_Parser::parse( String_Type::on( 'x' ) ) ); + } + + /** @testdox Argument_Parser::numeric_attributes() returns an empty array for non-numeric types. */ + public function test_numeric_attributes_guard(): void { + $parser = new Argument_Parser( String_Type::on( 'x' ) ); + $this->assertSame( array(), $parser->numeric_attributes() ); + } +} diff --git a/tests/Argument/Test_Any_Of_Type.php b/tests/Argument/Test_Any_Of_Type.php new file mode 100644 index 0000000..c8159dc --- /dev/null +++ b/tests/Argument/Test_Any_Of_Type.php @@ -0,0 +1,78 @@ + array( + 'anyOf' => array( + array( 'type' => 'string' ), + array( 'type' => 'integer' ), + ), + ), + ); + + $model = Any_Of_Type::on( 'thing' ) + ->variant( String_Type::on( 'thing' ) ) + ->variant( Integer_Type::on( 'thing' ) ); + + $this->assertSame( + $expected, + Argument_Parser::as_array( $model ) + ); + } + + /** @testdox Variant sub-schemas should preserve their own type-specific attributes in an anyOf combinator. */ + public function test_any_of_variants_preserve_nested_attributes(): void { + $expected = array( + 'thing' => array( + 'anyOf' => array( + array( + 'type' => 'string', + 'maxLength' => 10, + ), + array( + 'type' => 'integer', + 'maximum' => 999, + ), + ), + ), + ); + + $model = Any_Of_Type::on( 'thing' ) + ->variant( String_Type::on( 'thing' )->max_length( 10 ) ) + ->variant( Integer_Type::on( 'thing' )->maximum( 999 ) ); + + $this->assertSame( + $expected, + Argument_Parser::as_array( $model ) + ); + } + + /** @testdox Any_Of_Type with no variants emits an empty anyOf list. */ + public function test_any_of_with_no_variants_emits_empty_list(): void { + $expected = array( + 'thing' => array( + 'anyOf' => array(), + ), + ); + + $model = Any_Of_Type::on( 'thing' ); + + $this->assertSame( + $expected, + Argument_Parser::as_array( $model ) + ); + } +} diff --git a/tests/Argument/Test_Argument.php b/tests/Argument/Test_Argument.php index 0ab0a3f..87e024a 100644 --- a/tests/Argument/Test_Argument.php +++ b/tests/Argument/Test_Argument.php @@ -131,6 +131,91 @@ public function test_argument_defaults(): void { $this->assertEquals( 'DEF', $argument->get_default() ); } + /** @testdox Setting a default of false should count as a default being set and should be retrievable. */ + public function test_default_false_is_tracked_as_set(): void { + $argument = new Argument( 'flag' ); + $argument->default( false ); + $this->assertTrue( $argument->has_default() ); + $this->assertSame( false, $argument->get_default() ); + } + + /** @testdox Setting a default of 0 should count as a default being set and should be retrievable. */ + public function test_default_zero_is_tracked_as_set(): void { + $argument = new Argument( 'count' ); + $argument->default( 0 ); + $this->assertTrue( $argument->has_default() ); + $this->assertSame( 0, $argument->get_default() ); + } + + /** @testdox Setting a default of an empty string should count as a default being set and should be retrievable. */ + public function test_default_empty_string_is_tracked_as_set(): void { + $argument = new Argument( 'label' ); + $argument->default( '' ); + $this->assertTrue( $argument->has_default() ); + $this->assertSame( '', $argument->get_default() ); + } + + /** @testdox Setting a default of null should count as a default being set and should be retrievable (for unions that include null). */ + public function test_default_null_is_tracked_as_set(): void { + $argument = new Argument( 'optional' ); + $argument->default( null ); + $this->assertTrue( $argument->has_default() ); + $this->assertNull( $argument->get_default() ); + } + + /** @testdox expected() should accept null as a valid enum value (WP uses in_array strict which accepts null). */ + public function test_expected_accepts_null(): void { + $argument = new Argument( 'status' ); + $argument->expected( 'active', 'inactive', null ); + $this->assertSame( array( 'active', 'inactive', null ), $argument->get_expected() ); + } + + /** @testdox expected() should accept array values as enum entries (for array-typed schemas). */ + public function test_expected_accepts_arrays(): void { + $argument = new Argument( 'preset' ); + $argument->expected( array( 'a', 'b' ), array( 'c', 'd' ) ); + $this->assertSame( array( array( 'a', 'b' ), array( 'c', 'd' ) ), $argument->get_expected() ); + } + + /** @testdox arg_options() should store and return a raw array for WP controller pass-through. */ + public function test_arg_options_setter_and_getter(): void { + $argument = new Argument( 'id' ); + $this->assertNull( $argument->get_arg_options() ); + $argument->arg_options( + array( + 'sanitize_callback' => 'absint', + 'validate_callback' => 'is_numeric', + ) + ); + $this->assertSame( + array( + 'sanitize_callback' => 'absint', + 'validate_callback' => 'is_numeric', + ), + $argument->get_arg_options() + ); + } + + /** @testdox arg_options() values should be emitted verbatim in the parsed output. */ + public function test_arg_options_emitted_in_parser_output(): void { + $expected = array( + 'id' => array( + 'type' => 'string', + 'arg_options' => array( + 'sanitize_callback' => 'absint', + ), + ), + ); + + $model = String_Type::on( 'id' ) + ->arg_options( array( 'sanitize_callback' => 'absint' ) ); + + $this->assertSame( + $expected, + \PinkCrab\WP_Rest_Schema\Parser\Argument_Parser::as_array( $model ) + ); + } + /** @testdox It should be possible to set a description to an argument. */ public function test_argument_description(): void { $argument = new Argument( 'id' ); @@ -232,14 +317,36 @@ public function test_only_string_and_array_as_argument_types(): void { $this->assertSame( $types, $caught ); } - /** @testdox It should be possible to give an argument a name and get the name. */ - public function test_name(): void { + /** @testdox name() is deprecated — calling it triggers an E_USER_DEPRECATED notice. */ + public function test_name_setter_is_deprecated(): void { $argument = new Argument( 'id' ); - $this->assertNull( $argument->get_name() ); + $triggered = false; + set_error_handler( + function ( $errno ) use ( &$triggered ) { + if ( $errno === E_USER_DEPRECATED ) { + $triggered = true; + return true; + } + return false; + }, + E_USER_DEPRECATED + ); + + $argument->name( 'anything' ); + + restore_error_handler(); + + $this->assertTrue( $triggered, 'Expected Argument::name() to trigger E_USER_DEPRECATED.' ); + } - $argument->name( 'name it' ); - $this->assertEquals( 'name it', $argument->get_name() ); + /** @testdox name() is a no-op — the value is not stored and get_name() still returns null. */ + public function test_name_setter_is_no_op(): void { + $argument = new Argument( 'id' ); + + @$argument->name( 'ignored' ); + + $this->assertNull( $argument->get_name() ); } /** @testdox It should be possible to set and get the arguments, attributes as arrays. */ diff --git a/tests/Argument/Test_Object_Type.php b/tests/Argument/Test_Object_Type.php index 1e94b3e..7abba2d 100644 --- a/tests/Argument/Test_Object_Type.php +++ b/tests/Argument/Test_Object_Type.php @@ -67,7 +67,6 @@ public function test_string_property(): void { $arg->string_property( 'property_name', function( String_Type $type ): String_Type { - $this->assertEquals( 'property_name', $type->get_name() ); $this->assertInstanceOf( String_Type::class, $type ); return $type; } @@ -78,7 +77,6 @@ function( String_Type $type ): String_Type { $type = $arg->get_properties()['property_name']; $this->assertInstanceOf( String_Type::class, $type ); - $this->assertEquals( 'property_name', $type->get_name() ); } /** @testdox Can set a number property */ @@ -87,7 +85,6 @@ public function test_number_property(): void { $arg->number_property( 'property_name', function( Number_Type $type ): Number_Type { - $this->assertEquals( 'property_name', $type->get_name() ); $this->assertInstanceOf( Number_Type::class, $type ); return $type; } @@ -97,7 +94,6 @@ function( Number_Type $type ): Number_Type { $this->assertArrayHasKey( 'property_name', $arg->get_properties() ); $type = $arg->get_properties()['property_name']; $this->assertInstanceOf( Number_Type::class, $type ); - $this->assertEquals( 'property_name', $type->get_name() ); } /** @testdox Can set a integer property */ @@ -106,7 +102,6 @@ public function test_integer_property(): void { $arg->integer_property( 'property_name', function( Integer_Type $type ): Integer_Type { - $this->assertEquals( 'property_name', $type->get_name() ); $this->assertInstanceOf( Integer_Type::class, $type ); return $type; } @@ -116,7 +111,6 @@ function( Integer_Type $type ): Integer_Type { $this->assertArrayHasKey( 'property_name', $arg->get_properties() ); $type = $arg->get_properties()['property_name']; $this->assertInstanceOf( Integer_Type::class, $type ); - $this->assertEquals( 'property_name', $type->get_name() ); } /** @testdox Can set a null property */ @@ -125,7 +119,6 @@ public function test_null_property(): void { $arg->null_property( 'property_name', function( Null_Type $type ): Null_Type { - $this->assertEquals( 'property_name', $type->get_name() ); $this->assertInstanceOf( Null_Type::class, $type ); return $type; } @@ -135,7 +128,6 @@ function( Null_Type $type ): Null_Type { $this->assertArrayHasKey( 'property_name', $arg->get_properties() ); $type = $arg->get_properties()['property_name']; $this->assertInstanceOf( Null_Type::class, $type ); - $this->assertEquals( 'property_name', $type->get_name() ); } /** @testdox Can set a boolean property */ @@ -144,7 +136,6 @@ public function test_boolean_property(): void { $arg->boolean_property( 'property_name', function( Boolean_Type $type ): Boolean_Type { - $this->assertEquals( 'property_name', $type->get_name() ); $this->assertInstanceOf( Boolean_Type::class, $type ); return $type; } @@ -154,7 +145,6 @@ function( Boolean_Type $type ): Boolean_Type { $this->assertArrayHasKey( 'property_name', $arg->get_properties() ); $type = $arg->get_properties()['property_name']; $this->assertInstanceOf( Boolean_Type::class, $type ); - $this->assertEquals( 'property_name', $type->get_name() ); } /** @testdox Can set a array property */ @@ -163,7 +153,6 @@ public function test_array_property(): void { $arg->array_property( 'property_name', function( Array_Type $type ): Array_Type { - $this->assertEquals( 'property_name', $type->get_name() ); $this->assertInstanceOf( Array_Type::class, $type ); return $type; } @@ -173,7 +162,6 @@ function( Array_Type $type ): Array_Type { $this->assertArrayHasKey( 'property_name', $arg->get_properties() ); $type = $arg->get_properties()['property_name']; $this->assertInstanceOf( Array_Type::class, $type ); - $this->assertEquals( 'property_name', $type->get_name() ); } /** @testdox Can set a object property */ @@ -182,7 +170,6 @@ public function test_object_property(): void { $arg->object_property( 'property_name', function( Object_Type $type ): Object_Type { - $this->assertEquals( 'property_name', $type->get_name() ); $this->assertInstanceOf( Object_Type::class, $type ); return $type; } @@ -192,7 +179,6 @@ function( Object_Type $type ): Object_Type { $this->assertArrayHasKey( 'property_name', $arg->get_properties() ); $type = $arg->get_properties()['property_name']; $this->assertInstanceOf( Object_Type::class, $type ); - $this->assertEquals( 'property_name', $type->get_name() ); } /** @@ -245,7 +231,6 @@ public function test_string_pattern_property(): void { $arg->string_pattern_property( '^\\w+$', function( String_Type $type ): String_Type { - $this->assertEquals( '^\\w+$', $type->get_name() ); $this->assertInstanceOf( String_Type::class, $type ); return $type; } @@ -256,7 +241,6 @@ function( String_Type $type ): String_Type { $type = $arg->get_pattern_properties()['^\\w+$']; $this->assertInstanceOf( String_Type::class, $type ); - $this->assertEquals( '^\\w+$', $type->get_name() ); } /** @testdox Can set a number pattern property */ @@ -265,7 +249,6 @@ public function test_number_pattern_property(): void { $arg->number_pattern_property( '^\\w+$', function( Number_Type $type ): Number_Type { - $this->assertEquals( '^\\w+$', $type->get_name() ); $this->assertInstanceOf( Number_Type::class, $type ); return $type; } @@ -275,7 +258,6 @@ function( Number_Type $type ): Number_Type { $this->assertArrayHasKey( '^\\w+$', $arg->get_pattern_properties() ); $type = $arg->get_pattern_properties()['^\\w+$']; $this->assertInstanceOf( Number_Type::class, $type ); - $this->assertEquals( '^\\w+$', $type->get_name() ); } /** @testdox Can set a integer pattern property */ @@ -284,7 +266,6 @@ public function test_integer_pattern_property(): void { $arg->integer_pattern_property( '^\\w+$', function( Integer_Type $type ): Integer_Type { - $this->assertEquals( '^\\w+$', $type->get_name() ); $this->assertInstanceOf( Integer_Type::class, $type ); return $type; } @@ -294,7 +275,6 @@ function( Integer_Type $type ): Integer_Type { $this->assertArrayHasKey( '^\\w+$', $arg->get_pattern_properties() ); $type = $arg->get_pattern_properties()['^\\w+$']; $this->assertInstanceOf( Integer_Type::class, $type ); - $this->assertEquals( '^\\w+$', $type->get_name() ); } /** @testdox Can set a null pattern property */ @@ -303,7 +283,6 @@ public function test_null_pattern_property(): void { $arg->null_pattern_property( '^\\w+$', function( Null_Type $type ): Null_Type { - $this->assertEquals( '^\\w+$', $type->get_name() ); $this->assertInstanceOf( Null_Type::class, $type ); return $type; } @@ -313,7 +292,6 @@ function( Null_Type $type ): Null_Type { $this->assertArrayHasKey( '^\\w+$', $arg->get_pattern_properties() ); $type = $arg->get_pattern_properties()['^\\w+$']; $this->assertInstanceOf( Null_Type::class, $type ); - $this->assertEquals( '^\\w+$', $type->get_name() ); } /** @testdox Can set a boolean pattern property */ @@ -322,7 +300,6 @@ public function test_boolean_pattern_property(): void { $arg->boolean_pattern_property( '^\\w+$', function( Boolean_Type $type ): Boolean_Type { - $this->assertEquals( '^\\w+$', $type->get_name() ); $this->assertInstanceOf( Boolean_Type::class, $type ); return $type; } @@ -332,7 +309,6 @@ function( Boolean_Type $type ): Boolean_Type { $this->assertArrayHasKey( '^\\w+$', $arg->get_pattern_properties() ); $type = $arg->get_pattern_properties()['^\\w+$']; $this->assertInstanceOf( Boolean_Type::class, $type ); - $this->assertEquals( '^\\w+$', $type->get_name() ); } /** @testdox Can set a array pattern property */ @@ -341,7 +317,6 @@ public function test_array_pattern_property(): void { $arg->array_pattern_property( '^\\w+$', function( Array_Type $type ): Array_Type { - $this->assertEquals( '^\\w+$', $type->get_name() ); $this->assertInstanceOf( Array_Type::class, $type ); return $type; } @@ -351,7 +326,6 @@ function( Array_Type $type ): Array_Type { $this->assertArrayHasKey( '^\\w+$', $arg->get_pattern_properties() ); $type = $arg->get_pattern_properties()['^\\w+$']; $this->assertInstanceOf( Array_Type::class, $type ); - $this->assertEquals( '^\\w+$', $type->get_name() ); } /** @testdox Can set a object pattern property */ @@ -360,7 +334,6 @@ public function test_object_pattern_property(): void { $arg->object_pattern_property( '^\\w+$', function( Object_Type $type ): Object_Type { - $this->assertEquals( '^\\w+$', $type->get_name() ); $this->assertInstanceOf( Object_Type::class, $type ); return $type; } @@ -370,6 +343,77 @@ function( Object_Type $type ): Object_Type { $this->assertArrayHasKey( '^\\w+$', $arg->get_pattern_properties() ); $type = $arg->get_pattern_properties()['^\\w+$']; $this->assertInstanceOf( Object_Type::class, $type ); - $this->assertEquals( '^\\w+$', $type->get_name() ); + } + + /** @testdox required_properties() should store names for parent-level required array emission. */ + public function test_required_properties_setter_and_getter(): void { + $obj = Object_Type::on( 'user' ); + $this->assertSame( array(), $obj->get_required_properties() ); + + $obj->required_properties( 'id', 'email' ); + $this->assertSame( array( 'id', 'email' ), $obj->get_required_properties() ); + } + + /** @testdox Object_Type should emit required as a sibling array of property names at the parent level. */ + public function test_required_properties_emitted_at_object_level(): void { + $expected = array( + 'user' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( 'type' => 'integer' ), + 'email' => array( 'type' => 'string' ), + ), + 'required' => array( 'id', 'email' ), + ), + ); + + $model = Object_Type::on( 'user' ) + ->integer_property( 'id' ) + ->string_property( 'email' ) + ->required_properties( 'id', 'email' ); + + $this->assertSame( + $expected, + \PinkCrab\WP_Rest_Schema\Parser\Argument_Parser::as_array( $model ) + ); + } + + /** @testdox has_properties() and count_properties() reflect the registered properties. */ + public function test_properties_helpers(): void { + $obj = Object_Type::on( 'user' ); + $this->assertFalse( $obj->has_properties() ); + $this->assertSame( 0, $obj->count_properties() ); + + $obj->string_property( 'email' )->integer_property( 'id' ); + $this->assertTrue( $obj->has_properties() ); + $this->assertSame( 2, $obj->count_properties() ); + } + + /** @testdox has_pattern_properties() and count_pattern_properties() reflect the registered pattern properties. */ + public function test_pattern_properties_helpers(): void { + $obj = Object_Type::on( 'bag' ); + $this->assertFalse( $obj->has_pattern_properties() ); + $this->assertSame( 0, $obj->count_pattern_properties() ); + + $obj->string_pattern_property( '^[a-z]+$' ); + $this->assertTrue( $obj->has_pattern_properties() ); + $this->assertSame( 1, $obj->count_pattern_properties() ); + } + + /** @testdox required_properties() should coexist with per-property required booleans. */ + public function test_required_properties_coexists_with_per_property_required(): void { + $model = Object_Type::on( 'user' ) + ->string_property( + 'email', + function ( $p ) { + return $p->required( true ); + } + ) + ->string_property( 'nickname' ) + ->required_properties( 'email', 'nickname' ); + + $parsed = \PinkCrab\WP_Rest_Schema\Parser\Argument_Parser::as_array( $model ); + $this->assertSame( array( 'email', 'nickname' ), $parsed['user']['required'] ); + $this->assertTrue( $parsed['user']['properties']['email']['required'] ); } } diff --git a/tests/Argument/Test_One_Of_Type.php b/tests/Argument/Test_One_Of_Type.php new file mode 100644 index 0000000..dcf5092 --- /dev/null +++ b/tests/Argument/Test_One_Of_Type.php @@ -0,0 +1,78 @@ + array( + 'oneOf' => array( + array( 'type' => 'string' ), + array( 'type' => 'integer' ), + ), + ), + ); + + $model = One_Of_Type::on( 'thing' ) + ->variant( String_Type::on( 'thing' ) ) + ->variant( Integer_Type::on( 'thing' ) ); + + $this->assertSame( + $expected, + Argument_Parser::as_array( $model ) + ); + } + + /** @testdox One_Of_Type with zero variants should emit an empty oneOf list (caller's responsibility to add variants). */ + public function test_one_of_with_no_variants_emits_empty_list(): void { + $expected = array( + 'thing' => array( + 'oneOf' => array(), + ), + ); + + $model = One_Of_Type::on( 'thing' ); + + $this->assertSame( + $expected, + Argument_Parser::as_array( $model ) + ); + } + + /** @testdox Variant sub-schemas should preserve their own type-specific attributes (minLength, minimum, etc.). */ + public function test_one_of_variants_preserve_nested_attributes(): void { + $expected = array( + 'thing' => array( + 'oneOf' => array( + array( + 'type' => 'string', + 'minLength' => 3, + ), + array( + 'type' => 'integer', + 'minimum' => 1, + ), + ), + ), + ); + + $model = One_Of_Type::on( 'thing' ) + ->variant( String_Type::on( 'thing' )->min_length( 3 ) ) + ->variant( Integer_Type::on( 'thing' )->minimum( 1 ) ); + + $this->assertSame( + $expected, + Argument_Parser::as_array( $model ) + ); + } +} diff --git a/tests/Argument/Test_Output_Keys.php b/tests/Argument/Test_Output_Keys.php new file mode 100644 index 0000000..f917930 --- /dev/null +++ b/tests/Argument/Test_Output_Keys.php @@ -0,0 +1,165 @@ + + */ + protected function allowed_keys(): array { + return array( + // rest_get_allowed_schema_keywords() (WP core). + 'title', + 'description', + 'default', + 'type', + 'format', + 'enum', + 'items', + 'properties', + 'additionalProperties', + 'patternProperties', + 'minProperties', + 'maxProperties', + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + 'multipleOf', + 'minLength', + 'maxLength', + 'pattern', + 'minItems', + 'maxItems', + 'uniqueItems', + 'anyOf', + 'oneOf', + // Module / route-arg extras we intentionally emit. + 'required', + 'readonly', + 'context', + 'arg_options', + '$schema', + 'validate_callback', + 'sanitize_callback', + ); + } + + /** + * Recursively collect all schema-level string keys, skipping the + * user-defined children of `properties` / `patternProperties` (those + * are the caller's field names, not schema keywords). + * + * @param array $input + * @param array $keys + */ + protected function collect_keys( array $input, array &$keys ): void { + $skip_children = array( 'properties', 'patternProperties' ); + foreach ( $input as $key => $value ) { + if ( is_string( $key ) ) { + $keys[] = $key; + } + if ( ! is_array( $value ) ) { + continue; + } + if ( is_string( $key ) && in_array( $key, $skip_children, true ) ) { + foreach ( $value as $sub ) { + if ( is_array( $sub ) ) { + $this->collect_keys( $sub, $keys ); + } + } + continue; + } + $this->collect_keys( $value, $keys ); + } + } + + /** @testdox A complex Schema's output must contain only WP-allowed or module-sanctioned keys. */ + public function test_complex_schema_output_keys_are_all_allowed(): void { + $schema = Schema::on( 'post' ) + ->description( 'A post.' ) + ->integer_property( 'id', fn( $p ) => $p->readonly( true )->context( 'view', 'edit', 'embed' ) ) + ->string_property( 'title', fn( $p ) => $p->required( true )->min_length( 1 )->max_length( 255 )->context( 'view', 'edit' ) ) + ->string_property( 'status', fn( $p ) => $p->expected( 'publish', 'draft', 'pending' )->default( 'draft' ) ) + ->array_property( 'tags', fn( $p ) => $p->string_item()->min_items( 0 )->max_items( 10 )->unique_items( true ) ) + ->object_property( + 'meta', + fn( $o ) => $o + ->string_property( 'slug' ) + ->integer_property( 'views', fn( $p ) => $p->minimum( 0 ) ) + ->required_properties( 'slug' ) + ) + ->required_properties( 'id', 'title' ) + ->to_array(); + + $keys = array(); + $this->collect_keys( $schema, $keys ); + + $disallowed = array_diff( array_unique( $keys ), $this->allowed_keys() ); + $this->assertSame( + array(), + array_values( $disallowed ), + 'Schema output contains keys that are neither WP-honoured nor module-sanctioned: ' . implode( ', ', $disallowed ) + ); + } + + /** @testdox A root-level One_Of_Type with mixed-type variants must contain only allowed keys. */ + public function test_one_of_output_keys_are_all_allowed(): void { + $model = One_Of_Type::on( 'thing' ) + ->variant( String_Type::on( 'thing' )->min_length( 3 ) ) + ->variant( Integer_Type::on( 'thing' )->minimum( 1 ) ); + $parsed = Argument_Parser::as_array( $model ); + $inner = reset( $parsed ); + + $keys = array(); + $this->collect_keys( (array) $inner, $keys ); + + $disallowed = array_diff( array_unique( $keys ), $this->allowed_keys() ); + $this->assertSame( array(), array_values( $disallowed ) ); + } + + /** @testdox An Array_Type with items constraints and arg_options must contain only allowed keys. */ + public function test_array_with_arg_options_output_keys_are_all_allowed(): void { + $model = Array_Type::on( 'tags' ) + ->string_item() + ->min_items( 1 ) + ->max_items( 5 ) + ->unique_items( true ) + ->arg_options( array( 'sanitize_callback' => 'wp_parse_list' ) ); + + $parsed = Argument_Parser::as_array( $model ); + $inner = reset( $parsed ); + + $keys = array(); + $this->collect_keys( (array) $inner, $keys ); + + $disallowed = array_diff( array_unique( $keys ), $this->allowed_keys() ); + $this->assertSame( array(), array_values( $disallowed ) ); + } +} diff --git a/tests/Schema/Test_Schema.php b/tests/Schema/Test_Schema.php index 0cab09d..a43415a 100644 --- a/tests/Schema/Test_Schema.php +++ b/tests/Schema/Test_Schema.php @@ -157,4 +157,69 @@ function( String_Type $s ): String_Type { $this->assertTrue( $schema['properties']['title']['required'] ); $this->assertEquals( array( 'publish', 'draft', 'pending' ), $schema['properties']['status']['enum'] ); } + + /** @testdox to_array() passes through min/maxProperties and patternProperties from the internal Object_Type. */ + public function test_to_array_emits_min_max_and_pattern_properties(): void { + $schema = Schema::on( 'settings' ); + $schema->get_object() + ->min_properties( 1 ) + ->max_properties( 10 ) + ->string_pattern_property( '^[a-z]+$' ); + + $array = $schema->to_array(); + + $this->assertSame( 1, $array['minProperties'] ); + $this->assertSame( 10, $array['maxProperties'] ); + $this->assertArrayHasKey( 'patternProperties', $array ); + $this->assertArrayHasKey( '^[a-z]+$', $array['patternProperties'] ); + } + + /** @testdox number_property(), boolean_property(), null_property() forwarders register on the internal Object_Type. */ + public function test_schema_forwarders_for_number_boolean_null_property(): void { + $schema = Schema::on( 'mix' ) + ->number_property( 'score' ) + ->boolean_property( 'active' ) + ->null_property( 'nothing' ); + + $props = $schema->get_object()->get_properties(); + $this->assertArrayHasKey( 'score', $props ); + $this->assertArrayHasKey( 'active', $props ); + $this->assertArrayHasKey( 'nothing', $props ); + } + + /** @testdox get_context_param() returns the base param shape when no properties are set. */ + public function test_get_context_param_without_properties(): void { + $schema = Schema::on( 'empty' ); + $param = $schema->get_context_param(); + + $this->assertSame( 'string', $param['type'] ); + $this->assertArrayHasKey( 'description', $param ); + $this->assertArrayHasKey( 'sanitize_callback', $param ); + $this->assertArrayHasKey( 'validate_callback', $param ); + $this->assertArrayNotHasKey( 'enum', $param ); + } + + /** @testdox get_context_param() derives enum from the union of property contexts, unique and reverse-sorted. */ + public function test_get_context_param_derives_enum_from_properties(): void { + $schema = Schema::on( 'post' ) + ->integer_property( 'id', fn( $p ) => $p->context( 'view', 'edit', 'embed' ) ) + ->string_property( 'title', fn( $p ) => $p->context( 'view', 'edit' ) ) + ->string_property( 'content', fn( $p ) => $p->context( 'view' ) ); + + $param = $schema->get_context_param(); + + $this->assertArrayHasKey( 'enum', $param ); + $this->assertSame( array( 'view', 'embed', 'edit' ), $param['enum'] ); + } + + /** @testdox get_context_param() merges caller-provided args on top of the defaults. */ + public function test_get_context_param_merges_caller_args(): void { + $schema = Schema::on( 'post' ) + ->string_property( 'title', fn( $p ) => $p->context( 'view', 'edit' ) ); + + $param = $schema->get_context_param( array( 'default' => 'view' ) ); + + $this->assertSame( 'view', $param['default'] ); + $this->assertSame( array( 'view', 'edit' ), $param['enum'] ); + } }