288 lines
9.6 KiB
PHP
288 lines
9.6 KiB
PHP
<?php
|
|
|
|
namespace App\Rules;
|
|
|
|
use Closure;
|
|
use Illuminate\Contracts\Validation\ValidationRule;
|
|
|
|
class ValidLayoutStructure implements ValidationRule
|
|
{
|
|
/**
|
|
* 최대 중첩 깊이
|
|
*/
|
|
private const MAX_DEPTH = 10;
|
|
|
|
/**
|
|
* 레이아웃 JSON 스키마 검증
|
|
*
|
|
* 필수 필드, 재귀적 children 구조, 데이터 타입, actions 배열 구조를 검증합니다.
|
|
*/
|
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
|
{
|
|
// JSON 문자열인 경우 디코딩
|
|
if (is_string($value)) {
|
|
$decoded = json_decode($value, true);
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
$fail(__('validation.layout.invalid_json'));
|
|
|
|
return;
|
|
}
|
|
$value = $decoded;
|
|
}
|
|
|
|
// 배열이 아닌 경우
|
|
if (! is_array($value)) {
|
|
$fail(__('validation.layout.must_be_array'));
|
|
|
|
return;
|
|
}
|
|
|
|
// 최상위 필수 필드 검증
|
|
if (! $this->validateRequiredFields($value, $fail)) {
|
|
return;
|
|
}
|
|
|
|
// components 필드 검증 (존재하는 경우만)
|
|
if (isset($value['components']) && ! $this->validateComponents($value, $fail)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 최상위 필수 필드 검증
|
|
*/
|
|
private function validateRequiredFields(array $data, Closure $fail): bool
|
|
{
|
|
// 기본 필수 필드
|
|
$requiredFields = ['version', 'layout_name'];
|
|
|
|
foreach ($requiredFields as $field) {
|
|
if (! isset($data[$field])) {
|
|
$fail(__('validation.layout.required_field_missing', ['field' => $field]));
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// version 타입 검증
|
|
if (! is_string($data['version'])) {
|
|
$fail(__('validation.layout.version_must_be_string'));
|
|
|
|
return false;
|
|
}
|
|
|
|
// layout_name 타입 검증
|
|
if (! is_string($data['layout_name'])) {
|
|
$fail(__('validation.layout.layout_name_must_be_string'));
|
|
|
|
return false;
|
|
}
|
|
|
|
// extends가 있는 경우 (상속 레이아웃) - components나 slots 중 하나는 있어야 함
|
|
if (isset($data['extends'])) {
|
|
if (! isset($data['components']) && ! isset($data['slots'])) {
|
|
$fail(__('validation.layout.components_or_slots_required'));
|
|
|
|
return false;
|
|
}
|
|
} else {
|
|
// extends가 없는 경우 (base 레이아웃) - components 필수
|
|
if (! isset($data['components'])) {
|
|
$fail(__('validation.layout.required_field_missing', ['field' => 'components']));
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// components 타입 검증 (존재하는 경우)
|
|
if (isset($data['components']) && ! is_array($data['components'])) {
|
|
$fail(__('validation.layout.components_must_be_array'));
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* components 배열 검증
|
|
*/
|
|
private function validateComponents(array $data, Closure $fail): bool
|
|
{
|
|
$components = $data['components'];
|
|
|
|
foreach ($components as $index => $component) {
|
|
if (! $this->validateComponent($component, $index, $fail)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 개별 컴포넌트 검증
|
|
*
|
|
* children 내에 올 수 있는 요소 타입:
|
|
* - 일반 컴포넌트: type, name 필수
|
|
* - 슬롯 참조: { "slot": "slotName" }
|
|
* - Partial 참조: { "partial": "path/to/file.json" }
|
|
*/
|
|
private function validateComponent(mixed $component, int|string $index, Closure $fail, int $depth = 0): bool
|
|
{
|
|
// 중첩 깊이 제한 검증
|
|
if ($depth > self::MAX_DEPTH) {
|
|
$fail(__('validation.layout.max_depth_exceeded', ['max' => self::MAX_DEPTH]));
|
|
|
|
return false;
|
|
}
|
|
|
|
// 컴포넌트는 배열이어야 함
|
|
if (! is_array($component)) {
|
|
$fail(__('validation.layout.component_must_be_array', ['index' => $index]));
|
|
|
|
return false;
|
|
}
|
|
|
|
// 슬롯 참조인 경우 (slot 필드만 있음) - type/name 검증 건너뜀
|
|
if (isset($component['slot']) && is_string($component['slot'])) {
|
|
return true;
|
|
}
|
|
|
|
// Partial 참조인 경우 (partial 필드만 있음) - type/name 검증 건너뜀
|
|
if (isset($component['partial']) && is_string($component['partial'])) {
|
|
return true;
|
|
}
|
|
|
|
// 필수 필드 검증 (id, props는 선택적 필드)
|
|
$requiredComponentFields = ['type', 'name'];
|
|
|
|
foreach ($requiredComponentFields as $field) {
|
|
if (! isset($component[$field])) {
|
|
$fail(__('validation.layout.component_required_field_missing', ['index' => $index, 'field' => $field]));
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// name 필드 타입 검증
|
|
if (! is_string($component['name'])) {
|
|
$fail(__('validation.layout.component_name_must_be_string', ['index' => $index]));
|
|
|
|
return false;
|
|
}
|
|
|
|
// type 필드 검증 — basic/composite/layout 외에 extension_point 도 정상 노드 타입이다.
|
|
// extension_point 노드(예: `user_global_overlay`, `identity_provider_ui:provider`)는
|
|
// 호스트 레이아웃에 선언되는 확장 주입 지점으로, type="extension_point" + name(슬롯명) +
|
|
// default/children 구조를 가진다. 이를 허용하지 않으면 extension_point 를 포함한 레이아웃
|
|
// (`_user_base` 등)을 편집기에서 저장할 수 없다(`components[N].children[0].type`
|
|
// 422). ValidLayoutExtensionStructure 가 이미 동일하게 extension_point 를 예외 허용한다.
|
|
if (! in_array($component['type'], ['basic', 'composite', 'layout', 'extension_point'], true)) {
|
|
$fail(__('validation.layout.component_type_invalid', ['index' => $index]));
|
|
|
|
return false;
|
|
}
|
|
|
|
// props 필드 타입 검증 (선택적 필드)
|
|
if (isset($component['props']) && ! is_array($component['props']) && ! is_object($component['props'])) {
|
|
$fail(__('validation.layout.props_must_be_object', ['index' => $index]));
|
|
|
|
return false;
|
|
}
|
|
|
|
// children 필드 검증 (선택적)
|
|
if (isset($component['children'])) {
|
|
if (! is_array($component['children'])) {
|
|
$fail(__('validation.layout.children_must_be_array', ['index' => $index]));
|
|
|
|
return false;
|
|
}
|
|
|
|
// 재귀적으로 children 검증
|
|
foreach ($component['children'] as $childIndex => $child) {
|
|
if (! $this->validateComponent($child, "{$index}.children[{$childIndex}]", $fail, $depth + 1)) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// permissions 필드 검증 (선택적 - 컴포넌트별 권한 제어, OR/AND 구조 지원)
|
|
if (isset($component['permissions'])) {
|
|
$permissionRule = new ValidPermissionStructure((string) $index);
|
|
$permissionFailed = false;
|
|
$permissionRule->validate(
|
|
"components[{$index}].permissions",
|
|
$component['permissions'],
|
|
function ($message) use ($fail, &$permissionFailed) {
|
|
$fail($message);
|
|
$permissionFailed = true;
|
|
}
|
|
);
|
|
|
|
if ($permissionFailed) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// actions 필드 검증 (선택적)
|
|
if (isset($component['actions'])) {
|
|
if (! $this->validateActions($component['actions'], $index, $fail)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* actions 배열 검증
|
|
*
|
|
* type 또는 event 중 하나가 필수입니다:
|
|
* - type: 표준 DOM 이벤트 (click, change, submit 등)
|
|
* - event: 커스텀 콜백 이벤트 (onChange, onRowAction 등)
|
|
*/
|
|
private function validateActions(mixed $actions, int|string $componentIndex, Closure $fail): bool
|
|
{
|
|
if (! is_array($actions)) {
|
|
$fail(__('validation.layout.actions_must_be_array', ['index' => $componentIndex]));
|
|
|
|
return false;
|
|
}
|
|
|
|
foreach ($actions as $actionIndex => $action) {
|
|
if (! is_array($action)) {
|
|
$fail(__('validation.layout.action_must_be_array', ['index' => $componentIndex, 'actionIndex' => $actionIndex]));
|
|
|
|
return false;
|
|
}
|
|
|
|
// type 또는 event 중 하나는 필수
|
|
$hasType = isset($action['type']);
|
|
$hasEvent = isset($action['event']);
|
|
|
|
if (! $hasType && ! $hasEvent) {
|
|
$fail(__('validation.layout.action_type_or_event_missing', ['index' => $componentIndex, 'actionIndex' => $actionIndex]));
|
|
|
|
return false;
|
|
}
|
|
|
|
// type이 있으면 문자열이어야 함
|
|
if ($hasType && ! is_string($action['type'])) {
|
|
$fail(__('validation.layout.action_type_must_be_string', ['index' => $componentIndex, 'actionIndex' => $actionIndex]));
|
|
|
|
return false;
|
|
}
|
|
|
|
// event가 있으면 문자열이어야 함
|
|
if ($hasEvent && ! is_string($action['event'])) {
|
|
$fail(__('validation.layout.action_event_must_be_string', ['index' => $componentIndex, 'actionIndex' => $actionIndex]));
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|