Skip to content

Commit 4a58049

Browse files
committed
Add class-level Definition and AdditionalProperty attributes
- Add Definition attribute for class-level schema metadata - Add AdditionalProperty attribute for custom schema properties - Update Schema class to support metadata and additional properties - Update Generator to process both new attributes - Add comprehensive unit tests - Add usage examples
1 parent 8e9d6fb commit 4a58049

19 files changed

+746
-82
lines changed

.gitignore

+3-1
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,6 @@ docs
1010
vendor
1111
node_modules
1212
.php-cs-fixer.cache
13-
runtime
13+
runtime
14+
.context
15+
mcp-*.log

context.yaml

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
$schema: 'https://raw.githubusercontent.com/context-hub/generator/refs/heads/main/json-schema.json'
2+
3+
documents:
4+
- description: 'Project structure overview'
5+
outputPath: project-structure.md
6+
overwrite: true
7+
sources:
8+
- type: tree
9+
sourcePaths:
10+
- src
11+
filePattern: '*'
12+
renderFormat: ascii
13+
enabled: true
14+
showCharCount: true
15+
16+
- description: 'Code base'
17+
outputPath: code-base.md
18+
sources:
19+
- type: file
20+
sourcePaths:
21+
- src
22+
23+
- description: Unit tests
24+
outputPath: unit-tests.md
25+
sources:
26+
- type: file
27+
sourcePaths:
28+
- tests

examples/DefinitionExample.php

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Examples;
6+
7+
use Spiral\JsonSchemaGenerator\Attribute\AdditionalProperty;
8+
use Spiral\JsonSchemaGenerator\Attribute\Definition;
9+
use Spiral\JsonSchemaGenerator\Attribute\Field;
10+
use Spiral\JsonSchemaGenerator\Generator;
11+
12+
#[Definition(
13+
title: 'Product Schema',
14+
description: 'A schema representing a product in an e-commerce system',
15+
id: 'https://example.com/schemas/product.json',
16+
schemaVersion: 'http://json-schema.org/draft-07/schema#',
17+
)]
18+
#[AdditionalProperty(name: 'additionalProperties', value: false)]
19+
#[AdditionalProperty(name: 'examples', value: [
20+
[
21+
'id' => 123,
22+
'name' => 'Sample Product',
23+
'price' => 99.99,
24+
'tags' => ['new', 'featured'],
25+
'status' => 'Active',
26+
],
27+
])]
28+
#[AdditionalProperty(name: 'maxProperties', value: 5)]
29+
class Product
30+
{
31+
public function __construct(
32+
#[Field(title: 'Product ID', description: 'Unique identifier for the product')]
33+
public readonly int $id,
34+
#[Field(title: 'Product Name', description: 'Name of the product')]
35+
public readonly string $name,
36+
#[Field(title: 'Product Price', description: 'Current price of the product')]
37+
public readonly float $price,
38+
39+
/**
40+
* @var array<string>
41+
*/
42+
#[Field(title: 'Product Tags', description: 'List of tags associated with the product')]
43+
public readonly array $tags = [],
44+
#[Field(title: 'Product Status', description: 'Current status of the product')]
45+
public readonly ?ProductStatus $status = null,
46+
) {}
47+
}
48+
49+
#[Definition(title: 'Product Status')]
50+
#[AdditionalProperty(name: 'deprecated', value: ['Discontinued'])]
51+
enum ProductStatus: string
52+
{
53+
case Active = 'Active';
54+
case Inactive = 'Inactive';
55+
case Discontinued = 'Discontinued';
56+
case OutOfStock = 'Out of Stock';
57+
}
58+
59+
// Generate the schema
60+
$generator = new Generator();
61+
$schema = $generator->generate(Product::class);
62+
63+
// Output the schema as JSON
64+
\header('Content-Type: application/json');
65+
echo \json_encode($schema, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);

src/Attribute/AdditionalProperty.php

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Spiral\JsonSchemaGenerator\Attribute;
6+
7+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
8+
class AdditionalProperty
9+
{
10+
public function __construct(
11+
public readonly string $name,
12+
public readonly mixed $value,
13+
) {}
14+
}

src/Attribute/Definition.php

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Spiral\JsonSchemaGenerator\Attribute;
6+
7+
#[\Attribute(\Attribute::TARGET_CLASS)]
8+
class Definition
9+
{
10+
public function __construct(
11+
public readonly ?string $title = null,
12+
public readonly string $description = '',
13+
public readonly ?string $id = null,
14+
public readonly ?string $schemaVersion = null,
15+
) {}
16+
}

src/Attribute/Field.php

+1-2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,5 @@ public function __construct(
1111
public readonly string $title = '',
1212
public readonly string $description = '',
1313
public readonly mixed $default = null,
14-
) {
15-
}
14+
) {}
1615
}

src/Generator.php

+74-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
namespace Spiral\JsonSchemaGenerator;
66

7+
use Spiral\JsonSchemaGenerator\Attribute\AdditionalProperty;
8+
use Spiral\JsonSchemaGenerator\Attribute\Definition as ClassDefinition;
79
use Spiral\JsonSchemaGenerator\Attribute\Field;
810
use Spiral\JsonSchemaGenerator\Parser\ClassParserInterface;
911
use Spiral\JsonSchemaGenerator\Parser\Parser;
@@ -35,6 +37,35 @@ public function generate(string|\ReflectionClass $class): Schema
3537

3638
$schema = new Schema();
3739

40+
// Process class-level Definition attribute if present
41+
$classDefinition = $class->findAttribute(ClassDefinition::class);
42+
if ($classDefinition !== null) {
43+
if (!empty($classDefinition->title)) {
44+
$schema->setTitle($classDefinition->title);
45+
} else {
46+
// Use class short name as default title
47+
$schema->setTitle($class->getShortName());
48+
}
49+
50+
if (!empty($classDefinition->description)) {
51+
$schema->setDescription($classDefinition->description);
52+
}
53+
54+
if ($classDefinition->id !== null) {
55+
$schema->setId($classDefinition->id);
56+
}
57+
58+
if ($classDefinition->schemaVersion !== null) {
59+
$schema->setSchemaVersion($classDefinition->schemaVersion);
60+
}
61+
} else {
62+
// Set title to class name by default if no definition attribute
63+
$schema->setTitle($class->getShortName());
64+
}
65+
66+
// Process additional properties attributes if present
67+
$this->processAdditionalProperties($class, $schema);
68+
3869
$dependencies = [];
3970
// Generating properties
4071
foreach ($class->getProperties() as $property) {
@@ -80,14 +111,50 @@ public function generate(string|\ReflectionClass $class): Schema
80111
return $schema;
81112
}
82113

114+
/**
115+
* Process AdditionalProperty attributes on a class
116+
*/
117+
protected function processAdditionalProperties(ClassParserInterface $class, Schema $schema): void
118+
{
119+
// Get reflection class to extract attributes with \ReflectionClass::getAttributes()
120+
try {
121+
$reflectionClass = new \ReflectionClass($class->getName());
122+
$additionalProperties = $reflectionClass->getAttributes(AdditionalProperty::class);
123+
124+
foreach ($additionalProperties as $additionalProperty) {
125+
$instance = $additionalProperty->newInstance();
126+
$schema->addAdditionalProperty($instance->name, $instance->value);
127+
}
128+
} catch (\ReflectionException) {
129+
// Silently fail, we'll just not have additional properties
130+
}
131+
}
132+
83133
protected function generateDefinition(ClassParserInterface $class, array &$dependencies = []): ?Definition
84134
{
85135
$properties = [];
136+
137+
// Process class-level Definition attribute if present
138+
$title = $class->getShortName();
139+
$description = '';
140+
141+
$classDefinition = $class->findAttribute(ClassDefinition::class);
142+
if ($classDefinition !== null) {
143+
if (!empty($classDefinition->title)) {
144+
$title = $classDefinition->title;
145+
}
146+
147+
if (!empty($classDefinition->description)) {
148+
$description = $classDefinition->description;
149+
}
150+
}
151+
86152
if ($class->isEnum()) {
87153
return new Definition(
88154
type: $class->getName(),
89155
options: $class->getEnumValues(),
90-
title: $class->getShortName(),
156+
title: $title,
157+
description: $description,
91158
);
92159
}
93160

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

105-
return new Definition(type: $class->getName(), title: $class->getShortName(), properties: $properties);
172+
return new Definition(
173+
type: $class->getName(),
174+
title: $title,
175+
description: $description,
176+
properties: $properties,
177+
);
106178
}
107179

108180
protected function generateProperty(PropertyInterface $property): ?Property

src/Parser/ClassParser.php

+11
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,17 @@ public function getProperties(): array
9898
return $properties;
9999
}
100100

101+
public function findAttribute(string $name): ?object
102+
{
103+
$attributes = $this->class->getAttributes($name);
104+
105+
if ($attributes === []) {
106+
return null;
107+
}
108+
109+
return $attributes[0]->newInstance();
110+
}
111+
101112
public function isEnum(): bool
102113
{
103114
return $this->class->isEnum();

src/Parser/ClassParserInterface.php

+11
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,15 @@ public function getProperties(): array;
2424
public function isEnum(): bool;
2525

2626
public function getEnumValues(): array;
27+
28+
/**
29+
* Find a class-level attribute.
30+
*
31+
* @template T
32+
*
33+
* @param class-string<T> $name The class name of the attribute.
34+
*
35+
* @return T|null The attribute or {@see null}, if the requested attribute does not exist.
36+
*/
37+
public function findAttribute(string $name): ?object;
2738
}

src/Schema.php

+56
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,73 @@
99
final class Schema extends AbstractDefinition
1010
{
1111
private array $definitions = [];
12+
private string $title = '';
13+
private string $description = '';
14+
private ?string $id = null;
15+
private ?string $schemaVersion = null;
16+
private array $additionalProperties = [];
1217

1318
public function addDefinition(string $name, Definition $definition): self
1419
{
1520
$this->definitions[$name] = $definition;
1621
return $this;
1722
}
1823

24+
public function setTitle(string $title): self
25+
{
26+
$this->title = $title;
27+
return $this;
28+
}
29+
30+
public function setDescription(string $description): self
31+
{
32+
$this->description = $description;
33+
return $this;
34+
}
35+
36+
public function setId(?string $id): self
37+
{
38+
$this->id = $id;
39+
return $this;
40+
}
41+
42+
public function setSchemaVersion(?string $schemaVersion): self
43+
{
44+
$this->schemaVersion = $schemaVersion;
45+
return $this;
46+
}
47+
48+
public function addAdditionalProperty(string $name, mixed $value): self
49+
{
50+
$this->additionalProperties[$name] = $value;
51+
return $this;
52+
}
53+
1954
public function jsonSerialize(): array
2055
{
2156
$schema = $this->renderProperties([]);
2257

58+
if ($this->title !== '') {
59+
$schema['title'] = $this->title;
60+
}
61+
62+
if ($this->description !== '') {
63+
$schema['description'] = $this->description;
64+
}
65+
66+
if ($this->id !== null) {
67+
$schema['$id'] = $this->id;
68+
}
69+
70+
if ($this->schemaVersion !== null) {
71+
$schema['$schema'] = $this->schemaVersion;
72+
}
73+
74+
// Add any additional properties that were specified
75+
foreach ($this->additionalProperties as $key => $value) {
76+
$schema[$key] = $value;
77+
}
78+
2379
if ($this->definitions !== []) {
2480
$schema['definitions'] = [];
2581

0 commit comments

Comments
 (0)