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