| Require
The minimum number of properties allowed in an object instance.
An object instance is valid against this property if its number of properties is greater than, or equal to, the
value of this attribute.
- required : list<string>
- An object instance is valid against this property if its property set contains all elements in this property's
-array value.
+ required : bool|list<string>
+ A boolean flags the property as required in its parent schema, or a list names the required members when it is an object.
+
+The AugmentRequired processor collects a boolean into the parent Schema::$required.
properties : list<Property>
A collection of properties to define for an object.
diff --git a/docs/reference/processors.md b/docs/reference/processors.md
index 9dea07f7f..d25fe6bae 100644
--- a/docs/reference/processors.md
+++ b/docs/reference/processors.md
@@ -138,6 +138,27 @@ Use the property context to extract useful information and inject that into the
+### [AugmentRequired](https://github.com/zircote/swagger-php/tree/master/src/Processors/AugmentRequired.php)
+
+Populate Schema::$required from its properties.
+
+A property with a boolean required value is always honoured; true adds it to the parent Schema::$required and false removes it.
+The boolean is consumed so it never serialises on the property itself.
+When the augmentRequired.enabled config flag is set, properties without an explicit boolean are inferred: a property backed by a PHP member (property, promoted parameter or method) becomes required when it has a known, non-nullable type.
+Nullable properties, and properties whose type cannot be determined, are left optional.
+Inference is skipped for a schema that already declares a required list, leaving that list as-is.
+
+Inference (the augmentRequired.enabled flag) is off by default; the boolean required handling always runs.
+#### Config settings
+**augmentRequired.enabled**
+: bool
+ **default**
+: false
+
+ Enables/disables the AugmentRequired processor.
+
+
+
### [AugmentDiscriminators](https://github.com/zircote/swagger-php/tree/master/src/Processors/AugmentDiscriminators.php)
Use the property context to extract useful information and inject that into the annotation.
diff --git a/src/Annotations/Property.php b/src/Annotations/Property.php
index 76c9d62cd..0a5697b80 100644
--- a/src/Annotations/Property.php
+++ b/src/Annotations/Property.php
@@ -20,6 +20,15 @@ class Property extends Schema
*/
public $property = Generator::UNDEFINED;
+ /**
+ * A boolean flags the property as required in its parent schema, or a list names the required members when it is an object.
+ *
+ * The AugmentRequired processor collects a boolean into the parent Schema::$required.
+ *
+ * @var bool|list
+ */
+ public $required = Generator::UNDEFINED; // @phpstan-ignore property.phpDocType
+
/**
* @var Encoding
*/
diff --git a/src/Attributes/Property.php b/src/Attributes/Property.php
index 28ab67c4d..b12923f05 100644
--- a/src/Attributes/Property.php
+++ b/src/Attributes/Property.php
@@ -14,7 +14,7 @@ class Property extends OA\Property
{
/**
* @param string|class-string|object|null $ref
- * @param list $required
+ * @param bool|list $required
* @param list $properties
* @param string|non-empty-array|null $type
* @param array $examples
@@ -36,7 +36,7 @@ public function __construct(
?string $description = Generator::UNDEFINED,
?int $maxProperties = null,
?int $minProperties = null,
- ?array $required = null,
+ bool|array|null $required = null,
?array $properties = null,
string|array|null $type = null,
?string $format = null,
diff --git a/src/Generator.php b/src/Generator.php
index e85bbd6a2..2049ad759 100644
--- a/src/Generator.php
+++ b/src/Generator.php
@@ -161,6 +161,9 @@ public function getDefaultConfig(): array
'augmentParameters' => [
'augmentOperationParameters' => true,
],
+ 'augmentRequired' => [
+ 'enabled' => false,
+ ],
'pathFilter' => [
'tags' => [],
'paths' => [],
@@ -249,6 +252,7 @@ public function getProcessorPipeline(): Pipeline
new Processors\AugmentSchemas(),
new Processors\AugmentRequestBody(),
new Processors\AugmentProperties(),
+ new Processors\AugmentRequired(),
new Processors\AugmentDiscriminators(),
new Processors\BuildPaths(),
new Processors\AugmentParameters(),
diff --git a/src/Processors/AugmentRequired.php b/src/Processors/AugmentRequired.php
new file mode 100644
index 000000000..8b65e1391
--- /dev/null
+++ b/src/Processors/AugmentRequired.php
@@ -0,0 +1,148 @@
+Schema::$required from its properties.
+ *
+ * A property with a boolean required value is always honoured; true adds it to the parent Schema::$required and false removes it.
+ * The boolean is consumed so it never serialises on the property itself.
+ * When the augmentRequired.enabled config flag is set, properties without an explicit boolean are inferred: a property backed by a PHP member (property, promoted parameter or method) becomes required when it has a known, non-nullable type.
+ * Nullable properties, and properties whose type cannot be determined, are left optional.
+ * Inference is skipped for a schema that already declares a required list, leaving that list as-is.
+ *
+ * Inference (the augmentRequired.enabled flag) is off by default; the boolean required handling always runs.
+ */
+class AugmentRequired
+{
+ protected bool $enabled;
+
+ public function __construct(bool $enabled = false)
+ {
+ $this->enabled = $enabled;
+ }
+
+ public function isEnabled(): bool
+ {
+ return $this->enabled;
+ }
+
+ /**
+ * Enables/disables the AugmentRequired processor.
+ */
+ public function setEnabled(bool $enabled): AugmentRequired
+ {
+ $this->enabled = $enabled;
+
+ return $this;
+ }
+
+ public function __invoke(Analysis $analysis): void
+ {
+ /** @var OA\Schema[] $schemas */
+ $schemas = $analysis->getAnnotationsOfType(OA\Schema::class);
+
+ foreach ($schemas as $schema) {
+ if (!is_array($schema->properties) || $schema->properties === []) {
+ continue;
+ }
+
+ $declared = !Generator::isDefault($schema->required);
+ $required = $declared ? $schema->required : [];
+ $changed = false;
+
+ foreach ($schema->properties as $property) {
+ // consume an explicit boolean regardless of the property name so it never serialises on the property
+ $explicit = is_bool($property->required) ? $property->required : null;
+ if (null !== $explicit) {
+ /* @phpstan-ignore assign.propertyType */
+ $property->required = Generator::UNDEFINED;
+ }
+
+ if (Generator::isDefault($property->property) || !is_string($property->property)) {
+ continue;
+ }
+ $name = $property->property;
+
+ // an explicit boolean is always honoured: true adds the property, false removes it
+ if (null !== $explicit) {
+ if (null !== $updated = $this->withRequired($required, $name, $explicit)) {
+ $required = $updated;
+ $changed = true;
+ }
+
+ continue;
+ }
+
+ // inference is opt-in and never touches an explicitly declared list
+ if (!$this->enabled || $declared) {
+ continue;
+ }
+
+ if ($this->isInferredRequired($property) && null !== $updated = $this->withRequired($required, $name, true)) {
+ $required = $updated;
+ $changed = true;
+ }
+ }
+
+ if ($changed) {
+ $schema->required = $required === [] ? Generator::UNDEFINED : $required;
+ }
+ }
+ }
+
+ /**
+ * Whether an unflagged property should be inferred as required.
+ */
+ protected function isInferredRequired(OA\Property $property): bool
+ {
+ // only infer from properties backed by a PHP member
+ $reflector = $property->_context->reflector;
+ if (!$reflector instanceof \ReflectionProperty
+ && !$reflector instanceof \ReflectionParameter
+ && !$reflector instanceof \ReflectionMethod) {
+ return false;
+ }
+
+ if ($property->isNullable()) {
+ return false;
+ }
+
+ // only mark required when a non-nullable type is actually known
+ return !Generator::isDefault($property->type) || !Generator::isDefault($property->ref);
+ }
+
+ /**
+ * Add or remove a property name in the required list.
+ *
+ * @param list $required
+ *
+ * @return list|null the updated list, or null when nothing changed
+ */
+ protected function withRequired(array $required, string $name, bool $include): ?array
+ {
+ $pos = array_search($name, $required, true);
+
+ if ($include && false === $pos) {
+ $required[] = $name;
+
+ return $required;
+ }
+
+ if (!$include && false !== $pos) {
+ unset($required[$pos]);
+
+ return array_values($required);
+ }
+
+ return null;
+ }
+}
diff --git a/tests/Analysers/AttributeAnnotationFactoryTest.php b/tests/Analysers/AttributeAnnotationFactoryTest.php
index fd32e8e97..8d8f91ddf 100644
--- a/tests/Analysers/AttributeAnnotationFactoryTest.php
+++ b/tests/Analysers/AttributeAnnotationFactoryTest.php
@@ -28,7 +28,7 @@ public function testErrorOnInvalidAttribute(): void
$rm = new \ReflectionMethod($instance, 'post');
$this->expectException(\TypeError::class);
- $this->expectExceptionMessage(Property::class . '::__construct(): Argument #9 ($required) must be of type ?array');
+ $this->expectExceptionMessage(Property::class . '::__construct(): Argument #9 ($required) must be of type array|bool|null');
(new AttributeAnnotationFactory())->build($rm, $this->getContext());
}
diff --git a/tests/Fixtures/AllNullableProperties.php b/tests/Fixtures/AllNullableProperties.php
new file mode 100644
index 000000000..e26a4ea78
--- /dev/null
+++ b/tests/Fixtures/AllNullableProperties.php
@@ -0,0 +1,19 @@
+analysisFromFixtures(
+ [$fixture],
+ $this->processorPipeline(),
+ null,
+ $enabled ? ['augmentRequired' => ['enabled' => true]] : []
+ );
+
+ return $analysis->openapi->components->schemas[0];
+ }
+
+ public function testDisabledByDefault(): void
+ {
+ $this->assertSame(Generator::UNDEFINED, $this->schema('TypedProperties.php', false)->required);
+ }
+
+ public function testMarksNonNullableTypedPropertiesRequired(): void
+ {
+ $required = $this->schema('TypedProperties.php', true)->required;
+
+ // typed, non-nullable properties (incl. $ref properties) are required
+ $this->assertContains('stringType', $required);
+ $this->assertContains('intType', $required);
+ $this->assertContains('arrayType', $required);
+ $this->assertContains('namespaced', $required);
+ }
+
+ public function testLeavesNullablePropertiesOptional(): void
+ {
+ $required = $this->schema('TypedProperties.php', true)->required;
+
+ $this->assertNotContains('nullableString', $required);
+ $this->assertNotContains('staticNullableString', $required);
+ $this->assertNotContains('mixedValue', $required);
+ }
+
+ public function testLeavesUntypedPropertiesOptional(): void
+ {
+ $required = $this->schema('TypedProperties.php', true)->required;
+
+ // without a resolvable type there is nothing to assert as required
+ $this->assertNotContains('undefined', $required);
+ $this->assertNotContains('staticUndefined', $required);
+ }
+
+ public function testRespectsExplicitNullable(): void
+ {
+ $required = $this->schema('Customer.php', true)->required;
+
+ // nullable: false makes the ?string property required
+ $this->assertContains('iq', $required);
+ // a nullable docblock type stays optional
+ $this->assertNotContains('secondname', $required);
+ }
+
+ public function testDoesNotOverrideDeclaredRequired(): void
+ {
+ // the declared list is kept; the non-nullable createdAt is not appended
+ $this->assertSame(['name'], $this->schema('UsingVar.php', true)->required);
+ }
+
+ public function testExplicitBooleanIsHonouredWithInferenceDisabled(): void
+ {
+ $schema = $this->schema('ExplicitRequired.php', false);
+
+ // required: true is collected even though inference is off; required: false and the unflagged property are not
+ $this->assertSame(['explicitlyRequired'], $schema->required);
+ }
+
+ public function testExplicitBooleanOverridesInference(): void
+ {
+ $schema = $this->schema('ExplicitRequired.php', true);
+
+ // required: true wins over the nullable type; required: false wins over the non-nullable type
+ $this->assertContains('explicitlyRequired', $schema->required);
+ $this->assertNotContains('explicitlyOptional', $schema->required);
+ }
+
+ public function testBooleanRequiredIsConsumedOnTheProperty(): void
+ {
+ $schema = $this->schema('ExplicitRequired.php', false);
+
+ // the boolean is consumed back to UNDEFINED, so it never serialises on the property where required has to be an array
+ foreach ($schema->properties as $property) {
+ $this->assertSame(Generator::UNDEFINED, $property->required);
+ }
+ }
+
+ public function testInferenceIgnoresPropertiesWithoutAPhpMember(): void
+ {
+ $required = $this->schema('InlineProperties.php', true)->required;
+
+ // a typed inline property is not backed by a PHP member, so inference leaves it out
+ $this->assertNotContains('inlineTyped', $required);
+ // an explicit boolean still applies to an inline property
+ $this->assertContains('inlineFlagged', $required);
+ }
+
+ public function testInfersFromPromotedParametersAndMethods(): void
+ {
+ $required = $this->schema('PhpMemberProperties.php', true)->required;
+
+ $this->assertContains('promoted', $required);
+ $this->assertContains('computed', $required);
+ $this->assertNotContains('promotedNullable', $required);
+ }
+
+ public function testAllNullablePropertiesLeaveRequiredUndefined(): void
+ {
+ $this->assertSame(Generator::UNDEFINED, $this->schema('AllNullableProperties.php', true)->required);
+ }
+
+ public function testRequiredFalseClearsAnEmptiedDeclaredList(): void
+ {
+ // required: false on the only declared entry must remove it, leaving no required list
+ $this->assertSame(Generator::UNDEFINED, $this->schema('DeclaredRequiredEmptied.php', false)->required);
+ }
+
+ public function testBooleanRequiredIsConsumedOnAnUnnamedProperty(): void
+ {
+ $property = $this->schema('UnnamedBooleanRequired.php', false)->properties[0];
+
+ // an unnamed property still has its boolean consumed back to UNDEFINED, never serialising as required: true
+ $this->assertSame(Generator::UNDEFINED, $property->required);
+ }
+
+ public function testExplicitTrueAddsToADeclaredList(): void
+ {
+ // a declared list is kept and a property flagged required: true is appended to it
+ $this->assertSame(['declared', 'flagged'], $this->schema('DeclaredRequiredMerged.php', false)->required);
+ }
+
+ public function testArrayRequiredOnAnObjectPropertyIsLeftUntouched(): void
+ {
+ $schema = $this->schema('ObjectPropertyWithRequired.php', false);
+
+ // an array required is the object's own member list, not a boolean flag, so it is not consumed
+ $this->assertSame(['street'], $schema->properties[0]->required);
+ // and with no boolean and inference off, the parent gains no required list
+ $this->assertSame(Generator::UNDEFINED, $schema->required);
+ }
+}
|