16384) throw new \InvalidArgumentException('INPUT_TOO_LARGE'); if (array_diff(array_keys($options), ['group','timezone','collectErrors','skipNull','relationMode'])) throw new \InvalidArgumentException('UNKNOWN_OPTION'); foreach (['collectErrors','skipNull'] as $key) if (isset($options[$key]) && !is_bool($options[$key])) throw new \InvalidArgumentException('INVALID_OPTION'); if (isset($options['group']) && !in_array($options['group'], ['public','admin'], true)) throw new \InvalidArgumentException('INVALID_OPTION'); if (isset($options['timezone']) && !in_array($options['timezone'], ['UTC','Europe/Moscow'], true)) throw new \InvalidArgumentException('INVALID_OPTION'); if (isset($options['relationMode']) && !in_array($options['relationMode'], ['reference','depth'], true)) throw new \InvalidArgumentException('INVALID_OPTION'); $envelope = ['engine'=>'Symfony Serializer PHP','version'=>self::VERSION,'scenario'=>$id,'ok'=>false]; try { $decoded = json_decode($json, false, 16, JSON_THROW_ON_ERROR); if (!$decoded instanceof \stdClass) throw new \InvalidArgumentException('ROOT_MUST_BE_OBJECT'); $nodes = 0; self::bound($decoded, 0, $nodes); // The scheduling example requires an exact calendar timestamp, not // DateTime's permissive rollover or relative-date parser. if ($id === 'dates' && isset($decoded->startsAt) && is_string($decoded->startsAt) && !self::validTimestamp($decoded->startsAt)) { return array_merge($envelope, ['stage'=>'denormalize','errors'=>[['code'=>'INVALID_DATE','path'=>'startsAt','message'=>'Нужна существующая дата в формате YYYY-MM-DDTHH:MM:SS±HH:MM или с Z. Доли секунд и относительные даты в этом примере не принимаются.']]]); } $serializer = self::serializer($id === 'names'); $context = [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES=>false, AbstractNormalizer::REQUIRE_ALL_PROPERTIES=>true]; if ($options['collectErrors'] ?? true) $context[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS] = true; $classes = ['roundtrip'=>Customer::class,'names'=>Contact::class,'groups'=>Profile::class,'dates'=>Meeting::class,'nested'=>Order::class,'presence'=>Preferences::class,'strict'=>Customer::class,'validate'=>Signup::class]; if ($id === 'relations') return array_merge($envelope, self::relations($decoded, $options)); $object = $serializer->deserialize($json, $classes[$id], 'json', $context); $outContext = []; if ($id === 'groups') $outContext[AbstractNormalizer::GROUPS] = [$options['group'] ?? 'public']; if ($id === 'dates') $outContext = [DateTimeNormalizer::FORMAT_KEY=>'Y-m-d\TH:i:sP',DateTimeNormalizer::TIMEZONE_KEY=>$options['timezone'] ?? 'UTC']; if ($id === 'presence') $outContext[AbstractObjectNormalizer::SKIP_NULL_VALUES] = $options['skipNull'] ?? false; $normalized = $serializer->normalize($object, 'json', $outContext); $resultJson = $serializer->serialize($object, 'json', $outContext + ['json_encode_options'=>JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES]); $violations = []; if ($id === 'validate') foreach (Validation::createValidatorBuilder()->enableAttributeMapping()->getValidator()->validate($object) as $violation) $violations[] = ['path'=>$violation->getPropertyPath(),'message'=>$violation->getMessage()]; return array_merge($envelope, ['ok'=>!$violations, 'stage'=>$violations ? 'validation' : 'serialized', 'class'=>(new \ReflectionClass($object))->getShortName(), 'normalized'=>$normalized,'json'=>$resultJson,'violations'=>$violations,'details'=>self::details($id,$object,$decoded)]); } catch (\JsonException|NotEncodableValueException $e) { return array_merge($envelope,['stage'=>'decode','errors'=>[['code'=>'INVALID_JSON','path'=>'','message'=>'Нужен корректный JSON-объект глубиной не более 16.']]]); } catch (PartialDenormalizationException $e) { return array_merge($envelope,['stage'=>'denormalize','errors'=>array_map(self::typeError(...),$e->getErrors())]); } catch (NotNormalizableValueException $e) { return array_merge($envelope,['stage'=>'denormalize','errors'=>[self::typeError($e)]]); } catch (ExtraAttributesException $e) { return array_merge($envelope,['stage'=>'denormalize','errors'=>[['code'=>'EXTRA_FIELDS','path'=>'','message'=>'В DTO нет этих полей.','fields'=>$e->getExtraAttributes()]]]); } catch (MissingConstructorArgumentsException $e) { return array_merge($envelope,['stage'=>'denormalize','errors'=>[['code'=>'MISSING_FIELDS','path'=>'','message'=>'Отсутствуют обязательные параметры конструктора.','fields'=>$e->getMissingConstructorArguments()]]]); } catch (\InvalidArgumentException $e) { return array_merge($envelope,['stage'=>'input','errors'=>[['code'=>$e->getMessage(),'path'=>'','message'=>'Вход не соответствует ограниченному контракту этого примера.']]]); } } private static function typeError(NotNormalizableValueException $e): array { if(str_starts_with($e->getMessage(),'Failed to create object because the class misses the '))return ['code'=>'MISSING_FIELDS','path'=>$e->getPath()??'','message'=>'Обязательный параметр конструктора отсутствует. Явный null и отсутствие поля — разные случаи.']; return ['code'=>'TYPE_MISMATCH','path'=>$e->getPath() ?? '', 'expected'=>$e->getExpectedTypes(),'actual'=>$e->getCurrentType(),'message'=>'Значение не соответствует типу свойства. Исправьте поле; частичный объект не используется.']; } private static function validTimestamp(string $value): bool { if (!preg_match('/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/D', $value)) return false; $date = \DateTimeImmutable::createFromFormat('!Y-m-d\TH:i:sP', $value); $errors = \DateTimeImmutable::getLastErrors(); return $date !== false && ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0)); } private static function bound(mixed $value, int $depth, int &$nodes): void { if (++$nodes > 250 || $depth > 8) throw new \InvalidArgumentException('STRUCTURE_LIMIT'); if (is_string($value) && strlen($value)>2000) throw new \InvalidArgumentException('STRING_LIMIT'); if (is_object($value) || is_array($value)) { $items=(array)$value; if(count($items)>30)throw new \InvalidArgumentException('COLLECTION_LIMIT'); foreach($items as $k=>$v) {if(strlen((string)$k)>100)throw new \InvalidArgumentException('KEY_LIMIT');self::bound($v,$depth+1,$nodes);} } } private static function details(string $id,object $object,object $input): array { if($id==='nested')return ['addressClass'=>get_class($object->address),'itemClasses'=>array_map(get_class(...),$object->items)]; if($id==='presence')return ['inputKeys'=>array_keys((array)$input),'noteIsNull'=>$object->note===null,'active'=>$object->active]; if($id==='dates')return ['timestamp'=>$object->startsAt->getTimestamp(),'originalTimezone'=>$object->startsAt->getTimezone()->getName()]; return ['immutableInput'=>'DTO is created for this request only; nothing is stored.']; } private static function relations(object $input,array $options): array { if(array_diff(array_keys((array)$input),['team','member']))throw new \InvalidArgumentException('EXTRA_FIELDS'); if(!isset($input->team,$input->member)||!is_string($input->team)||!is_string($input->member))throw new \InvalidArgumentException('RELATION_LABELS_REQUIRED'); $serializer=self::serializer(); if(($options['relationMode']??'reference')==='depth') { $root=new TreeNode($input->team);$root->child=new TreeNode($input->member);$root->child->child=new TreeNode('Уровень 3'); $ctx=[AbstractObjectNormalizer::ENABLE_MAX_DEPTH=>true];$object=$root; } else { $team=new Team(1,$input->team);$member=new Member(2,$input->member);$team->members=[$member];$member->team=$team;$object=$team; $ctx=[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER=>static fn(object $o):array=>['refType'=>(new \ReflectionClass($o))->getShortName(),'id'=>$o->id]]; } return ['ok'=>true,'stage'=>'serialized','class'=>(new \ReflectionClass($object))->getShortName(),'normalized'=>$serializer->normalize($object,'json',$ctx),'json'=>$serializer->serialize($object,'json',$ctx+['json_encode_options'=>JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE]),'details'=>['mode'=>$options['relationMode']??'reference']]; } }