├── .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 | --------------------------------------------------------------------------------