Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.99% covered (warning)
86.99%
127 / 146
50.00% covered (danger)
50.00%
8 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
ComponentsBuilder
86.99% covered (warning)
86.99%
127 / 146
50.00% covered (danger)
50.00%
8 / 16
82.11
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 createWithExistingComponents
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 runInContentType
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 setContentType
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getContentType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMixedReference
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getSchemaForReference
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getComponents
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 checkDuplicate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setSchema
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 getSchemaForMethod
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 getSchemaForType
76.00% covered (warning)
76.00%
19 / 25
0.00% covered (danger)
0.00%
0 / 1
10.12
 addDisplaySchemaFor
90.00% covered (success)
90.00%
27 / 30
0.00% covered (danger)
0.00%
0 / 1
17.29
 addCreationSchemaFor
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
16.08
 addModificationSchemaFor
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 addDescriptionOfObject
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2namespace Apie\SchemaGenerator\Builders;
3
4use Apie\Core\Attributes\Context;
5use Apie\Core\Attributes\Description;
6use Apie\Core\Attributes\ExampleValue;
7use Apie\Core\Exceptions\DuplicateIdentifierException;
8use Apie\Core\ValueObjects\Utils;
9use Apie\SchemaGenerator\Exceptions\ICanNotExtractASchemaFromClassException;
10use Apie\SchemaGenerator\Interfaces\ModifySchemaProvider;
11use Apie\SchemaGenerator\Interfaces\SchemaProvider;
12use Apie\SchemaGenerator\Other\MethodSchemaInfo;
13use cebe\openapi\ReferenceContext;
14use cebe\openapi\spec\Components;
15use cebe\openapi\spec\OpenApi;
16use cebe\openapi\spec\Reference;
17use cebe\openapi\spec\Schema;
18use ReflectionClass;
19use ReflectionIntersectionType;
20use ReflectionMethod;
21use ReflectionNamedType;
22use ReflectionType;
23use ReflectionUnionType;
24
25class ComponentsBuilder
26{
27    /**
28     * @var array<int, SchemaProvider<object>>
29     */
30    private array $schemaProviders;
31
32    private Components $components;
33
34    private ?string $contentType = null;
35
36    /**
37     * @param SchemaProvider<object> $schemaProviders
38     */
39    public function __construct(SchemaProvider... $schemaProviders)
40    {
41        $this->schemaProviders = $schemaProviders;
42        $this->components = new Components([]);
43    }
44
45    /**
46     * @param SchemaProvider<object> $schemaProviders
47     */
48    public static function createWithExistingComponents(Components $components, SchemaProvider... $schemaProviders): self
49    {
50        $res = new self(...$schemaProviders);
51        $res->components = $components;
52        return $res;
53    }
54
55    public function runInContentType(?string $contentType, callable $callback): mixed
56    {
57        $previousContentType = $this->contentType;
58        try {
59            $this->contentType = $contentType;
60            return $callback();
61        } finally {
62            $this->contentType = $previousContentType;
63        }
64    }
65
66    public function setContentType(?string $contentType): self
67    {
68        $this->contentType = $contentType;
69        return $this;
70    }
71
72    public function getContentType(): ?string
73    {
74        return $this->contentType;
75    }
76
77    public function getMixedReference(): Reference
78    {
79        if (!isset($this->components->schemas['mixed'])) {
80            $this->setSchema('mixed', new Schema(['nullable' => true]));
81        }
82        return new Reference(['$ref' => '#/components/schemas/mixed']);
83    }
84
85    public function getSchemaForReference(Reference $reference): ?Schema
86    {
87        $result = $reference->resolve(
88            new ReferenceContext(
89                new OpenApi(['components' => $this->components]),
90                'file:///#/components'
91            )
92        );
93        assert($result === null || $result instanceof Schema);
94        return $result;
95    }
96
97    public function getComponents(): Components
98    {
99        $schemas = $this->components->schemas;
100        ksort($schemas);
101        $this->components->schemas = $schemas;
102        return $this->components;
103    }
104
105    private function checkDuplicate(string $identifier, Schema $original, Schema $newObject): void
106    {
107        throw new DuplicateIdentifierException($identifier, json_encode($original->getSerializableData()), json_encode($newObject->getSerializableData()));
108    }
109
110    public function setSchema(string $identifier, Schema $schema): self
111    {
112        if (isset($this->components->schemas[$identifier])) {
113            $this->checkDuplicate($identifier, $this->components->schemas[$identifier], $schema);
114        }
115        $schemas = $this->components->schemas;
116        $schemas[$identifier] = $schema;
117        
118        $this->components->schemas = $schemas;
119        return $this;
120    }
121
122    public function getSchemaForMethod(ReflectionMethod $method): MethodSchemaInfo
123    {
124        $returnValue = new MethodSchemaInfo();
125        foreach ($method->getParameters() as $parameter) {
126            if (count($parameter->getAttributes(Context::class)) > 0) {
127                continue;
128            }
129            if (!$parameter->isDefaultValueAvailable() && !$parameter->allowsNull()) {
130                $returnValue->required[] = $parameter->name;
131            }
132            $type = $parameter->getType();
133            $returnValue->schemas[$parameter->name] = $this->getSchemaForType($type, $parameter->isVariadic(), nullable: $type?->allowsNull() ?? false);
134        }
135        return $returnValue;
136    }
137
138    public function getSchemaForType(ReflectionType|null $type, bool $array = false, bool $display = false, bool $nullable = false): Schema|Reference
139    {
140        $map = $nullable ? ['nullable' => true] : [];
141        $methodName = $display ? 'addDisplaySchemaFor' : 'addCreationSchemaFor';
142        $result = $this->getMixedReference();
143        if ($type instanceof ReflectionIntersectionType) {
144            $allOfs = [];
145            foreach ($type->getTypes() as $allOfType) {
146                $allOfs[] = $this->$methodName((string) $allOfType, nullable: $allOfType->allowsNull());
147            }
148            $result = new Schema([
149                'allOf' => $allOfs,
150            ] + $map);
151        } elseif ($type instanceof ReflectionUnionType) {
152            $oneOfs = [];
153            foreach ($type->getTypes() as $oneOfType) {
154                $oneOfs[] = $this->$methodName((string) $oneOfType, nullable: $oneOfType->allowsNull());
155            }
156            $result = new Schema([
157                'oneOf' => $oneOfs,
158            ] + $map);
159        } elseif ($type instanceof ReflectionNamedType) {
160            $result = $this->$methodName($type->getName(), nullable: $type->allowsNull());
161        }
162        if ($array) {
163            return new Schema([
164                'type' => 'array',
165                'items' => $result,
166            ] + $map);
167        }
168        return $result;
169    }
170
171    public function addDisplaySchemaFor(string $class, ?string $discriminatorColumn = null, bool $nullable = false): Reference|Schema
172    {
173        $map = $nullable ? ['nullable' => true] : [];
174        switch ($class) {
175            case 'mixed':
176                return $this->getMixedReference();
177            case 'string':
178                return new Schema(['type' => $class] + $map);
179            case 'array':
180                return new Schema(['type' => 'object', 'additionalProperties' => $this->getMixedReference()] + $map);
181            case 'bool':
182                return new Schema(['type' => 'boolean'] + $map);
183            case 'true':
184                return new Schema(['type' => 'boolean', 'enum' => [true]]);
185            case 'false':
186                return new Schema(['type' => 'boolean', 'enum' => [false]]);
187            case 'int':
188                return new Schema(['type' => 'integer'] + $map);
189            case 'float':
190            case 'double':
191                return new Schema(['type' => 'number'] + $map);
192            case 'void':
193            case 'null':
194                return new Schema(['nullable' => true, 'default' => null]);
195        }
196        $refl = new ReflectionClass($class);
197        $identifier = Utils::getDisplayNameForValueObject($refl) . ($nullable ? '-nullable' : '') . '-get';
198        if (isset($this->components->schemas[$identifier])) {
199            return new Reference(['$ref' => '#/components/schemas/' . $identifier]);
200        }
201
202        foreach ($this->schemaProviders as $schemaProvider) {
203            if ($schemaProvider->supports($refl)) {
204                $this->components = $schemaProvider->addDisplaySchemaFor($this, $identifier, $refl, $nullable);
205                return new Reference(['$ref' => '#/components/schemas/' . $identifier]);
206            }
207        }
208        throw new ICanNotExtractASchemaFromClassException($refl->name);
209    }
210
211    public function addCreationSchemaFor(string $class, ?string $discriminatorColumn = null, bool $nullable = false): Reference|Schema
212    {
213        $map = $nullable ? ['nullable' => true] : [];
214        switch ($class) {
215            case 'mixed':
216                return $this->getMixedReference();
217            case 'object':
218                return new Schema(['type' => 'object', 'additionalProperties' => true] + $map);
219            case 'string':
220                return new Schema(['type' => $class] + $map);
221            case 'array':
222                return new Schema(['type' => 'object', 'additionalProperties' => $this->getMixedReference()] + $map);
223            case 'bool':
224                return new Schema(['type' => 'boolean'] + $map);
225            case 'int':
226                return new Schema(['type' => 'integer'] + $map);
227            case 'float':
228            case 'double':
229                return new Schema(['type' => 'number'] + $map);
230            case 'null':
231                return new Schema(['nullable' => true, 'default' => null]);
232        }
233        $refl = new ReflectionClass($class);
234        $identifier = Utils::getDisplayNameForValueObject($refl) . ($nullable ? '-nullable' : '') . '-post';
235        if ($this->contentType) {
236            $identifier .= '-' . str_replace('/', '-', $this->contentType);
237        }
238        if (isset($this->components->schemas[$identifier])) {
239            return new Reference(['$ref' => '#/components/schemas/' . $identifier]);
240        }
241        foreach ($this->schemaProviders as $schemaProvider) {
242            if ($schemaProvider->supports($refl)) {
243                $this->components = $schemaProvider->addCreationSchemaFor($this, $identifier, $refl, $nullable);
244                return new Reference(['$ref' => '#/components/schemas/' . $identifier]);
245            }
246        }
247        throw new ICanNotExtractASchemaFromClassException($refl->name);
248    }
249
250    public function addModificationSchemaFor(string $class, ?string $discriminatorColumn = null): Reference|Schema
251    {
252        $refl = new ReflectionClass($class);
253        $identifier = Utils::getDisplayNameForValueObject($refl) . '-patch';
254        if (isset($this->components->schemas[$identifier])) {
255            return new Reference(['$ref' => '#/components/schemas/' . $identifier]);
256        }
257        foreach ($this->schemaProviders as $schemaProvider) {
258            if ($schemaProvider instanceof ModifySchemaProvider && $schemaProvider->supports($refl)) {
259                $this->components = $schemaProvider->addModificationSchemaFor($this, $identifier, $refl);
260                return new Reference(['$ref' => '#/components/schemas/' . $identifier]);
261            }
262        }
263        throw new ICanNotExtractASchemaFromClassException($refl->name);
264    }
265
266    /**
267     * @param ReflectionClass<object> $class
268     */
269    public static function addDescriptionOfObject(Schema $schema, ReflectionClass $class): void
270    {
271        if (!$schema->description) {
272            foreach ($class->getAttributes(Description::class) as $attribute) {
273                $schema->description = $attribute->newInstance()->description;
274            }
275        }
276        if (!$schema->example) {
277            $examples = [];
278            foreach ($class->getAttributes(ExampleValue::class) as $attribute) {
279                $exampleValue = $attribute->newInstance();
280                $examples[$exampleValue->name] = $exampleValue->example;
281            }
282            if (count($examples) > 0) {
283                $schema->example = reset($examples);
284            }
285        }
286    }
287}