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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
<!-- file generated with AI assistance: Claude Code - 2026-07-22, revised 2026-10-08 13:37:13 UTC -->
<!-- file generated with AI assistance: Claude Code - 2026-07-22, revised 2026-10-08 13:37:13 UTC, revised 2026-10-10 00:25:00 UTC -->

# Changelog

## Unreleased

### Fixed

- `InvalidDefaultSchemaDecorator` drops a property `default` from the JSON/OpenAPI schemas when the property schema itself rejects it (`pattern`, `minLength`, `maxLength`, `enum`). API Platform derives `default` from the PHP initializer, so `private string $slug = '';` next to a slug `#[Assert\Regex]` produced `default: ""` that its own `pattern` rejects, and form clients showed a validation error before anyone typed. Valid defaults are untouched; a pattern PHP cannot compile keeps its default. On by default, switch off with `schema_default_cleanup.enabled: false`.

## 0.5.0 (2026-10-09)

### Added
Expand Down
17 changes: 17 additions & 0 deletions config/services_schema_default_cleanup.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# file generated with AI assistance: Claude Code - 2026-10-10 00:15:00 UTC

services:
_defaults:
autowire: false
autoconfigure: false
public: false

# Drops property defaults that violate their own pattern/minLength/
# maxLength/enum (e.g. `default: ""` next to a slug pattern). Priority 5
# wraps below RelationFieldSchemaDecorator (10); the order does not
# matter for the result, the two touch different keys.
Dmstr\ApiPlatformUtils\OpenApi\InvalidDefaultSchemaDecorator:
decorates: 'api_platform.json_schema.schema_factory'
decoration_priority: 5
arguments:
$decorated: '@.inner'
7 changes: 7 additions & 0 deletions src/DependencyInjection/ApiPlatformUtilsExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ public function load(array $configs, ContainerBuilder $container): void

$container->setParameter('dmstr_api_platform_utils.stable_order.enabled', $config['stable_order']['enabled']);

$container->setParameter('dmstr_api_platform_utils.schema_default_cleanup.enabled', $config['schema_default_cleanup']['enabled']);

// Load service definitions
$loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config'));
$loader->load('services.yaml');
Expand All @@ -75,6 +77,11 @@ public function load(array $configs, ContainerBuilder $container): void
if ($config['stable_order']['enabled']) {
$loader->load('services_stable_order.yaml');
}

// On by default: it only removes defaults the schema itself rejects.
if ($config['schema_default_cleanup']['enabled']) {
$loader->load('services_schema_default_cleanup.yaml');
}
}

public function getAlias(): string
Expand Down
11 changes: 11 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ public function getConfigTreeBuilder(): TreeBuilder
->end()
->end()

// Invalid property defaults in JSON schemas
->arrayNode('schema_default_cleanup')
->addDefaultsIfNotSet()
->children()
->booleanNode('enabled')
->defaultTrue()
->info('Drop a property `default` from the JSON/OpenAPI schemas when the property schema itself rejects it (pattern, minLength, maxLength, enum), e.g. `default: ""` derived from `private string $slug = \'\'` next to a slug pattern')
->end()
->end()
->end()

// Hydra Operations Subscriber
->arrayNode('hydra_operations')
->addDefaultsIfNotSet()
Expand Down
137 changes: 137 additions & 0 deletions src/OpenApi/InvalidDefaultSchemaDecorator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php
// file generated with AI assistance: Claude Code - 2026-10-10 00:15:00 UTC

declare(strict_types=1);

namespace Dmstr\ApiPlatformUtils\OpenApi;

use ApiPlatform\JsonSchema\Schema;
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
use ApiPlatform\Metadata\Operation;

/**
* Drops a property `default` that its own property schema rejects.
*
* API Platform derives `default` from the PHP property initializer, so
* `private string $slug = '';` next to `#[Assert\Regex(...)]` yields
* `{"pattern": "^[a-z0-9-]+$", "default": ""}` — a schema that contradicts
* itself. Form clients start from the default and show a validation error
* before anyone has typed. Removing the default only removes the
* contradiction: the field simply starts empty in the client, and the
* server-side validation is unchanged.
*
* Checked keywords: `pattern`, `minLength`, `maxLength` (strings) and `enum`.
* A pattern PHP cannot compile is treated as "unknown" and keeps the default.
*/
class InvalidDefaultSchemaDecorator implements SchemaFactoryInterface
{
public function __construct(
private readonly SchemaFactoryInterface $decorated,
) {
}

public function buildSchema(
string $className,
string $format = 'json',
string $type = Schema::TYPE_OUTPUT,
?Operation $operation = null,
?Schema $schema = null,
?array $serializerContext = null,
bool $forceCollection = false,
): Schema {
$schema = $this->decorated->buildSchema(
$className,
$format,
$type,
$operation,
$schema,
$serializerContext,
$forceCollection,
);

$definitions = $schema->getDefinitions();
foreach ($definitions as $key => $definition) {
$definitions[$key] = $this->cleanNode($definition);
}

return $schema;
}

/**
* Walks `properties` (nested objects included) and `allOf`/`anyOf`/`oneOf`
* branches, the places API Platform puts property schemas.
*/
private function cleanNode(mixed $node): mixed
{
if (!\is_array($node) && !$node instanceof \ArrayObject) {
return $node;
}

if (isset($node['properties']) && (\is_array($node['properties']) || $node['properties'] instanceof \ArrayObject)) {
$properties = $node['properties'];
foreach ($properties as $name => $property) {
$properties[$name] = $this->cleanNode($this->dropInvalidDefault($property));
}
$node['properties'] = $properties;
}

foreach (['allOf', 'anyOf', 'oneOf'] as $keyword) {
if (isset($node[$keyword]) && \is_array($node[$keyword])) {
$branches = $node[$keyword];
foreach ($branches as $index => $branch) {
$branches[$index] = $this->cleanNode($branch);
}
$node[$keyword] = $branches;
}
}

return $node;
}

private function dropInvalidDefault(mixed $property): mixed
{
if ((!\is_array($property) && !$property instanceof \ArrayObject) || !isset($property['default'])) {
return $property;
}

if (!$this->violates($property, $property['default'])) {
return $property;
}

unset($property['default']);

return $property;
}

private function violates(array|\ArrayObject $property, mixed $default): bool
{
if (isset($property['enum']) && \is_array($property['enum']) && !\in_array($default, $property['enum'], true)) {
return true;
}

if (!\is_string($default)) {
return false;
}

$length = mb_strlen($default);
if (isset($property['minLength']) && $length < (int) $property['minLength']) {
return true;
}
if (isset($property['maxLength']) && $length > (int) $property['maxLength']) {
return true;
}

if (isset($property['pattern']) && \is_string($property['pattern'])) {
// JSON Schema patterns are ECMA-262 and unanchored; the delimiter
// is escaped, everything else is passed through as written.
$regex = '/' . str_replace('/', '\/', $property['pattern']) . '/u';
$match = @preg_match($regex, $default);
if ($match === 0) {
return true;
}
// false: PHP cannot compile it — keep the default rather than guess
}

return false;
}
}
14 changes: 14 additions & 0 deletions tests/DependencyInjection/ApiPlatformUtilsExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Dmstr\ApiPlatformUtils\DependencyInjection\ApiPlatformUtilsExtension;
use Dmstr\ApiPlatformUtils\Doctrine\Orm\Extension\StableOrderExtension;
use Dmstr\ApiPlatformUtils\Metadata\AutoOrderResourceMetadataCollectionFactory;
use Dmstr\ApiPlatformUtils\OpenApi\InvalidDefaultSchemaDecorator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\DependencyInjection\ContainerBuilder;
Expand Down Expand Up @@ -67,6 +68,19 @@ public function testStableOrderServiceTag(): void
);
}

public function testSchemaDefaultCleanupIsOnByDefaultAndCanBeSwitchedOff(): void
{
$on = $this->load([self::BASE]);
self::assertTrue($on->hasDefinition(InvalidDefaultSchemaDecorator::class));
self::assertSame(
'api_platform.json_schema.schema_factory',
$on->getDefinition(InvalidDefaultSchemaDecorator::class)->getDecoratedService()[0],
);

$off = $this->load([self::BASE + ['schema_default_cleanup' => ['enabled' => false]]]);
self::assertFalse($off->hasDefinition(InvalidDefaultSchemaDecorator::class));
}

public function testLabelCandidatesOfSeveralConfigsAreDeduplicated(): void
{
$container = $this->load([
Expand Down
108 changes: 108 additions & 0 deletions tests/OpenApi/InvalidDefaultSchemaDecoratorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php
// file generated with AI assistance: Claude Code - 2026-10-10 00:20:00 UTC

declare(strict_types=1);

namespace Dmstr\ApiPlatformUtils\Tests\OpenApi;

use ApiPlatform\JsonSchema\Schema;
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
use ApiPlatform\Metadata\Operation;
use Dmstr\ApiPlatformUtils\OpenApi\InvalidDefaultSchemaDecorator;
use PHPUnit\Framework\TestCase;

final class InvalidDefaultSchemaDecoratorTest extends TestCase
{
private const SLUG = ['type' => 'string', 'pattern' => '^([a-z0-9]+(?:-[a-z0-9]+)*)$'];

public function testDropsDefaultThatViolatesPatternInsideAllOf(): void
{
// the shape of a `.jsonld` read schema: properties inside allOf[1]
$properties = $this->build(['Node.jsonld' => ['allOf' => [
['$ref' => '#/definitions/HydraItemBaseSchema'],
['type' => 'object', 'properties' => ['slug' => self::SLUG + ['default' => '']]],
]]])['Node.jsonld']['allOf'][1]['properties'];

self::assertSame(self::SLUG, $properties['slug']);
}

public function testKeepsDefaultsTheSchemaAccepts(): void
{
$properties = $this->build(['Node' => ['type' => 'object', 'properties' => [
'slug' => self::SLUG + ['default' => 'home'],
'title' => ['type' => 'string', 'default' => ''],
'status' => ['type' => 'string', 'enum' => ['draft', 'published'], 'default' => 'draft'],
'position' => ['type' => 'integer', 'minimum' => 0, 'default' => 0],
]]])['Node']['properties'];

self::assertSame('home', $properties['slug']['default']);
self::assertSame('', $properties['title']['default']);
self::assertSame('draft', $properties['status']['default']);
self::assertSame(0, $properties['position']['default']);
}

public function testDropsDefaultsViolatingLengthAndEnum(): void
{
$properties = $this->build(['Node' => ['type' => 'object', 'properties' => [
'name' => ['type' => 'string', 'minLength' => 1, 'default' => ''],
'code' => ['type' => 'string', 'maxLength' => 2, 'default' => 'abc'],
'status' => ['type' => 'string', 'enum' => ['draft'], 'default' => 'gone'],
]]])['Node']['properties'];

self::assertArrayNotHasKey('default', $properties['name']);
self::assertArrayNotHasKey('default', $properties['code']);
self::assertArrayNotHasKey('default', $properties['status']);
}

public function testWalksNestedObjectProperties(): void
{
$nested = $this->build(['Node' => ['type' => 'object', 'properties' => [
'meta' => ['type' => 'object', 'properties' => ['key' => self::SLUG + ['default' => '']]],
]]])['Node']['properties']['meta']['properties'];

self::assertArrayNotHasKey('default', $nested['key']);
}

public function testKeepsDefaultWhenPhpCannotCompileThePattern(): void
{
// ECMA-only syntax PHP's PCRE rejects: keep rather than guess
$properties = $this->build(['Node' => ['type' => 'object', 'properties' => [
'odd' => ['type' => 'string', 'pattern' => '(?<=a', 'default' => ''],
]]])['Node']['properties'];

self::assertSame('', $properties['odd']['default']);
}

public function testPatternWithSlashIsEscaped(): void
{
$properties = $this->build(['Node' => ['type' => 'object', 'properties' => [
'path' => ['type' => 'string', 'pattern' => '^/[a-z]+$', 'default' => '/home'],
'bad' => ['type' => 'string', 'pattern' => '^/[a-z]+$', 'default' => ''],
]]])['Node']['properties'];

self::assertSame('/home', $properties['path']['default']);
self::assertArrayNotHasKey('default', $properties['bad']);
}

/**
* @param array<string, mixed> $definitions
*/
private function build(array $definitions): \ArrayObject
{
$inner = new class(new \ArrayObject($definitions)) implements SchemaFactoryInterface {
public function __construct(private readonly \ArrayObject $definitions)
{
}

public function buildSchema(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema
{
$schema = new Schema();
$schema->setDefinitions($this->definitions);

return $schema;
}
};

return (new InvalidDefaultSchemaDecorator($inner))->buildSchema('Node')->getDefinitions();
}
}
Loading