Skip to content
Closed
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
107 changes: 107 additions & 0 deletions generator/JsonApiDtoGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,27 @@
use Crescat\SaloonSdkGenerator\Generator;
use Crescat\SaloonSdkGenerator\Helpers\NameHelper;
use Crescat\SaloonSdkGenerator\Helpers\Utils;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\Literal;
use Nette\PhpGenerator\PhpFile;
use Timatic\Hydration\Attributes\DateTime;
use Timatic\Hydration\Attributes\Property;
use Timatic\Hydration\Attributes\Relationship;
use Timatic\Hydration\Model;
use Timatic\Hydration\RelationType;

class JsonApiDtoGenerator extends Generator
{
protected array $generated = [];

protected ApiSpecification $specification;

public function generate(ApiSpecification $specification): PhpFile|array
{
$this->specification = $specification;

if ($specification->components) {
foreach ($specification->components->schemas as $className => $schema) {
$this->generateModelClass(NameHelper::safeClassName($className), $schema);
Expand Down Expand Up @@ -55,6 +63,9 @@ protected function generateModelClass(string $className, Schema $schema): PhpFil
$this->addPropertyToClass($classType, $namespace, $propertyName, $propertySpec);
}

// Add relationship properties
$this->addRelationshipProperties($classType, $namespace, $schema);

// Add imports
$namespace->addUse(Model::class);
$namespace->addUse(Property::class);
Expand Down Expand Up @@ -177,4 +188,100 @@ protected function mapType(string $type, ?string $format = null): string
'null' => 'null',
};
}

/**
* Add relationship properties to the DTO class
*/
protected function addRelationshipProperties(ClassType $classType, $namespace, Schema $schema): void
{
// Check if schema has relationships
if (! isset($schema->properties['relationships'])) {
return;
}

$relationships = $schema->properties['relationships'];

if (! isset($relationships->properties) || ! is_array($relationships->properties)) {
return;
}

// Import required classes
$namespace->addUse(Relationship::class);
$namespace->addUse(RelationType::class);
$namespace->addUse(Collection::class);

foreach ($relationships->properties as $relationName => $relationSpec) {
$relationType = $this->detectRelationType($relationName, $relationSpec);
$relatedModel = $this->detectRelatedModel($relationName);

if (! $relatedModel) {
// Skip if we can't determine the related model
echo " ⚠️ Skipping relationship '{$relationName}' - model not found\n";

continue;
}

// Check if related model schema exists
if (! isset($this->specification->components->schemas[$relatedModel])) {
echo " ⚠️ Skipping relationship '{$relationName}' - model '{$relatedModel}' not found in schemas\n";

continue;
}

// Import related model
$namespace->addUse("Timatic\\Dto\\{$relatedModel}");

// Create property
$property = $classType->addProperty($relationName)
->setPublic()
->setNullable(true)
->setValue(null); // Add default value

// Set type based on relationship type
// Use FQN for Collection and related model to avoid backslash prefix
if ($relationType === 'Many') {
$property->setType('null|\\Illuminate\\Support\\Collection');
$property->addComment("@var Collection<int, {$relatedModel}>|null");
} else {
$property->setType("null|\\Timatic\\Dto\\{$relatedModel}");
}

// Add Relationship attribute (use full class name for attribute)
$property->addAttribute(Relationship::class, [
new Literal("{$relatedModel}::class"),
new Literal("RelationType::{$relationType}"),
]);
}
}

/**
* Detect relationship type (One or Many) from relationship name
*/
protected function detectRelationType(string $relationName, $relationSpec): string
{
// Plural relationship names are typically "to-many"
if (Str::plural($relationName) === $relationName) {
return 'Many';
}

// Singular names are "to-one"
return 'One';
}

/**
* Detect related model class name from relationship name
*/
protected function detectRelatedModel(string $relationName): ?string
{
// Convert relationship name to model name
// Examples:
// budgetType -> BudgetType
// entries -> Entry
// currentPeriod -> Period

$singular = Str::singular($relationName);
$modelName = NameHelper::dtoClassName($singular);

return $modelName;
}
}
14 changes: 14 additions & 0 deletions generator/JsonApiFactoryGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ protected function getDtoProperties(string $dtoFullClass): array
continue;
}

// Skip relationship properties (they have Relationship attribute)
$hasRelationshipAttribute = false;
foreach ($property->getAttributes() as $attribute) {
$attrName = $attribute->getName();
if ($attrName === 'Relationship' || str_ends_with($attrName, '\\Relationship')) {
$hasRelationshipAttribute = true;
break;
}
}

if ($hasRelationshipAttribute) {
continue;
}

// Check if property has DateTime attribute
$isDateTime = ! empty($property->getAttributes(DateTime::class));

Expand Down
85 changes: 77 additions & 8 deletions generator/JsonApiRequestGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@
use Crescat\SaloonSdkGenerator\Data\Generator\Parameter;
use Crescat\SaloonSdkGenerator\Generators\RequestGenerator;
use Crescat\SaloonSdkGenerator\Helpers\MethodGeneratorHelper;
use Illuminate\Support\Str;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\PhpFile;
use Saloon\Http\Response;
use Saloon\PaginationPlugin\Contracts\Paginatable;
use Timatic\Generator\TestGenerators\Traits\DtoHelperTrait;
use Timatic\Hydration\Facades\Hydrator;
use Timatic\Hydration\Model;
use Timatic\Requests\HasFilters;
use Timatic\Requests\Concerns\HasFilters;
use Timatic\Requests\Concerns\HasIncludes;

class JsonApiRequestGenerator extends RequestGenerator
{
Expand Down Expand Up @@ -86,6 +88,15 @@ protected function customizeRequestClass(ClassType $classType, $namespace, Endpo
$namespace->addUse(HasFilters::class);
$classType->addTrait(HasFilters::class);
}

// Add HasIncludes trait if endpoint has include parameter
if ($this->hasIncludeParameter($endpoint)) {
$namespace->addUse(HasIncludes::class);
$classType->addTrait(HasIncludes::class);

// Add relationship-specific include methods
$this->addIncludeMethods($classType, $namespace, $endpoint);
}
}

// Add hydration support to GET, POST, and PATCH requests
Expand Down Expand Up @@ -121,24 +132,32 @@ protected function customizeConstructor($classConstructor, ClassType $classType,
}

/**
* Hook: Filter out filter* query parameters (handled by HasFilters trait)
* Hook: Filter out filter* and include query parameters (handled by traits)
*/
protected function shouldIncludeQueryParameter(string $paramName): bool
{
return ! str_starts_with($paramName, 'filter');
// Filter out filter* parameters (handled by HasFilters trait)
if (str_starts_with($paramName, 'filter')) {
return false;
}

// Filter out include parameter (handled by HasIncludes trait)
if ($paramName === 'include') {
return false;
}

return true;
}

/**
* Hook: Generate defaultQuery method with custom JSON:API logic
*/
protected function generateDefaultQueryMethod(\Nette\PhpGenerator\ClassType $classType, $namespace, array $queryParams, Endpoint $endpoint): void
{
// If we have any query parameters (likely just 'include'), use array_filter

// For other cases with query parameters, use parent implementation
if (! empty($queryParams)) {
$classType->addMethod('defaultQuery')
->setProtected()
->setReturnType('array')
->addBody("return array_filter(['include' => \$this->include]);");
parent::generateDefaultQueryMethod($classType, $namespace, $queryParams, $endpoint);
}
}

Expand Down Expand Up @@ -168,6 +187,20 @@ protected function hasFilterParameters(Endpoint $endpoint): bool
return false;
}

/**
* Check if endpoint has include query parameter
*/
protected function hasIncludeParameter(Endpoint $endpoint): bool
{
foreach ($endpoint->queryParameters as $param) {
if ($param->name === 'include') {
return true;
}
}

return false;
}

/**
* Determine if request should have hydration support
*/
Expand Down Expand Up @@ -229,4 +262,40 @@ protected function addHydrationSupport(ClassType $classType, $namespace, Endpoin
$method->addBody(');');
}
}

/**
* Add relationship-specific include methods to request class
*/
protected function addIncludeMethods(ClassType $classType, $namespace, Endpoint $endpoint): void
{
// Get the DTO class name for this endpoint
$dtoClassName = $this->getDtoClassName($endpoint);

// Check if schema exists in specification
if (! isset($this->specification->components->schemas[$dtoClassName])) {
return;
}

$schema = $this->specification->components->schemas[$dtoClassName];

// Check if schema has relationships
if (! isset($schema->properties['relationships'])) {
return;
}

$relationships = $schema->properties['relationships'];

// Generate include method for each relationship
if (isset($relationships->properties) && is_array($relationships->properties)) {
foreach ($relationships->properties as $relationName => $relationSpec) {
$methodName = 'include'.Str::studly($relationName);

$classType->addMethod($methodName)
->setPublic()
->setReturnType('static')
->addComment("Include the {$relationName} relationship in the response")
->addBody("return \$this->addInclude('{$relationName}');");
}
}
}
}
Loading
Loading