├── .github
├── FUNDING.yml
└── workflows
│ └── ci.yml
├── LICENSE
├── composer.json
├── psalm-baseline.xml
├── psalm.xml
└── src
├── JsonMapper.php
├── JsonMapperException.php
├── NameMapper.php
├── NameMapper
├── CamelCaseToSnakeCaseMapper.php
├── NullMapper.php
└── SnakeCaseToCamelCaseMapper.php
├── OnExtraProperties.php
├── OnMissingProperties.php
└── Reflection
├── Reflector.php
├── Type
├── ArrayType.php
├── ClassType.php
├── EnumType.php
├── SimpleType.php
└── UnionType.php
├── TypeParser.php
├── TypeToken.php
└── TypeTokenizer.php
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: BenMorel
2 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push, pull_request]
4 |
5 | env:
6 | PSALM_PHP_VERSION: "8.4"
7 | COVERAGE_PHP_VERSION: "8.4"
8 |
9 | jobs:
10 | psalm:
11 | name: Psalm
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v4
17 |
18 | - name: Setup PHP
19 | uses: shivammathur/setup-php@v2
20 | with:
21 | php-version: ${{ env.PSALM_PHP_VERSION }}
22 |
23 | - name: Install composer dependencies
24 | uses: ramsey/composer-install@v3
25 |
26 | - name: Run Psalm
27 | run: vendor/bin/psalm --show-info=false --find-unused-psalm-suppress --no-progress
28 |
29 | phpunit:
30 | name: PHPUnit
31 | runs-on: ubuntu-latest
32 |
33 | strategy:
34 | fail-fast: false
35 | matrix:
36 | php-version:
37 | - "8.1"
38 | - "8.2"
39 | - "8.3"
40 | - "8.4"
41 | deps:
42 | - "highest"
43 | include:
44 | - php-version: "8.1"
45 | deps: "lowest"
46 |
47 | steps:
48 | - name: Checkout
49 | uses: actions/checkout@v4
50 |
51 | - name: Setup PHP
52 | uses: shivammathur/setup-php@v2
53 | with:
54 | php-version: ${{ matrix.php-version }}
55 | coverage: pcov
56 |
57 | - name: Install composer dependencies
58 | uses: ramsey/composer-install@v3
59 | with:
60 | dependency-versions: ${{ matrix.deps }}
61 |
62 | - name: Run PHPUnit
63 | run: vendor/bin/phpunit
64 | if: ${{ matrix.php-version != env.COVERAGE_PHP_VERSION }}
65 |
66 | - name: Run PHPUnit with coverage
67 | run: |
68 | mkdir -p build/logs
69 | vendor/bin/phpunit --coverage-clover build/logs/clover.xml
70 | if: ${{ matrix.php-version == env.COVERAGE_PHP_VERSION }}
71 |
72 | - name: Upload coverage report to Coveralls
73 | run: vendor/bin/php-coveralls --coverage_clover=build/logs/clover.xml -v
74 | env:
75 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
76 | if: ${{ matrix.php-version == env.COVERAGE_PHP_VERSION }}
77 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2023-present Benjamin Morel
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "brick/json-mapper",
3 | "description": "Maps JSON data to strongly typed PHP DTOs",
4 | "type": "library",
5 | "keywords": [
6 | "Brick",
7 | "JSON",
8 | "Mapper",
9 | "DTO"
10 | ],
11 | "license": "MIT",
12 | "require": {
13 | "php": "^8.1",
14 | "brick/reflection": "~0.5.0"
15 | },
16 | "require-dev": {
17 | "php-coveralls/php-coveralls": "^2.5",
18 | "phpunit/phpunit": "^10.1.0",
19 | "vimeo/psalm": "6.3.0"
20 | },
21 | "autoload": {
22 | "psr-4": {
23 | "Brick\\JsonMapper\\": "src/"
24 | }
25 | },
26 | "autoload-dev": {
27 | "psr-4": {
28 | "Brick\\JsonMapper\\Tests\\": "tests/"
29 | }
30 | },
31 | "config": {
32 | "sort-packages": true
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/psalm-baseline.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | '_' . strtolower($matches[0]),
11 | $name,
12 | )]]>
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | strtoupper($matches[1]),
23 | $name,
24 | )]]>
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/psalm.xml:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/src/JsonMapper.php:
--------------------------------------------------------------------------------
1 | reflector = new Reflector(
70 | $allowUntypedArrays,
71 | $allowUntypedObjects,
72 | $allowMixed,
73 | );
74 | }
75 |
76 | /**
77 | * @template T of object
78 | *
79 | * @param class-string $className
80 | *
81 | * @return T
82 | *
83 | * @throws JsonMapperException
84 | */
85 | public function map(string $json, string $className): object
86 | {
87 | try {
88 | $data = json_decode($json, flags: JSON_THROW_ON_ERROR);
89 | } catch (JsonException $e) {
90 | throw new JsonMapperException('Invalid JSON data: ' . $e->getMessage(), $e);
91 | }
92 |
93 | if (! $data instanceof stdClass) {
94 | throw new JsonMapperException(sprintf('Unexpected JSON data: expected object, got %s.', gettype($data)));
95 | }
96 |
97 | return $this->mapToObject($data, $className);
98 | }
99 |
100 | /**
101 | * @template T of object
102 | *
103 | * @param class-string $className
104 | *
105 | * @return T
106 | *
107 | * @throws JsonMapperException
108 | */
109 | private function mapToObject(stdClass $jsonData, string $className): object
110 | {
111 | try {
112 | $reflectionClass = new ReflectionClass($className);
113 | } catch (ReflectionException $e) {
114 | throw new JsonMapperException('Invalid class name: ' . $className, $e);
115 | }
116 |
117 | $reflectionConstructor = $reflectionClass->getConstructor();
118 |
119 | if ($reflectionConstructor === null) {
120 | throw new JsonMapperException('Class ' . $className . ' must have a constructor.');
121 | }
122 |
123 | $parameters = [];
124 |
125 | $consumedJsonPropertyNames = [];
126 |
127 | foreach ($reflectionConstructor->getParameters() as $reflectionParameter) {
128 | $jsonPropertyName = $this->phpToJsonNameMapper->mapName($reflectionParameter->getName());
129 | $consumedJsonPropertyNames[] = $jsonPropertyName;
130 |
131 | $parameters[$reflectionParameter->getName()] = $this->getParameterValue(
132 | $jsonData,
133 | $jsonPropertyName,
134 | $reflectionParameter,
135 | );
136 | }
137 |
138 | if ($this->onExtraProperties === OnExtraProperties::THROW_EXCEPTION) {
139 | /** @psalm-suppress MixedAssignment, RawObjectIteration */
140 | foreach ($jsonData as $jsonPropertyName => $_) {
141 | /** @var string $jsonPropertyName https://github.com/vimeo/psalm/issues/9108 */
142 | if (! in_array($jsonPropertyName, $consumedJsonPropertyNames, true)) {
143 | throw new JsonMapperException([
144 | sprintf(
145 | 'Unexpected property "%s" in JSON data: ' .
146 | '%s::__construct() does not have a corresponding $%s parameter.',
147 | $jsonPropertyName,
148 | $className,
149 | $this->jsonToPhpNameMapper->mapName($jsonPropertyName),
150 | ),
151 | 'If you want to allow extra properties, change the $onExtraProperties option.',
152 | ]);
153 | }
154 | }
155 | }
156 |
157 | return $reflectionClass->newInstanceArgs($parameters);
158 | }
159 |
160 | /**
161 | * @throws JsonMapperException
162 | */
163 | private function getParameterValue(
164 | stdClass $jsonData,
165 | string $jsonPropertyName,
166 | ReflectionParameter $reflectionParameter,
167 | ): mixed {
168 | $parameterType = $this->reflector->getParameterType($reflectionParameter);
169 |
170 | if (!property_exists($jsonData, $jsonPropertyName)) {
171 | if ($this->onMissingProperties === OnMissingProperties::SET_NULL) {
172 | if ($parameterType->allowsNull) {
173 | return null;
174 | }
175 | }
176 |
177 | if ($this->onMissingProperties === OnMissingProperties::SET_DEFAULT) {
178 | if ($reflectionParameter->isDefaultValueAvailable()) {
179 | // TODO we should technically check if the default value is compatible with the parameter type,
180 | // as the type declared as @param may be more specific than the PHP type.
181 | return $reflectionParameter->getDefaultValue();
182 | }
183 | }
184 |
185 | throw new JsonMapperException([
186 | sprintf('Missing property "%s" in JSON data.', $jsonPropertyName),
187 | match ($this->onMissingProperties) {
188 | OnMissingProperties::SET_NULL => 'The parameter does not allow null.',
189 | OnMissingProperties::SET_DEFAULT => 'The parameter does not have a default value.',
190 | OnMissingProperties::THROW_EXCEPTION => 'If you want to allow missing properties, change the $onMissingProperties option.',
191 | }
192 | ]);
193 | }
194 |
195 | $jsonValue = $jsonData->{$jsonPropertyName};
196 |
197 | return $this->mapValue($jsonValue, $jsonPropertyName, $parameterType);
198 | }
199 |
200 | /**
201 | * @throws JsonMapperException
202 | */
203 | private function mapValue(
204 | mixed $jsonValue,
205 | string $jsonPropertyName,
206 | UnionType $parameterType,
207 | ): mixed {
208 | if ($parameterType->allowsMixed) {
209 | return $jsonValue;
210 | }
211 |
212 | if ($jsonValue instanceof stdClass) {
213 | return $this->getJsonObjectValue($jsonValue, $jsonPropertyName, $parameterType);
214 | }
215 |
216 | if (is_array($jsonValue)) {
217 | if ($parameterType->allowsRawArray) {
218 | return $jsonValue;
219 | }
220 |
221 | if ($parameterType->arrayType === null) {
222 | throw new JsonMapperException('Property ' . $jsonPropertyName . ' is an array, but the parameter does not accept arrays.');
223 | }
224 |
225 | $type = $parameterType->arrayType->type;
226 |
227 | return array_map(
228 | // TODO $jsonPropertyName is wrong here, rework the exception message
229 | fn (mixed $item): mixed => $this->mapValue($item, $jsonPropertyName, $type),
230 | $jsonValue,
231 | );
232 | }
233 |
234 | if (is_string($jsonValue)) {
235 | if ($parameterType->allowsString) {
236 | return $jsonValue;
237 | }
238 |
239 | foreach ($parameterType->enumTypes as $enumType) {
240 | if ($enumType->isStringBacked) {
241 | return ($enumType->name)::from($jsonValue);
242 | }
243 | }
244 |
245 | // TODO "Parameter %s of class %s does not accept string" + JSON path
246 | throw new JsonMapperException('Property ' . $jsonPropertyName . ' cannot be a string.');
247 | }
248 |
249 | if (is_int($jsonValue)) {
250 | if ($parameterType->allowsInt) {
251 | return $jsonValue;
252 | }
253 |
254 | foreach ($parameterType->enumTypes as $enumType) {
255 | if ($enumType->isIntBacked) {
256 | return ($enumType->name)::from($jsonValue);
257 | }
258 | }
259 |
260 | throw new JsonMapperException('Property ' . $jsonPropertyName . ' cannot be a string.');
261 | }
262 |
263 | if (is_float($jsonValue)) {
264 | if ($parameterType->allowsFloat) {
265 | return $jsonValue;
266 | }
267 |
268 | throw new JsonMapperException('Property ' . $jsonPropertyName . ' cannot be a float.');
269 | }
270 |
271 | if ($jsonValue === null) {
272 | if ($parameterType->allowsNull) {
273 | return null;
274 | }
275 |
276 | throw new JsonMapperException('Property ' . $jsonPropertyName . ' cannot be null.');
277 | }
278 |
279 | if ($jsonValue === true) {
280 | if ($parameterType->allowsTrue) {
281 | return true;
282 | }
283 |
284 | throw new JsonMapperException('Property ' . $jsonPropertyName . ' cannot be true.');
285 | }
286 |
287 | if ($jsonValue === false) {
288 | if ($parameterType->allowsFalse) {
289 | return false;
290 | }
291 |
292 | throw new JsonMapperException('Property ' . $jsonPropertyName . ' cannot be false.');
293 | }
294 |
295 | throw new LogicException('Unreachable. If you see this, please report a bug.');
296 | }
297 |
298 | /**
299 | * @throws JsonMapperException
300 | */
301 | private function getJsonObjectValue(
302 | stdClass $jsonValue,
303 | string $jsonPropertyName,
304 | UnionType $parameterType,
305 | ): object {
306 | if ($parameterType->allowsRawObject) {
307 | return $jsonValue;
308 | }
309 |
310 | if (! $parameterType->classTypes) {
311 | throw new JsonMapperException('Property ' . $jsonPropertyName . ' is an object, but the parameter does not accept objects.');
312 | }
313 |
314 | if (count($parameterType->classTypes) === 1) {
315 | return $this->mapToObject($jsonValue, $parameterType->classTypes[0]->name);
316 | }
317 |
318 | $matches = [];
319 | $errors = [];
320 |
321 | foreach ($parameterType->classTypes as $classType) {
322 | try {
323 | $matches[] = $this->mapToObject($jsonValue, $classType->name);
324 | } catch (JsonMapperException $e) {
325 | $errors[] = [$classType->name, $e->getFirstMessage()];
326 | }
327 | }
328 |
329 | if (! $matches) {
330 | throw new JsonMapperException(
331 | "JSON object does not match any of the allowed PHP classes:\n" . implode("\n", array_map(
332 | fn (array $error) => sprintf(' - %s: %s', ...$error),
333 | $errors,
334 | ),
335 | ));
336 | }
337 |
338 | if (count($matches) === 1) {
339 | return $matches[0];
340 | }
341 |
342 | throw new JsonMapperException(sprintf(
343 | 'JSON object matches multiple PHP classes: %s.',
344 | implode(', ', array_map(get_class(...), $matches)),
345 | ));
346 | }
347 | }
348 |
--------------------------------------------------------------------------------
/src/JsonMapperException.php:
--------------------------------------------------------------------------------
1 |
13 | */
14 | private array $messages;
15 |
16 | /**
17 | * @param string|non-empty-list $messages
18 | */
19 | public function __construct(string|array $messages, ?Exception $previous = null)
20 | {
21 | if (is_string($messages)) {
22 | $messages = [$messages];
23 | }
24 |
25 | parent::__construct(implode(' ', $messages), 0, $previous);
26 |
27 | $this->messages = $messages;
28 | }
29 |
30 | public function getFirstMessage(): string
31 | {
32 | return $this->messages[0];
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/NameMapper.php:
--------------------------------------------------------------------------------
1 | '_' . strtolower($matches[0]),
16 | $name,
17 | );
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/NameMapper/NullMapper.php:
--------------------------------------------------------------------------------
1 | strtoupper($matches[1]),
16 | $name,
17 | );
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/OnExtraProperties.php:
--------------------------------------------------------------------------------
1 |
67 | */
68 | private array $parameterTypes = [];
69 |
70 | /**
71 | * @throws JsonMapperException
72 | */
73 | public function getParameterType(ReflectionParameter $parameter): UnionType
74 | {
75 | $cacheKey = $this->getCacheKey($parameter);
76 |
77 | if ($cacheKey !== null && isset($this->parameterTypes[$cacheKey])) {
78 | return $this->parameterTypes[$cacheKey];
79 | }
80 |
81 | $parameterType = $this->doGetParameterType($parameter);
82 |
83 | if ($cacheKey !== null) {
84 | $this->parameterTypes[$cacheKey] = $parameterType;
85 | }
86 |
87 | return $parameterType;
88 | }
89 |
90 | private function getCacheKey(ReflectionParameter $parameter): ?string
91 | {
92 | $function = $parameter->getDeclaringFunction();
93 |
94 | if ($function->isClosure()) {
95 | return null;
96 | }
97 |
98 | $class = $parameter->getDeclaringClass();
99 |
100 | $result = [];
101 |
102 | if ($class !== null) {
103 | $result[] = $class->getName();
104 | }
105 |
106 | $result[] = $function->getName();
107 | $result[] = $parameter->getName();
108 |
109 | return implode(':', $result);
110 | }
111 |
112 | /**
113 | * @throws JsonMapperException
114 | */
115 | private function doGetParameterType(ReflectionParameter $parameter): UnionType
116 | {
117 | $parameterType = $this->getParameterTypeFromDocComment($parameter);
118 |
119 | if ($parameterType !== null) {
120 | return $parameterType;
121 | }
122 |
123 | $parameterType = $parameter->getType();
124 |
125 | if ($parameterType === null) {
126 | throw new JsonMapperException([
127 | sprintf(
128 | 'Parameter %s must have a type or be documented with @param.',
129 | $this->getParameterNameWithDeclaringFunctionName($parameter),
130 | ),
131 | 'You can explicitly accept any type by typing the parameter as mixed.',
132 | ]);
133 | }
134 |
135 | return $this->getParameterTypeFromReflection($parameterType, $parameter);
136 | }
137 |
138 | /**
139 | * @throws JsonMapperException
140 | */
141 | private function getParameterTypeFromDocComment(ReflectionParameter $parameter): ?UnionType
142 | {
143 | $docComment = $parameter->getDeclaringFunction()->getDocComment();
144 |
145 | if ($docComment === false) {
146 | return null;
147 | }
148 |
149 | $pattern = '/@param(.*)\$' . $parameter->getName() . '\W/';
150 |
151 | /** @var list $matches */
152 | preg_match_all($pattern, $docComment, $matches, PREG_SET_ORDER);
153 |
154 | if (count($matches) === 0) {
155 | return null;
156 | }
157 |
158 | if (count($matches) !== 1) {
159 | throw new JsonMapperException(sprintf(
160 | 'Parameter %s has multiple @param types.',
161 | $this->getParameterNameWithDeclaringFunctionName($parameter),
162 | ));
163 | }
164 |
165 | $type = $matches[0][1];
166 |
167 | if ($type === '' || ! $this->isWhitespace($type[0]) || ! $this->isWhitespace($type[-1])) {
168 | throw new JsonMapperException(sprintf(
169 | 'Parameter %s has a badly-formatted @param type.',
170 | $this->getParameterNameWithDeclaringFunctionName($parameter),
171 | ));
172 | }
173 |
174 | $type = preg_replace('/(^\s+)|(\s+$)/', '', $type);
175 |
176 | if ($type === '') {
177 | throw new JsonMapperException(sprintf(
178 | 'Parameter %s has an empty @param type.',
179 | $this->getParameterNameWithDeclaringFunctionName($parameter),
180 | ));
181 | }
182 |
183 | $typeParser = new TypeParser($type);
184 |
185 | try {
186 | $parsedType = $typeParser->parse();
187 | } catch (JsonMapperException $e) {
188 | throw new JsonMapperException(sprintf(
189 | 'Parameter %s has an invalid @param type: %s',
190 | $this->getParameterNameWithDeclaringFunctionName($parameter),
191 | $e->getMessage(),
192 | ), previous: $e);
193 | }
194 |
195 | return $this->createUnionType(
196 | array_map(
197 | fn (string|array $value) => $this->convertDocCommentType($value, $parameter),
198 | $parsedType,
199 | ),
200 | $parameter,
201 | );
202 | }
203 |
204 | private function isWhitespace(string $char): bool
205 | {
206 | return preg_match('/\s/', $char) === 1;
207 | }
208 |
209 | /**
210 | * @throws JsonMapperException
211 | */
212 | private function convertDocCommentType(string|array $type, ReflectionParameter $reflectionParameter): SimpleType|ClassType|EnumType|ArrayType
213 | {
214 | if (is_string($type)) {
215 | return $this->convertNamedType($type, false, $reflectionParameter);
216 | }
217 |
218 | return new ArrayType(
219 | $this->createUnionType(
220 | array_map(
221 | fn (string|array $type) => $this->convertDocCommentType($type, $reflectionParameter),
222 | $type,
223 | ),
224 | $reflectionParameter,
225 | ),
226 | );
227 | }
228 |
229 | /**
230 | * @throws JsonMapperException
231 | */
232 | private function getParameterTypeFromReflection(ReflectionType $type, ReflectionParameter $reflectionParameter): UnionType
233 | {
234 | if ($type instanceof ReflectionIntersectionType) {
235 | $this->throwOnIntersectionType($reflectionParameter);
236 | }
237 |
238 | if ($type instanceof ReflectionUnionType) {
239 | return $this->createUnionType(
240 | array_map(
241 | function (ReflectionNamedType|ReflectionIntersectionType $type) use ($reflectionParameter): SimpleType|ClassType|EnumType|ArrayType {
242 | /** @psalm-suppress DocblockTypeContradiction https://github.com/vimeo/psalm/issues/9079 */
243 | if ($type instanceof ReflectionIntersectionType) {
244 | $this->throwOnIntersectionType($reflectionParameter);
245 | }
246 |
247 | return $this->convertNamedType($type->getName(), true, $reflectionParameter);
248 | },
249 | $type->getTypes(),
250 | ),
251 | $reflectionParameter,
252 | );
253 | }
254 |
255 | if ($type instanceof ReflectionNamedType) {
256 | $result = [
257 | $this->convertNamedType($type->getName(), true, $reflectionParameter),
258 | ];
259 |
260 | if ($type->allowsNull() && $type->getName() !== 'mixed') {
261 | $result[] = new SimpleType('null');
262 | }
263 |
264 | return $this->createUnionType($result, $reflectionParameter);
265 | }
266 |
267 | // @codeCoverageIgnoreStart
268 | throw new LogicException(sprintf(
269 | 'Unknown reflection type: %s',
270 | $type::class,
271 | ));
272 | // @codeCoverageIgnoreEnd
273 | }
274 |
275 | /**
276 | * @throws JsonMapperException
277 | */
278 | private function convertNamedType(string $namedType, bool $isReflection, ReflectionParameter $reflectionParameter): SimpleType|ClassType|EnumType
279 | {
280 | $namedTypeLower = strtolower($namedType);
281 |
282 | $isAllowedBuiltinType = in_array($namedTypeLower, self::ALLOWED_BUILTIN_TYPES, true);
283 | $isDisallowedBuiltinType = in_array($namedTypeLower, self::DISALLOWED_BUILTIN_TYPES, true);
284 | $isBuiltinType = $isAllowedBuiltinType || $isDisallowedBuiltinType;
285 |
286 | if (! $isBuiltinType && ! $isReflection) {
287 | // Class names coming from reflection are already fully qualified, while class names coming from @param
288 | // must be resolved according to the current namespace & use statements,
289 | $importResolver = new ImportResolver($reflectionParameter);
290 |
291 | $namedType = $importResolver->resolve($namedType);
292 | $namedTypeLower = strtolower($namedType);
293 | }
294 |
295 | if ($namedTypeLower === 'array' && ! $this->allowUntypedArrays) {
296 | throw new JsonMapperException([
297 | sprintf(
298 | 'Parameter %s contains type "array" which is not allowed by default.',
299 | $this->getParameterNameWithDeclaringFunctionName($reflectionParameter),
300 | ),
301 | 'Please document the type of the array in @param, for example "string[]".',
302 | 'Alternatively, if you want to allow untyped arrays, and receive the raw JSON array, set $allowUntypedArrays to true.',
303 | ]);
304 | }
305 |
306 | if (($namedTypeLower === 'stdclass' || $namedTypeLower === 'object') && ! $this->allowUntypedObjects) {
307 | throw new JsonMapperException([
308 | sprintf(
309 | 'Parameter %s contains type "%s" which is not allowed by default.',
310 | $this->getParameterNameWithDeclaringFunctionName($reflectionParameter),
311 | $namedTypeLower === 'stdclass' ? stdClass::class : 'object',
312 | ),
313 | 'It is advised to map a JSON object to a PHP class.',
314 | 'If you want to allow this, and receive the raw stdClass object, set $allowUntypedObjects to true.',
315 | ]);
316 | }
317 |
318 | if ($namedTypeLower === 'mixed' && ! $this->allowMixed) {
319 | throw new JsonMapperException([
320 | sprintf(
321 | 'Parameter %s contains type "mixed" which is not allowed by default.',
322 | $this->getParameterNameWithDeclaringFunctionName($reflectionParameter),
323 | ),
324 | 'If you want to allow this, and receive the raw JSON value, set $allowMixed to true.'
325 | ]);
326 | }
327 |
328 | if ($namedTypeLower === 'stdclass') {
329 | return new SimpleType('object');
330 | }
331 |
332 | if ($isDisallowedBuiltinType) {
333 | throw new JsonMapperException(sprintf(
334 | 'Parameter %s contains type "%s" which is not allowed.',
335 | $this->getParameterNameWithDeclaringFunctionName($reflectionParameter),
336 | $namedTypeLower,
337 | ));
338 | }
339 |
340 | if ($isAllowedBuiltinType) {
341 | return new SimpleType($namedTypeLower);
342 | }
343 |
344 | if (is_a($namedType, UnitEnum::class, true)) {
345 | $reflectionEnum = new ReflectionEnum($namedType);
346 |
347 | /** @var ReflectionNamedType|null $backingType */
348 | $backingType = $reflectionEnum->getBackingType();
349 |
350 | if ($backingType === null) {
351 | throw new JsonMapperException('Non-backed enums are not supported.');
352 | }
353 |
354 | $backingType = $backingType->getName();
355 |
356 | /** @var class-string $namedType */
357 |
358 | return new EnumType(
359 | $namedType,
360 | isIntBacked: $backingType === 'int',
361 | isStringBacked: $backingType === 'string',
362 | );
363 | }
364 |
365 | /** @psalm-var class-string $namedType */
366 | return new Type\ClassType($namedType);
367 | }
368 |
369 | /**
370 | * @throws JsonMapperException
371 | */
372 | private function throwOnIntersectionType(ReflectionParameter $parameter): never
373 | {
374 | throw new JsonMapperException(sprintf(
375 | 'Parameter %s cannot have an intersection type.',
376 | $this->getParameterNameWithDeclaringFunctionName($parameter),
377 | ));
378 | }
379 |
380 | /**
381 | * @param (SimpleType|ClassType|EnumType|ArrayType)[] $types
382 | *
383 | * @throws JsonMapperException
384 | */
385 | private function createUnionType(array $types, ReflectionParameter $parameter): UnionType
386 | {
387 | try {
388 | return new UnionType($types);
389 | } catch (JsonMapperException $e) {
390 | throw new JsonMapperException(sprintf(
391 | 'Parameter %s contains an invalid type: %s',
392 | $this->getParameterNameWithDeclaringFunctionName($parameter),
393 | $e->getMessage(),
394 | ), previous: $e);
395 | }
396 | }
397 |
398 | private function getParameterNameWithDeclaringFunctionName(ReflectionParameter $parameter): string
399 | {
400 | return sprintf(
401 | '$%s of %s',
402 | $parameter->getName(),
403 | $this->getDeclaringFunctionName($parameter),
404 | );
405 | }
406 |
407 | private function getDeclaringFunctionName(ReflectionParameter $parameter): string
408 | {
409 | $function = $parameter->getDeclaringFunction();
410 | $declaringClass = $parameter->getDeclaringClass();
411 |
412 | if ($function->isClosure() || $declaringClass === null) {
413 | return $function->getName();
414 | }
415 |
416 | return sprintf(
417 | '%s::%s()',
418 | $declaringClass->getName(),
419 | $function->getName(),
420 | );
421 | }
422 | }
423 |
--------------------------------------------------------------------------------
/src/Reflection/Type/ArrayType.php:
--------------------------------------------------------------------------------
1 | type->types) === 1) {
22 | return $this->type . '[]';
23 | }
24 |
25 | return '(' . $this->type . ')[]';
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/Reflection/Type/ClassType.php:
--------------------------------------------------------------------------------
1 | name;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Reflection/Type/EnumType.php:
--------------------------------------------------------------------------------
1 | $name
18 | */
19 | public function __construct(
20 | public readonly string $name,
21 | public readonly bool $isIntBacked = false,
22 | public readonly bool $isStringBacked = false,
23 | ) {
24 | if (! ($this->isIntBacked xor $this->isStringBacked)) {
25 | throw new InvalidArgumentException('EnumType must be either int or string backed.');
26 | }
27 | }
28 |
29 | public function __toString(): string
30 | {
31 | return $this->name;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/Reflection/Type/SimpleType.php:
--------------------------------------------------------------------------------
1 | name;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/Reflection/Type/UnionType.php:
--------------------------------------------------------------------------------
1 | ensureNotEmpty();
54 | $this->ensureNoDuplicateTypes();
55 |
56 | $simpleTypes = array_map(
57 | fn (SimpleType $type): string => $type->name,
58 | $this->filterTypes(SimpleType::class),
59 | );
60 |
61 | $containsSimpleType = static fn (string $type): bool => in_array($type, $simpleTypes, true);
62 |
63 | $hasInt = $containsSimpleType('int');
64 | $hasFloat = $containsSimpleType('float');
65 | $hasString = $containsSimpleType('string');
66 | $hasBool = $containsSimpleType('bool');
67 | $hasTrue = $containsSimpleType('true');
68 | $hasFalse = $containsSimpleType('false');
69 | $hasNull = $containsSimpleType('null');
70 | $hasArray = $containsSimpleType('array');
71 | $hasObject = $containsSimpleType('object');
72 | $hasMixed = $containsSimpleType('mixed');
73 |
74 | // We accept mapping int to float for two reasons:
75 | // - JSON does not have separate int & float types
76 | // - PHP accepts int to float implicit conversion, even with strict types enabled
77 | $this->allowsInt = $hasInt || $hasFloat || $hasMixed;
78 | $this->allowsFloat = $hasFloat || $hasMixed;
79 | $this->allowsString = $hasString || $hasMixed;
80 | $this->allowsTrue = $hasTrue || $hasBool || $hasMixed;
81 | $this->allowsFalse = $hasFalse || $hasBool || $hasMixed;
82 | $this->allowsNull = $hasNull || $hasMixed;
83 | $this->allowsRawArray = $hasArray || $hasMixed;
84 | $this->allowsRawObject = $hasObject || $hasMixed;
85 | $this->allowsMixed = $hasMixed;
86 |
87 | $this->classTypes = $this->filterTypes(ClassType::class);
88 | $this->enumTypes = $this->filterTypes(EnumType::class);
89 |
90 | $hasIntBackedEnum = false;
91 | $hasStringBackedEnum = false;
92 |
93 | foreach ($this->enumTypes as $enumType) {
94 | if ($enumType->isIntBacked) {
95 | if ($hasInt) {
96 | throw new JsonMapperException('Cannot use int-backed enum together with "int" in a union.');
97 | }
98 | if ($hasIntBackedEnum) {
99 | throw new JsonMapperException('At most one int-backed enum is allowed in a union.');
100 | }
101 | $hasIntBackedEnum = true;
102 | }
103 |
104 | if ($enumType->isStringBacked) {
105 | if ($hasString) {
106 | throw new JsonMapperException('Cannot use string-backed enum together with "string" in a union.');
107 | }
108 | if ($hasStringBackedEnum) {
109 | throw new JsonMapperException('At most one string-backed enum is allowed in a union.');
110 | }
111 | $hasStringBackedEnum = true;
112 | }
113 | }
114 |
115 | $arrayTypes = $this->filterTypes(ArrayType::class);
116 |
117 | $this->arrayType = match (count($arrayTypes)) {
118 | 0 => null,
119 | 1 => $arrayTypes[0],
120 | default => throw new JsonMapperException('At most one typed array "[]" is allowed in a union.'),
121 | };
122 |
123 | if ($hasMixed && count($types) > 1) {
124 | throw new JsonMapperException('Cannot use "mixed" together with other types in a union.');
125 | }
126 |
127 | if ($hasBool) {
128 | if ($hasTrue) {
129 | throw new JsonMapperException('Type "true" is redundant with "bool".');
130 | }
131 | if ($hasFalse) {
132 | throw new JsonMapperException('Type "false" is redundant with "bool".');
133 | }
134 | }
135 |
136 | if ($hasTrue && $hasFalse) {
137 | throw new JsonMapperException('Type contains both "true" and "false", "bool" should be used instead.');
138 | }
139 |
140 | if ($hasArray && $this->arrayType !== null) {
141 | throw new JsonMapperException('Cannot use untyped "array" together with a typed array "[]" in a union.');
142 | }
143 |
144 | if ($hasObject && $this->classTypes) {
145 | throw new JsonMapperException('Cannot use untyped "object" or "stdClass" together with a typed class in a union.');
146 | }
147 | }
148 |
149 | private function ensureNotEmpty(): void
150 | {
151 | if (! $this->types) {
152 | throw new JsonMapperException('Union type cannot be empty.');
153 | }
154 | }
155 |
156 | private function ensureNoDuplicateTypes(): void
157 | {
158 | $typeStrings = array_map(
159 | fn (Stringable $type) => (string) $type,
160 | $this->types,
161 | );
162 |
163 | if (count($typeStrings) !== count(array_unique($typeStrings))) {
164 | foreach (array_count_values($typeStrings) as $type => $count) {
165 | if ($count !== 1) {
166 | throw new JsonMapperException(sprintf('Duplicate type "%s" is redundant.', $type));
167 | }
168 | }
169 | }
170 | }
171 |
172 | /**
173 | * @template T of object
174 | *
175 | * @param class-string $className
176 | *
177 | * @return list
178 | */
179 | private function filterTypes(string $className): array
180 | {
181 | return array_values(array_filter(
182 | $this->types,
183 | fn (SimpleType|ClassType|EnumType|ArrayType $type) => $type instanceof $className,
184 | ));
185 | }
186 |
187 | public function __toString(): string
188 | {
189 | return implode('|', array_map(
190 | fn (Stringable $value) => (string) $value,
191 | $this->types,
192 | ));
193 | }
194 | }
195 |
--------------------------------------------------------------------------------
/src/Reflection/TypeParser.php:
--------------------------------------------------------------------------------
1 | ['string']
15 | * - 'string|null' => ['string', 'null']
16 | * - 'int[]|null' => [['int'], 'null']
17 | * - (string|int)[] => [['string', 'int']]
18 | * - (string|int)[]|null => [['string', 'int'], 'null']
19 | *
20 | * Disallowed:
21 | *
22 | * - nullable types: '?string'
23 | * - generics style: 'array'
24 | * - intersection types: 'A&B'
25 | *
26 | * @internal This class is not part of the public API, and may change without notice.
27 | */
28 | final class TypeParser
29 | {
30 | /**
31 | * @var TypeToken[]
32 | *
33 | * @psalm-var list
34 | */
35 | private array $tokens = [];
36 |
37 | private int $pointer = 0;
38 |
39 | public function __construct(
40 | private string $type,
41 | ) {
42 | }
43 |
44 | /**
45 | * @throws JsonMapperException
46 | */
47 | public function parse(): array
48 | {
49 | $this->tokens = TypeTokenizer::tokenize($this->type);
50 | $this->pointer = 0;
51 |
52 | $result = $this->parseUnion(false);
53 |
54 | $nextToken = $this->nextToken();
55 |
56 | if ($nextToken !== null) {
57 | $this->failExpectation(['end of string', '|', '[]'], $nextToken);
58 | }
59 |
60 | return $result;
61 | }
62 |
63 | /**
64 | * @throws JsonMapperException
65 | */
66 | private function parseUnion(bool $nested): array
67 | {
68 | $values = [];
69 |
70 | for (;;) {
71 | $value = $this->parseValue();
72 |
73 | if ($this->isNextToken('[]')) {
74 | if (is_array($value)) {
75 | $this->advance();
76 | }
77 |
78 | while ($this->isNextToken('[]')) {
79 | $this->advance();
80 | $value = [$value];
81 | }
82 |
83 | $values[] = $value;
84 | } elseif (is_array($value)) {
85 | // unnecessary parentheses, merge into current values
86 | $values = array_merge($values, $value);
87 | } else {
88 | $values[] = $value;
89 | }
90 |
91 | $peekToken = $this->peekToken();
92 |
93 | if ($peekToken === null || $peekToken->value === ')') {
94 | break;
95 | }
96 |
97 | if ($peekToken->value === '|') {
98 | $this->advance();
99 | continue;
100 | }
101 |
102 | $this->failExpectation(
103 | array_merge(['|', '[]'], $nested ? [')'] : []),
104 | $peekToken,
105 | );
106 | }
107 |
108 | return $values;
109 | }
110 |
111 | /**
112 | * @throws JsonMapperException
113 | */
114 | private function parseValue(): string|array
115 | {
116 | $nextToken = $this->nextToken();
117 |
118 | if ($nextToken !== null) {
119 | if ($nextToken->isNamedType) {
120 | return $nextToken->value;
121 | }
122 |
123 | if ($nextToken->value === '(') {
124 | $value = $this->parseUnion(true);
125 |
126 | $nextToken = $this->nextToken();
127 |
128 | if ($nextToken === null || $nextToken->value !== ')') {
129 | $this->failExpectation([')', '|', '[]'], $nextToken);
130 | }
131 |
132 | return $value;
133 | }
134 | }
135 |
136 | $this->failExpectation(['named type', '('], $nextToken);
137 | }
138 |
139 | /**
140 | * Retrieves the next token, and advances the pointer.
141 | */
142 | private function nextToken(): ?TypeToken
143 | {
144 | if ($this->pointer === count($this->tokens)) {
145 | return null;
146 | }
147 |
148 | return $this->tokens[$this->pointer++];
149 | }
150 |
151 | /**
152 | * Peeks at the next token, without advancing the pointer.
153 | */
154 | private function peekToken(): ?TypeToken
155 | {
156 | if ($this->pointer === count($this->tokens)) {
157 | return null;
158 | }
159 |
160 | return $this->tokens[$this->pointer];
161 | }
162 |
163 | /**
164 | * Advances the pointer.
165 | */
166 | private function advance(): void
167 | {
168 | $this->pointer++;
169 | }
170 |
171 | /**
172 | * Checks if the next token, if any, matches the given value.
173 | * Does not advance the pointer.
174 | */
175 | private function isNextToken(string $value): bool
176 | {
177 | $nextToken = $this->peekToken();
178 |
179 | return $nextToken !== null && $nextToken->value === $value;
180 | }
181 |
182 | /**
183 | * @param string[] $expected
184 | *
185 | * @throws JsonMapperException
186 | */
187 | private function failExpectation(array $expected, ?TypeToken $actual): never
188 | {
189 | $expected = implode(' or ' , array_map(
190 | fn (string $value) => in_array($value, ['(', ')', '|', '[]'], true) ? "\"$value\"" : $value,
191 | $expected,
192 | ));
193 |
194 | $actual = ($actual === null)
195 | ? 'end of string'
196 | : sprintf('"%s" at offset %d', $actual->value, $actual->offset);
197 |
198 | throw new JsonMapperException(sprintf('Expected %s, found %s.', $expected, $actual));
199 | }
200 | }
201 |
--------------------------------------------------------------------------------
/src/Reflection/TypeToken.php:
--------------------------------------------------------------------------------
1 | [' . ('A-Z' . 'a-z' . '_' . '\\\\') . ']+)'
27 | . '|'
28 | . '(?[' . ('\|' . '\(' . '\)') . ']' . '|' . '\[\]' . ')'
29 | . '|'
30 | . '(?\s+)'
31 | . '|'
32 | . '(?[^' . ('A-Z' . 'a-z' . '_' . '\\\\' . '\|' . '\(' . '\)' . '\s') . ']+' . '|' . '(?
39 | *
40 | * @throws JsonMapperException
41 | */
42 | public static function tokenize(string $type): array
43 | {
44 | /** @var list> $matches */
45 | preg_match_all(self::PATTERN, $type, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
46 |
47 | $tokens = [];
48 |
49 | foreach ($matches as $match) {
50 | foreach ($match as $group => [$value, $offset]) {
51 | if (is_int($group)) {
52 | continue;
53 | }
54 |
55 | if ($value === '') {
56 | continue;
57 | }
58 |
59 | if ($group === 'whitespace') {
60 | continue 2;
61 | }
62 |
63 | if ($group === 'other') {
64 | throw new JsonMapperException(match ($value) {
65 | '[' => sprintf('Char "[" is not followed by "]" at offset %d.', $offset),
66 | ']' => sprintf('Char "]" is not preceded by "[" at offset %d.', $offset),
67 | default => sprintf('Unexpected "%s" at offset %d.', $value, $offset),
68 | });
69 | }
70 |
71 | $tokens[] = new TypeToken($value, $offset, match ($group) {
72 | 'namedType' => true,
73 | 'symbol' => false,
74 | });
75 | }
76 | }
77 |
78 | return $tokens;
79 | }
80 | }
81 |
--------------------------------------------------------------------------------