true]+$payload, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_THROW_ON_ERROR); exit; } function fail(int $status, string $code, string $message, array $fields=[]): never { reply($status,['error'=>['code'=>$code,'message'=>$message,'fields'=>(object)$fields]]); } function query(string $key, string $default=''): string { $value=$_GET[$key]??$default; if (!is_string($value)||strlen($value)>120) fail(422,'INVALID_QUERY','Проверьте параметры запроса',[$key=>'Нужна короткая строка']); return $value; } function integerQuery(string $key, int $default, int $min, int $max): int { $value=query($key,(string)$default); if (!preg_match('/^(0|[1-9][0-9]*)$/D',$value)||(float)$value<$min||(float)$value>$max) fail(422,'INVALID_QUERY','Проверьте параметры запроса',[$key=>"Целое число от $min до $max"]); return (int)$value; } function rawBody(int $limit=12288): string { if ((int)($_SERVER['CONTENT_LENGTH']??0)>$limit) fail(413,'BODY_TOO_LARGE','Превышен лимит тела запроса'); $raw=file_get_contents('php://input',false,null,0,$limit+1); if ($raw===false||strlen($raw)>$limit) fail(413,'BODY_TOO_LARGE','Превышен лимит тела запроса'); return $raw; } function jsonBody(): array { if (strtolower(trim(explode(';',$_SERVER['CONTENT_TYPE']??'')[0]))!=='application/json') fail(415,'UNSUPPORTED_MEDIA','Нужен Content-Type: application/json'); $raw=rawBody(); try { $value=json_decode($raw,true,16,JSON_THROW_ON_ERROR); } catch (JsonException) { fail(400,'INVALID_JSON','Не удалось прочитать JSON'); } // Decode object shape separately: {} is a valid JSON object, [] is not. if (!is_array($value)||!str_starts_with(ltrim($raw),'{')) fail(400,'OBJECT_REQUIRED','Ожидается JSON-объект'); return $value; } function tickets(): array { $kinds=['Монтаж освещения','Проверка щита','Сервис оборудования','Обновление сети','Диагностика линии','Подключение датчиков']; $states=['new','in_progress','done']; $out=[]; for($i=1;$i<=18;$i++) $out[]=['id'=>$i,'title'=>$kinds[($i-1)%6].' · объект '.$i,'status'=>$states[($i-1)%3],'amountMinor'=>750000+$i*12500,'currency'=>'RUB','version'=>3]; return $out; } $route=AEN_SCALAR_ROUTE; $routes=['catalog'=>['GET',['status','page','limit']], 'detail'=>['GET',['id']], 'quote'=>['POST',[]], 'account'=>['GET',['action']], 'revision'=>['PATCH',[]], 'upload'=>['POST',[]], 'limited'=>['GET',[]], 'legacy'=>['GET',[]]]; if (!isset($routes[$route])) fail(404,'NOT_FOUND','Маршрут не найден'); [$allowed,$params]=$routes[$route]; if (($_SERVER['REQUEST_METHOD']??'')!==$allowed) { header('Allow: '.$allowed); fail(405,'METHOD_NOT_ALLOWED','Для этого маршрута нужен '.$allowed); } foreach(array_keys($_GET) as $key) if(!in_array($key,$params,true)) fail(422,'UNKNOWN_QUERY','Неизвестный параметр',[(string)$key=>'Этот параметр не поддерживается']); if (isset($_SERVER['HTTP_ORIGIN'])) { $origin=parse_url($_SERVER['HTTP_ORIGIN']); $originHost=strtolower(($origin['host']??'').(isset($origin['port'])?':'.$origin['port']:'')); // Only server-owned scheme metadata is used; never trust forwarded headers from a client. $https=$_SERVER['HTTPS']??''; $scheme=(($https!==''&&strtolower((string)$https)!=='off')||($_SERVER['REQUEST_SCHEME']??'')==='https')?'https':'http'; if (($origin['scheme']??'')!==$scheme||$originHost!==strtolower($_SERVER['HTTP_HOST']??'')) fail(403,'ORIGIN_DENIED','Для браузера разрешён только тот же origin'); } switch($route) { case 'catalog': $state=query('status','all'); if (!in_array($state,['all','new','in_progress','done'],true)) fail(422,'INVALID_QUERY','Неизвестный статус',['status'=>'all, new, in_progress или done']); $page=integerQuery('page',1,1,100);$limit=integerQuery('limit',4,1,6); $rows=array_values(array_filter(tickets(),fn($x)=>$state==='all'||$x['status']===$state)); reply(200,['data'=>array_slice($rows,($page-1)*$limit,$limit),'meta'=>['page'=>$page,'limit'=>$limit,'total'=>count($rows),'pages'=>(int)ceil(count($rows)/$limit)]]); case 'detail': $id=integerQuery('id',1,1,999); foreach(tickets() as $ticket) if($ticket['id']===$id) reply(200,['data'=>$ticket]); fail(404,'TICKET_NOT_FOUND','Учебная заявка не найдена'); case 'quote': $v=jsonBody();$errors=[]; foreach(array_keys($v) as $k) if(!in_array($k,['customer','items'],true)) $errors[$k]='Неизвестное поле'; $c=$v['customer']??null; if(!is_array($c)||array_is_list($c)) $errors['customer']='Нужен объект клиента'; else { foreach(array_keys($c) as $k) if(!in_array($k,['name','email'],true))$errors['customer.'.$k]='Неизвестное поле'; if(!is_string($c['name']??null)||!preg_match('/^.{2,60}$/us',$c['name']))$errors['customer.name']='От 2 до 60 символов'; if(!is_string($c['email']??null)||strlen($c['email'])>120||!filter_var($c['email'],FILTER_VALIDATE_EMAIL))$errors['customer.email']='Нужен корректный email'; } $items=$v['items']??null;$total=0; if(!is_array($items)||!array_is_list($items)||count($items)<1||count($items)>5)$errors['items']='От 1 до 5 позиций'; else foreach($items as $i=>$item){ if(!is_array($item)||array_is_list($item)){$errors["items.$i"]='Нужен объект';continue;} foreach(array_keys($item) as $k)if(!in_array($k,['title','quantity','priceMinor'],true))$errors["items.$i.$k"]='Неизвестное поле'; if(!is_string($item['title']??null)||!preg_match('/^.{1,70}$/us',$item['title']))$errors["items.$i.title"]='От 1 до 70 символов'; if(!is_int($item['quantity']??null)||$item['quantity']<1||$item['quantity']>20)$errors["items.$i.quantity"]='Целое число от 1 до 20'; if(!is_int($item['priceMinor']??null)||$item['priceMinor']<0||$item['priceMinor']>1000000)$errors["items.$i.priceMinor"]='От 0 до 1000000 копеек'; if(is_int($item['quantity']??null)&&is_int($item['priceMinor']??null)&&$item['quantity']>=1&&$item['quantity']<=20&&$item['priceMinor']>=0&&$item['priceMinor']<=1000000)$total+=$item['quantity']*$item['priceMinor']; } if($errors)fail(422,'VALIDATION_FAILED','Исправьте отмеченные поля',$errors); reply(201,['data'=>['id'=>'DEMO-Q1001','totalMinor'=>$total,'currency'=>'RUB','items'=>count($items),'persisted'=>false],'message'=>'Предпросмотр расчёта готов. Заявка не сохранялась.']); case 'account': $token=$_SERVER['HTTP_AUTHORIZATION']??$_SERVER['REDIRECT_HTTP_AUTHORIZATION']??''; if(!in_array($token,['Bearer demo-reader','Bearer demo-editor'],true)){header('WWW-Authenticate: Bearer realm="public-demo"');fail(401,'AUTH_REQUIRED','Используйте только публичный учебный токен');} $action=query('action','read');if(!in_array($action,['read','edit'],true))fail(422,'INVALID_QUERY','Действие должно быть read или edit'); $role=$token==='Bearer demo-editor'?'editor':'reader'; if($action==='edit'&&$role!=='editor')fail(403,'FORBIDDEN','Учебной роли reader недоступно редактирование'); reply(200,['data'=>['name'=>'Учебный пользователь','role'=>$role,'action'=>$action],'message'=>'Публичный токен показывает контракт. Реальной защиты аккаунтов здесь нет.']); case 'revision': $v=jsonBody();$errors=[]; foreach(array_keys($v)as$k)if(!in_array($k,['expectedVersion','title'],true))$errors[$k]='Неизвестное поле'; if(!is_int($v['expectedVersion']??null)||$v['expectedVersion']<1)$errors['expectedVersion']='Положительное целое число'; if(!is_string($v['title']??null)||!preg_match('/^.{3,70}$/us',$v['title']))$errors['title']='От 3 до 70 символов'; if($errors)fail(422,'VALIDATION_FAILED','Проверьте поля',$errors); if($v['expectedVersion']!==3)reply(409,['error'=>['code'=>'VERSION_CONFLICT','message'=>'Данные изменились: перечитайте карточку','fields'=>(object)[]],'currentVersion'=>3]); reply(200,['data'=>['id'=>1,'title'=>$v['title'],'version'=>4,'persisted'=>false],'message'=>'Показан результат проверки версии. Общая карточка не изменена.']); case 'upload': if(strtolower(trim(explode(';',$_SERVER['CONTENT_TYPE']??'')[0]))!=='text/plain')fail(415,'UNSUPPORTED_MEDIA','Этот опыт принимает только text/plain'); $raw=rawBody(4096); if($raw==='')fail(422,'EMPTY_FILE','Передайте непустой текст'); if(!preg_match('//u',$raw))fail(422,'INVALID_ENCODING','Нужен UTF-8 текст'); reply(200,['data'=>['bytes'=>strlen($raw),'lines'=>substr_count($raw,"\n")+(str_ends_with($raw,"\n")?0:1),'sha256'=>hash('sha256',$raw),'saved'=>false],'message'=>'Тело прочитано в памяти. Файл не сохранялся.']); case 'limited': header('Retry-After: 3');fail(429,'DEMO_RATE_LIMIT','Учебный ответ 429. Это фиксированный сценарий, а не настоящий счётчик лимита.'); case 'legacy': reply(200,['data'=>['supported'=>true,'replacement'=>'catalog.php','deprecated'=>true],'message'=>'deprecated в контракте предупреждает о миграции, но сам по себе не выключает endpoint.']); }