Skip to content

Add class-level Definition and AdditionalProperty attributes #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: 2.x
Choose a base branch
from
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
2 changes: 1 addition & 1 deletion .github/workflows/cs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ jobs:
os: >-
['ubuntu-latest']
php: >-
['8.2']
['8.3']
4 changes: 2 additions & 2 deletions .github/workflows/phpunit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ jobs:
os: >-
['ubuntu-latest']
php: >-
['8.1', '8.2', '8.3']
['8.3']
stability: >-
['prefer-lowest', 'prefer-stable']
['prefer-stable']
2 changes: 1 addition & 1 deletion .github/workflows/psalm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ jobs:
os: >-
['ubuntu-latest']
php: >-
['8.2']
['8.3']
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ docs
vendor
node_modules
.php-cs-fixer.cache
runtime
runtime
.context
mcp-*.log
56 changes: 56 additions & 0 deletions context.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json'

documents:
- description: 'Project structure overview'
outputPath: project-structure.md
overwrite: true
sources:
- type: tree
sourcePaths:
- src
filePattern: '*'
renderFormat: ascii
enabled: true
showCharCount: true

- description: 'Code base'
outputPath: code-base.md
sources:
- type: file
sourcePaths:
- src

- description: Unit tests
outputPath: unit-tests.md
sources:
- type: file
sourcePaths:
- tests

tools:
- id: run-all-tests
description: "Run all PHPUnit tests for the json-schema-generator"
type: run
commands:
- cmd: composer
args:
- install
workingDir: "./"
- cmd: vendor/bin/phpunit
args:
- "--color=always"
workingDir: "./"

- id: run-union-tests
description: "Run only the union type tests"
type: run
commands:
- cmd: composer
args:
- install
workingDir: "./"
- cmd: vendor/bin/phpunit
args:
- "--color=always"
- "--filter=UnionType"
workingDir: "./"
65 changes: 65 additions & 0 deletions examples/DefinitionExample.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace Examples;

use Spiral\JsonSchemaGenerator\Attribute\AdditionalProperty;
use Spiral\JsonSchemaGenerator\Attribute\Definition;
use Spiral\JsonSchemaGenerator\Attribute\Field;
use Spiral\JsonSchemaGenerator\Generator;

#[Definition(
title: 'Product Schema',
description: 'A schema representing a product in an e-commerce system',
id: 'https://example.com/schemas/product.json',
schemaVersion: 'http://json-schema.org/draft-07/schema#',
)]
#[AdditionalProperty(name: 'additionalProperties', value: false)]
#[AdditionalProperty(name: 'examples', value: [
[
'id' => 123,
'name' => 'Sample Product',
'price' => 99.99,
'tags' => ['new', 'featured'],
'status' => 'Active',
],
])]
#[AdditionalProperty(name: 'maxProperties', value: 5)]
class Product
{
public function __construct(
#[Field(title: 'Product ID', description: 'Unique identifier for the product')]
public readonly int $id,
#[Field(title: 'Product Name', description: 'Name of the product')]
public readonly string $name,
#[Field(title: 'Product Price', description: 'Current price of the product')]
public readonly float $price,

/**
* @var array<string>
*/
#[Field(title: 'Product Tags', description: 'List of tags associated with the product')]
public readonly array $tags = [],
#[Field(title: 'Product Status', description: 'Current status of the product')]
public readonly ?ProductStatus $status = null,
) {}
}

#[Definition(title: 'Product Status')]
#[AdditionalProperty(name: 'deprecated', value: ['Discontinued'])]
enum ProductStatus: string
{
case Active = 'Active';
case Inactive = 'Inactive';
case Discontinued = 'Discontinued';
case OutOfStock = 'Out of Stock';
}

// Generate the schema
$generator = new Generator();
$schema = $generator->generate(Product::class);

// Output the schema as JSON
\header('Content-Type: application/json');
echo \json_encode($schema, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
45 changes: 45 additions & 0 deletions examples/UnionTypeExample.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace Examples;

use Spiral\JsonSchemaGenerator\Attribute\Field;

/**
* Example DTO with union types to demonstrate the oneOf JSON Schema generation.
*/
class UnionTypeExample
{
public function __construct(
#[Field(title: 'String or Integer Value', description: 'A value that can be either a string or an integer')]
public readonly string|int $stringOrInt,

#[Field(title: 'Multiple Types', description: 'A value that can be one of multiple types')]
public readonly string|int|bool|null $multiType = null,

#[Field(title: 'Object Union', description: 'A value that can be one of multiple object types')]
public readonly SimpleObject|ComplexObject|null $objectUnion = null,
) {}
}

/**
* Simple object for union type example.
*/
class SimpleObject
{
public function __construct(
public readonly string $name,
) {}
}

/**
* Complex object for union type example.
*/
class ComplexObject
{
public function __construct(
public readonly string $title,
public readonly int $count,
) {}
}
6 changes: 6 additions & 0 deletions runAllTests.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?php
// Simple script to execute all PHPUnit tests

echo "Running all PHPUnit tests...\n";
passthru('vendor/bin/phpunit', $exitCode);
exit($exitCode);
14 changes: 14 additions & 0 deletions src/Attribute/AdditionalProperty.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Spiral\JsonSchemaGenerator\Attribute;

#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
class AdditionalProperty
{
public function __construct(
public readonly string $name,
public readonly mixed $value,
) {}
}
16 changes: 16 additions & 0 deletions src/Attribute/Definition.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Spiral\JsonSchemaGenerator\Attribute;

#[\Attribute(\Attribute::TARGET_CLASS)]
class Definition
{
public function __construct(
public readonly ?string $title = null,
public readonly string $description = '',
public readonly ?string $id = null,
public readonly ?string $schemaVersion = null,
) {}
}
3 changes: 1 addition & 2 deletions src/Attribute/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,5 @@ public function __construct(
public readonly string $title = '',
public readonly string $description = '',
public readonly mixed $default = null,
) {
}
) {}
}
83 changes: 81 additions & 2 deletions src/Generator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@

namespace Spiral\JsonSchemaGenerator;

use Spiral\JsonSchemaGenerator\Attribute\AdditionalProperty;
use Spiral\JsonSchemaGenerator\Attribute\Definition as ClassDefinition;
use Spiral\JsonSchemaGenerator\Attribute\Field;
use Spiral\JsonSchemaGenerator\Parser\ClassParserInterface;
use Spiral\JsonSchemaGenerator\Parser\Parser;
use Spiral\JsonSchemaGenerator\Parser\ParserInterface;
use Spiral\JsonSchemaGenerator\Parser\PropertyInterface;
use Spiral\JsonSchemaGenerator\Parser\TypeInterface;
use Spiral\JsonSchemaGenerator\Parser\UnionType;
use Spiral\JsonSchemaGenerator\Schema\Definition;
use Spiral\JsonSchemaGenerator\Schema\Property;

Expand All @@ -35,6 +38,35 @@ public function generate(string|\ReflectionClass $class): Schema

$schema = new Schema();

// Process class-level Definition attribute if present
$classDefinition = $class->findAttribute(ClassDefinition::class);
if ($classDefinition !== null) {
if (!empty($classDefinition->title)) {
$schema->setTitle($classDefinition->title);
} else {
// Use class short name as default title
$schema->setTitle($class->getShortName());
}

if (!empty($classDefinition->description)) {
$schema->setDescription($classDefinition->description);
}

if ($classDefinition->id !== null) {
$schema->setId($classDefinition->id);
}

if ($classDefinition->schemaVersion !== null) {
$schema->setSchemaVersion($classDefinition->schemaVersion);
}
} else {
// Set title to class name by default if no definition attribute
$schema->setTitle($class->getShortName());
}

// Process additional properties attributes if present
$this->processAdditionalProperties($class, $schema);

$dependencies = [];
// Generating properties
foreach ($class->getProperties() as $property) {
Expand Down Expand Up @@ -80,14 +112,50 @@ public function generate(string|\ReflectionClass $class): Schema
return $schema;
}

/**
* Process AdditionalProperty attributes on a class
*/
protected function processAdditionalProperties(ClassParserInterface $class, Schema $schema): void
{
// Get reflection class to extract attributes with \ReflectionClass::getAttributes()
try {
$reflectionClass = new \ReflectionClass($class->getName());
$additionalProperties = $reflectionClass->getAttributes(AdditionalProperty::class);

foreach ($additionalProperties as $additionalProperty) {
$instance = $additionalProperty->newInstance();
$schema->addAdditionalProperty($instance->name, $instance->value);
}
} catch (\ReflectionException) {
// Silently fail, we'll just not have additional properties
}
}

protected function generateDefinition(ClassParserInterface $class, array &$dependencies = []): ?Definition
{
$properties = [];

// Process class-level Definition attribute if present
$title = $class->getShortName();
$description = '';

$classDefinition = $class->findAttribute(ClassDefinition::class);
if ($classDefinition !== null) {
if (!empty($classDefinition->title)) {
$title = $classDefinition->title;
}

if (!empty($classDefinition->description)) {
$description = $classDefinition->description;
}
}

if ($class->isEnum()) {
return new Definition(
type: $class->getName(),
options: $class->getEnumValues(),
title: $class->getShortName(),
title: $title,
description: $description,
);
}

Expand All @@ -102,7 +170,12 @@ protected function generateDefinition(ClassParserInterface $class, array &$depen
$properties[$property->getName()] = $psc;
}

return new Definition(type: $class->getName(), title: $class->getShortName(), properties: $properties);
return new Definition(
type: $class->getName(),
title: $title,
description: $description,
properties: $properties,
);
}

protected function generateProperty(PropertyInterface $property): ?Property
Expand All @@ -125,6 +198,12 @@ protected function generateProperty(PropertyInterface $property): ?Property

$type = $property->getType();

// Handle union types (e.g., string|int|bool)
if ($type instanceof UnionType) {
$required = $default === null && !$type->allowsNull();
return new Property($type, [], $title, $description, $required, $default);
}

$options = [];
if ($property->isCollection()) {
$options = \array_map(
Expand Down
Loading
Loading