Skip to content

Commit

Permalink
add class converter and more tests
Browse files Browse the repository at this point in the history
  • Loading branch information
tymondesigns committed Jan 16, 2025
1 parent 0a888dc commit 8b3efe5
Show file tree
Hide file tree
Showing 26 changed files with 626 additions and 176 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ composer.lock
.env
.DS_Store
.phpstan-cache
coverage
58 changes: 57 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,7 @@ $schema

## Converting to JSON Schema

This uses reflection to infer the schema from the closure parameters and docblocks.
This uses reflection to infer the schema from the parameters and docblocks.

### From a Closure

Expand Down Expand Up @@ -500,6 +500,62 @@ $schema->toJson();
}
```

### From a Class

```php
use Cortex\JsonSchema\SchemaFactory;

class User
{
/**
* @var string The name of the user
*/
public string $name;

/**
* @var ?int The age of the user
*/
public ?int $age = null;

/**
* @var float The height of the user in meters
*/
public float $height = 1.7;
}

$schema = SchemaFactory::fromClass(User::class);

$schema->toJson();
```

```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "User",
"properties": {
"name": {
"type": "string",
"description": "The name of the user"
},
"age": {
"type": [
"integer",
"null"
],
"description": "The age of the user",
"default": null
},
"height": {
"type": "number",
"description": "The height of the user in meters",
"default": 1.7
}
},
"required": ["name"]
}
```

## Credits

- [Sean Tymon](https://github.com/tymondesigns)
Expand Down
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
},
"require-dev": {
"pestphp/pest": "^3.0",
"pestphp/pest-plugin-type-coverage": "^3.2",
"phpstan/phpstan": "^2.0",
"rector/rector": "^2.0",
"symplify/easy-coding-standard": "^12.5"
Expand All @@ -42,14 +43,16 @@
"ecs": "ecs check --fix",
"rector": "rector process",
"stan": "phpstan analyse",
"type-coverage": "pest --type-coverage --min=100",
"format": [
"@rector",
"@ecs"
],
"check": [
"@format",
"@test",
"@stan"
"@stan",
"@type-coverage"
]
},
"config": {
Expand Down
5 changes: 5 additions & 0 deletions phpunit.dist.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
<coverage>
<report>
<html outputDirectory="coverage"/>
</report>
</coverage>
<source>
<include>
<directory suffix=".php">./src</directory>
Expand Down
15 changes: 15 additions & 0 deletions src/Contracts/Converter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Cortex\JsonSchema\Contracts;

use Cortex\JsonSchema\Types\ObjectSchema;

interface Converter
{
/**
* Convert the value to an object schema.
*/
public function convert(): ObjectSchema;
}
14 changes: 0 additions & 14 deletions src/Contracts/Schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@

namespace Cortex\JsonSchema\Contracts;

use Cortex\JsonSchema\Enums\SchemaType;

interface Schema
{
/**
Expand All @@ -23,18 +21,6 @@ public function getTitle(): ?string;
*/
public function description(string $description): static;

/**
* Get the description
*/
public function getDescription(): ?string;

/**
* Get the type or types
*
* @return \Cortex\JsonSchema\Enums\SchemaType|array<int, \Cortex\JsonSchema\Enums\SchemaType>
*/
public function getType(): SchemaType|array;

/**
* Determine if the schema is required
*/
Expand Down
128 changes: 128 additions & 0 deletions src/Converters/ClassConverter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

declare(strict_types=1);

namespace Cortex\JsonSchema\Converters;

use BackedEnum;
use ReflectionEnum;
use ReflectionClass;
use ReflectionProperty;
use ReflectionNamedType;
use Cortex\JsonSchema\Contracts\Schema;
use Cortex\JsonSchema\Support\DocParser;
use Cortex\JsonSchema\Types\ObjectSchema;
use Cortex\JsonSchema\Contracts\Converter;
use Cortex\JsonSchema\Converters\Concerns\InteractsWithTypes;

class ClassConverter implements Converter
{
use InteractsWithTypes;

/**
* @var ReflectionClass<object>
*/
protected ReflectionClass $reflection;

/**
* @param object|class-string $class
*/
public function __construct(
protected object|string $class,
protected bool $publicOnly = true,
) {
$this->reflection = new ReflectionClass($this->class);
}

public function convert(): ObjectSchema
{
$schema = new ObjectSchema();

// Get the description from the doc parser
$description = $this->getDocParser($this->reflection)?->description() ?? null;

// Add the description to the schema if it exists
if ($description !== null) {
$schema->description($description);
}

$properties = $this->reflection->getProperties(
$this->publicOnly ? ReflectionProperty::IS_PUBLIC : null,
);

// Add the properties to the object schema
foreach ($properties as $property) {
$schema->properties(self::getSchemaFromReflectionProperty($property));
}

return $schema;
}

/**
* Create a schema from a given type.
*/
protected function getSchemaFromReflectionProperty(
ReflectionProperty $property,
): Schema {
$type = $property->getType();

// @phpstan-ignore argument.type
$schema = self::getSchemaFromReflectionType($type);

$schema->title($property->getName());

// Add the description to the schema if it exists
$variable = $this->getDocParser($property)?->variable();

// Add the description to the schema if it exists
if (isset($variable['description']) && $variable['description'] !== '') {
$schema->description($variable['description']);
}

if ($type === null || $type->allowsNull()) {
$schema->nullable();
}

if ($property->hasDefaultValue()) {
$defaultValue = $property->getDefaultValue();

// If the default value is a backed enum, use its value
if ($defaultValue instanceof BackedEnum) {
$defaultValue = $defaultValue->value;
}

$schema->default($defaultValue);
} else {
$schema->required();
}

// If it's an enum, add the possible values
if ($type instanceof ReflectionNamedType) {
$typeName = $type->getName();

if (enum_exists($typeName)) {
$reflection = new ReflectionEnum($typeName);

if ($reflection->isBacked()) {
/** @var non-empty-array<array-key, \BackedEnum> */
$cases = $typeName::cases();
$schema->enum(array_map(fn(BackedEnum $case): int|string => $case->value, $cases));
}
}
}

return $schema;
}

/**
* @param ReflectionProperty|ReflectionClass<object> $reflection
*/
protected function getDocParser(ReflectionProperty|ReflectionClass $reflection): ?DocParser
{
if ($docComment = $reflection->getDocComment()) {
return new DocParser($docComment);
}

return null;
}
}
Loading

0 comments on commit 8b3efe5

Please sign in to comment.