feat(Docker): Upgrade to PHP 8.5.0RC3 with native ext-uri support

BREAKING CHANGE: Requires PHP 8.5.0RC3

Changes:
- Update Docker base image from php:8.4-fpm to php:8.5.0RC3-fpm
- Enable ext-uri for native WHATWG URL parsing support
- Update composer.json PHP requirement from ^8.4 to ^8.5
- Add ext-uri as required extension in composer.json
- Move URL classes from Url.php85/ to Url/ directory (now compatible)
- Remove temporary PHP 8.4 compatibility workarounds

Benefits:
- Native URL parsing with Uri\WhatWg\Url class
- Better performance for URL operations
- Future-proof with latest PHP features
- Eliminates PHP version compatibility issues
This commit is contained in:
2025-10-27 09:31:28 +01:00
parent 799f74f00a
commit c8b47e647d
81 changed files with 6988 additions and 601 deletions

View File

@@ -28,7 +28,7 @@ final readonly class MLDashboardAdminController
#[Route(path: '/admin/ml/dashboard', method: Method::GET, name: AdminRoutes::ML_DASHBOARD)]
public function dashboard(HttpRequest $request): ViewResult
{
$timeWindowHours = (int) ($request->queryParameters['timeWindow'] ?? 24);
$timeWindowHours = $request->query->getInt('timeWindow', 24);
$timeWindow = Duration::fromHours($timeWindowHours);
// Get all models
@@ -115,6 +115,12 @@ final readonly class MLDashboardAdminController
$byType[$typeName] = ($byType[$typeName] ?? 0) + 1;
}
// Fetch confusion matrices
$confusionMatrices = $this->getConfusionMatrices($allModels, $timeWindow);
// Fetch registry summary
$registrySummary = $this->getRegistrySummary($allModels);
$data = [
'title' => 'ML Model Dashboard',
'page_title' => 'Machine Learning Model Dashboard',
@@ -143,6 +149,17 @@ final readonly class MLDashboardAdminController
'has_alerts' => count($degradationAlerts) > 0,
'alert_count' => count($degradationAlerts),
// Confusion matrices
'confusion_matrices' => $confusionMatrices,
'has_confusion_matrices' => count($confusionMatrices) > 0,
// Registry summary
'registry_total_versions' => $registrySummary['total_versions'],
'registry_production_count' => $registrySummary['production_count'],
'registry_development_count' => $registrySummary['development_count'],
'registry_models' => $registrySummary['models'],
'has_registry_summary' => count($registrySummary['models']) > 0,
// Links
'api_dashboard_url' => '/api/ml/dashboard',
'api_health_url' => '/api/ml/dashboard/health',
@@ -172,4 +189,109 @@ final readonly class MLDashboardAdminController
return $allModels;
}
/**
* Get confusion matrices for classification models
*/
private function getConfusionMatrices(array $allModels, Duration $timeWindow): array
{
$matrices = [];
foreach ($allModels as $metadata) {
$metrics = $this->performanceMonitor->getCurrentMetrics(
$metadata->modelName,
$metadata->version,
$timeWindow
);
if (isset($metrics['confusion_matrix'])) {
$cm = $metrics['confusion_matrix'];
$total = $metrics['total_predictions'];
$fpRate = $total > 0 ? $cm['false_positive'] / $total : 0.0;
$fnRate = $total > 0 ? $cm['false_negative'] / $total : 0.0;
$matrices[] = [
'model_name' => $metadata->modelName,
'version' => $metadata->version->toString(),
'type' => $metadata->modelType->value,
'true_positives' => number_format($cm['true_positive']),
'true_negatives' => number_format($cm['true_negative']),
'false_positives' => number_format($cm['false_positive']),
'false_negatives' => number_format($cm['false_negative']),
'fp_rate' => round($fpRate, 4),
'fn_rate' => round($fnRate, 4),
'fp_rate_percent' => round($fpRate * 100, 2),
'fn_rate_percent' => round($fnRate * 100, 2),
'fp_rate_badge' => $fpRate > 0.1 ? 'warning' : 'success',
'fn_rate_badge' => $fnRate > 0.1 ? 'warning' : 'success',
'total_predictions' => $total,
];
}
}
return $matrices;
}
/**
* Get model registry summary statistics
*/
private function getRegistrySummary(array $allModels): array
{
// Group by model name
$modelGroups = [];
$productionCount = 0;
$developmentCount = 0;
foreach ($allModels as $metadata) {
$modelName = $metadata->modelName;
if (!isset($modelGroups[$modelName])) {
$modelGroups[$modelName] = [
'model_name' => $modelName,
'type' => $metadata->modelType->value,
'versions' => [],
];
}
$modelGroups[$modelName]['versions'][] = [
'version' => $metadata->version->toString(),
'environment' => $metadata->environment,
];
// Count environments
if ($metadata->environment === 'production') {
$productionCount++;
} elseif ($metadata->environment === 'development') {
$developmentCount++;
}
}
// Calculate summary per model
$modelsSummary = [];
foreach ($modelGroups as $modelName => $group) {
// Sort versions
$versions = array_column($group['versions'], 'version');
usort($versions, 'version_compare');
// Get latest environment
$latestVersion = end($versions);
$latestVersionData = array_filter($group['versions'], fn($v) => $v['version'] === $latestVersion);
$latestEnv = !empty($latestVersionData) ? reset($latestVersionData)['environment'] : 'unknown';
$modelsSummary[] = [
'model_name' => $modelName,
'type' => $group['type'],
'version_count' => count($versions),
'latest_version' => $latestVersion,
'environment' => $latestEnv,
];
}
return [
'total_versions' => count($allModels),
'production_count' => $productionCount,
'development_count' => $developmentCount,
'models' => $modelsSummary,
];
}
}

View File

@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin\Notifications;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\HttpRequest;
use App\Framework\Http\Status;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Http\Responses\JsonResponse;
use App\Framework\Notification\Storage\NotificationRepository;
use App\Framework\Notification\ValueObjects\NotificationId;
use App\Framework\Meta\MetaData;
/**
* Admin Notifications Controller
*
* Displays and manages system notifications for administrators
*/
final readonly class NotificationsAdminController
{
public function __construct(
private NotificationRepository $notificationRepository
) {}
/**
* Display notifications list
*/
#[Route(path: '/admin/notifications', method: Method::GET)]
public function index(HttpRequest $request): ViewResult
{
// For now, use 'admin' as recipient ID
// TODO: Replace with actual authenticated admin user ID
$adminUserId = 'admin';
// Get pagination parameters
$page = $request->query->getInt('page', 1);
$perPage = 20;
$offset = ($page - 1) * $perPage;
// Fetch notifications
$notifications = $this->notificationRepository->findByUser(
$adminUserId,
limit: $perPage,
offset: $offset
);
// Get unread count
$unreadCount = $this->notificationRepository->countUnreadByUser($adminUserId);
// Transform notifications for template
$notificationsList = array_map(function ($notification) {
return [
'id' => $notification->id->toString(),
'type' => $notification->type->toString(),
'title' => $notification->title,
'body' => $notification->body,
'priority' => $notification->priority->value,
'status' => $notification->status->value,
'is_read' => $notification->status->value === 'read',
'created_at' => $notification->createdAt->format('Y-m-d H:i:s'),
'created_at_human' => $this->getHumanReadableTime($notification->createdAt->toDateTime()),
'action_url' => $notification->actionUrl,
'action_label' => $notification->actionLabel ?? 'View Details',
'icon' => $this->getNotificationIcon($notification->type->toString()),
'badge_class' => $this->getPriorityBadgeClass($notification->priority->value),
];
}, $notifications);
$data = [
'notifications' => $notificationsList,
'unread_count' => $unreadCount,
'current_page' => $page,
'per_page' => $perPage,
'has_more' => count($notifications) === $perPage,
'has_notifications' => count($notifications) > 0,
'has_unread' => $unreadCount > 0, // Boolean flag for if attribute
'show_pagination' => count($notifications) === $perPage || $page > 1, // Boolean flag
];
return new ViewResult(
template: 'notification-index',
metaData: new MetaData(
title: 'Notifications - Admin',
description: 'System notifications for administrators'
),
data : $data
);
}
/**
* Mark notification as read
*/
#[Route(path: '/admin/notifications/{id}/read', method: Method::POST)]
public function markAsRead(HttpRequest $request, string $id): JsonResponse
{
$notificationId = NotificationId::fromString($id);
$success = $this->notificationRepository->markAsRead($notificationId);
if (!$success) {
return new JsonResponse(
body: ['error' => 'Notification not found'],
status: Status::NOT_FOUND
);
}
// Get updated unread count
$unreadCount = $this->notificationRepository->countUnreadByUser('admin');
return new JsonResponse([
'success' => true,
'unread_count' => $unreadCount,
]);
}
/**
* Mark all notifications as read
*/
#[Route(path: '/admin/notifications/read-all', method: Method::POST)]
public function markAllAsRead(HttpRequest $request): JsonResponse
{
$count = $this->notificationRepository->markAllAsReadForUser('admin');
return new JsonResponse([
'success' => true,
'marked_count' => $count,
'unread_count' => 0,
]);
}
/**
* Get unread count for badge
*/
#[Route(path: '/admin/notifications/unread-count', method: Method::GET)]
public function getUnreadCount(HttpRequest $request): JsonResponse
{
$unreadCount = $this->notificationRepository->countUnreadByUser('admin');
return new JsonResponse([
'unread_count' => $unreadCount,
]);
}
/**
* Get human-readable time difference
*/
private function getHumanReadableTime(\DateTimeInterface $timestamp): string
{
$now = new \DateTime();
$diff = $now->diff($timestamp);
if ($diff->y > 0) {
return $diff->y . ' year' . ($diff->y > 1 ? 's' : '') . ' ago';
}
if ($diff->m > 0) {
return $diff->m . ' month' . ($diff->m > 1 ? 's' : '') . ' ago';
}
if ($diff->d > 0) {
return $diff->d . ' day' . ($diff->d > 1 ? 's' : '') . ' ago';
}
if ($diff->h > 0) {
return $diff->h . ' hour' . ($diff->h > 1 ? 's' : '') . ' ago';
}
if ($diff->i > 0) {
return $diff->i . ' minute' . ($diff->i > 1 ? 's' : '') . ' ago';
}
return 'Just now';
}
/**
* Get icon for notification type
*/
private function getNotificationIcon(string $type): string
{
return match ($type) {
'ml_performance_degradation' => '⚠️',
'ml_model_deployed' => '🚀',
'ml_training_complete' => '✅',
'system_alert' => '🚨',
'security_alert' => '🔒',
'info' => '',
default => '📢',
};
}
/**
* Get badge class for priority
*/
private function getPriorityBadgeClass(string $priority): string
{
return match ($priority) {
'urgent' => 'admin-badge--danger',
'high' => 'admin-badge--warning',
'normal' => 'admin-badge--info',
'low' => 'admin-badge--secondary',
default => 'admin-badge--info',
};
}
}

View File

@@ -0,0 +1,420 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{meta.title}</title>
<meta name="description" content="{meta.description}">
<link rel="stylesheet" href="/css/admin.css">
<style>
.notification-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.notification-header h1 {
margin: 0;
font-size: 2rem;
color: var(--color-text);
}
.unread-badge {
background: var(--color-danger);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.875rem;
font-weight: 600;
}
.notification-actions {
margin-bottom: 1.5rem;
}
.btn-mark-all {
background: var(--color-primary);
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
transition: background 0.2s;
}
.btn-mark-all:hover {
background: var(--color-primary-dark);
}
.notification-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.notification-item {
background: white;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 1.5rem;
transition: all 0.2s;
position: relative;
}
.notification-item.unread {
border-left: 4px solid var(--color-primary);
background: #f8f9fa;
}
.notification-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.notification-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.notification-title-wrapper {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
}
.notification-icon {
font-size: 1.5rem;
}
.notification-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text);
margin: 0;
}
.notification-meta {
display: flex;
align-items: center;
gap: 0.75rem;
}
.notification-time {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.admin-badge {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.admin-badge--danger {
background: #fee;
color: #c33;
}
.admin-badge--warning {
background: #ffc;
color: #c93;
}
.admin-badge--info {
background: #e3f2fd;
color: #1976d2;
}
.admin-badge--secondary {
background: #f5f5f5;
color: #666;
}
.notification-body {
color: var(--color-text-secondary);
line-height: 1.6;
margin-bottom: 1rem;
}
.notification-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.notification-action {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--color-primary);
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: color 0.2s;
}
.notification-action:hover {
color: var(--color-primary-dark);
}
.btn-mark-read {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
padding: 0.375rem 0.75rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.btn-mark-read:hover {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 2rem;
padding: 1rem;
}
.pagination-link {
padding: 0.5rem 1rem;
background: white;
border: 1px solid var(--color-border);
border-radius: 0.25rem;
color: var(--color-text);
text-decoration: none;
transition: all 0.2s;
}
.pagination-link:hover {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: var(--color-text-muted);
}
.empty-state-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.empty-state-title {
font-size: 1.5rem;
margin-bottom: 0.5rem;
color: var(--color-text);
}
</style>
</head>
<body>
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="admin-sidebar-header">
<h2>Admin Panel</h2>
</div>
<nav class="admin-nav">
<a href="/admin/dashboard" class="admin-nav-link">
📊 Dashboard
</a>
<a href="/admin/ml/dashboard" class="admin-nav-link">
🤖 ML Dashboard
</a>
<a href="/admin/notifications" class="admin-nav-link admin-nav-link--active">
🔔 Notifications
<span class="unread-badge" if="$unread_count > 0">{{ $unread_count }}</span>
</a>
<a href="/admin/users" class="admin-nav-link">
👥 Users
</a>
<a href="/admin/settings" class="admin-nav-link">
⚙️ Settings
</a>
</nav>
</aside>
<main class="admin-main">
<div class="notification-container">
<div class="notification-header">
<h1>Notifications</h1>
<span class="unread-badge" if="$unread_count > 0">{{ $unread_count }} unread</span>
</div>
<div class="notification-actions" if="$unread_count > 0">
<button class="btn-mark-all" onclick="markAllAsRead()">
Mark all as read
</button>
</div>
<div class="notification-list" if="$has_notifications">
<div foreach="$notifications as $notification" class="notification-item" data-id="{{ $notification['id'] }}">
<div class="notification-item-header">
<div class="notification-title-wrapper">
<span class="notification-icon">{{ $notification['icon'] }}</span>
<h3 class="notification-title">{{ $notification['title'] }}</h3>
</div>
<div class="notification-meta">
<span class="admin-badge {{ $notification['badge_class'] }}">{{ $notification['priority'] }}</span>
<span class="notification-time">{{ $notification['created_at_human'] }}</span>
</div>
</div>
<div class="notification-body">
{{ $notification['body'] }}
</div>
<div class="notification-footer">
<div>
<a href="{{ $notification['action_url'] }}" class="notification-action" if="$notification['action_url']">
{{ $notification['action_label'] }}
</a>
</div>
<button
class="btn-mark-read"
onclick="markAsRead('{{ $notification['id'] }}')"
>
Mark as read
</button>
</div>
</div>
</div>
<div class="pagination" if="$has_more || $current_page > 1">
<a href="/admin/notifications?page={{ $current_page - 1 }}" class="pagination-link" if="$current_page > 1">
Previous
</a>
<span class="pagination-info">Page {{ $current_page }}</span>
<a href="/admin/notifications?page={{ $current_page + 1 }}" class="pagination-link" if="$has_more">
Next
</a>
</div>
<div class="empty-state" if="!$has_notifications">
<div class="empty-state-icon">📭</div>
<h2 class="empty-state-title">No notifications</h2>
<p>You're all caught up! No notifications to display.</p>
</div>
</div>
</main>
</div>
<script>
async function markAsRead(notificationId) {
try {
const response = await fetch(`/admin/notifications/${notificationId}/read`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
const data = await response.json();
if (data.success) {
// Update UI
const item = document.querySelector(`[data-id="${notificationId}"]`);
if (item) {
item.classList.remove('unread');
const button = item.querySelector('.btn-mark-read');
if (button) {
button.textContent = ' Read';
button.disabled = true;
}
}
// Update unread count badge
updateUnreadCount(data.unread_count);
}
} catch (error) {
console.error('Error marking notification as read:', error);
alert('Failed to mark notification as read. Please try again.');
}
}
async function markAllAsRead() {
if (!confirm('Mark all notifications as read?')) {
return;
}
try {
const response = await fetch('/admin/notifications/read-all', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
const data = await response.json();
if (data.success) {
// Reload page to show updated state
window.location.reload();
}
} catch (error) {
console.error('Error marking all notifications as read:', error);
alert('Failed to mark all notifications as read. Please try again.');
}
}
function updateUnreadCount(count) {
const badges = document.querySelectorAll('.unread-badge');
badges.forEach(badge => {
if (count === 0) {
badge.style.display = 'none';
} else {
badge.textContent = count;
badge.style.display = '';
}
});
}
// Poll for new notifications every 30 seconds
setInterval(async () => {
try {
const response = await fetch('/admin/notifications/unread-count');
const data = await response.json();
const currentBadge = document.querySelector('.unread-badge');
const currentCount = currentBadge ? parseInt(currentBadge.textContent) || 0 : 0;
if (data.unread_count !== currentCount) {
updateUnreadCount(data.unread_count);
if (data.unread_count > currentCount) {
console.log('New notifications received');
}
}
} catch (error) {
console.error('Error polling for notifications:', error);
}
}, 30000);
</script>
</body>
</html>

View File

@@ -228,6 +228,123 @@
</div>
</div>
<!-- Confusion Matrices Section -->
<div class="admin-card" if="{{ $has_confusion_matrices }}">
<div class="admin-card__header">
<h3 class="admin-card__title">Classification Performance (Confusion Matrices)</h3>
</div>
<div class="admin-card__content">
<div class="admin-grid admin-grid--2-col">
<div foreach="$confusion_matrices as $matrix" class="confusion-matrix-card">
<h4 class="confusion-matrix-card__title">{{ $matrix['model_name'] }} v{{ $matrix['version'] }}</h4>
<div class="confusion-matrix">
<div class="confusion-matrix__grid">
<!-- True Positive -->
<div class="confusion-matrix__cell confusion-matrix__cell--tp">
<div class="confusion-matrix__cell-label">True Positive</div>
<div class="confusion-matrix__cell-value">{{ $matrix['true_positives'] }}</div>
</div>
<!-- False Positive -->
<div class="confusion-matrix__cell confusion-matrix__cell--fp">
<div class="confusion-matrix__cell-label">False Positive</div>
<div class="confusion-matrix__cell-value">{{ $matrix['false_positives'] }}</div>
</div>
<!-- False Negative -->
<div class="confusion-matrix__cell confusion-matrix__cell--fn">
<div class="confusion-matrix__cell-label">False Negative</div>
<div class="confusion-matrix__cell-value">{{ $matrix['false_negatives'] }}</div>
</div>
<!-- True Negative -->
<div class="confusion-matrix__cell confusion-matrix__cell--tn">
<div class="confusion-matrix__cell-label">True Negative</div>
<div class="confusion-matrix__cell-value">{{ $matrix['true_negatives'] }}</div>
</div>
</div>
<div class="confusion-matrix__rates">
<div class="admin-stat-item">
<span class="admin-stat-item__label">False Positive Rate</span>
<span class="admin-stat-item__value">
<span class="admin-badge admin-badge--{{ $matrix['fp_rate_badge'] }}">
{{ $matrix['fp_rate_percent'] }}%
</span>
</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">False Negative Rate</span>
<span class="admin-stat-item__value">
<span class="admin-badge admin-badge--{{ $matrix['fn_rate_badge'] }}">
{{ $matrix['fn_rate_percent'] }}%
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Model Registry Summary -->
<div class="admin-card" if="{{ $has_registry_summary }}">
<div class="admin-card__header">
<h3 class="admin-card__title">Model Registry Summary</h3>
</div>
<div class="admin-card__content">
<div class="admin-grid admin-grid--3-col">
<div class="admin-stat-item">
<span class="admin-stat-item__label">Total Versions</span>
<span class="admin-stat-item__value">{{ $registry_total_versions }}</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">Production Models</span>
<span class="admin-stat-item__value">
<span class="admin-badge admin-badge--success">{{ $registry_production_count }}</span>
</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">Development Models</span>
<span class="admin-stat-item__value">
<span class="admin-badge admin-badge--info">{{ $registry_development_count }}</span>
</span>
</div>
</div>
<div class="admin-table-container">
<table class="admin-table admin-table--compact">
<thead>
<tr>
<th>Model Name</th>
<th>Total Versions</th>
<th>Type</th>
<th>Latest Version</th>
<th>Environment</th>
</tr>
</thead>
<tbody>
<tr foreach="$registry_models as $regModel">
<td><strong>{{ $regModel['model_name'] }}</strong></td>
<td>{{ $regModel['version_count'] }}</td>
<td>
<span class="admin-badge admin-badge--info">{{ $regModel['type'] }}</span>
</td>
<td><code>{{ $regModel['latest_version'] }}</code></td>
<td>
<span class="admin-badge admin-badge--{{ $regModel['environment'] === 'production' ? 'success' : 'info' }}">
{{ $regModel['environment'] }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- API Information Card -->
<div class="admin-card">
<div class="admin-card__header">
@@ -247,6 +364,18 @@
<code>GET {{ $api_health_url }}</code>
</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">Confusion Matrices</span>
<span class="admin-stat-item__value">
<code>GET /api/ml/dashboard/confusion-matrices</code>
</span>
</div>
<div class="admin-stat-item">
<span class="admin-stat-item__label">Registry Summary</span>
<span class="admin-stat-item__value">
<code>GET /api/ml/dashboard/registry-summary</code>
</span>
</div>
</div>
</div>
</div>

View File

@@ -350,8 +350,8 @@ final readonly class MLABTestingController
)]
public function calculateSampleSize(HttpRequest $request): JsonResult
{
$confidenceLevel = (float) ($request->queryParameters['confidence_level'] ?? 0.95);
$marginOfError = (float) ($request->queryParameters['margin_of_error'] ?? 0.05);
$confidenceLevel = $request->query->getFloat('confidence_level', 0.95);
$marginOfError = $request->query->getFloat('margin_of_error', 0.05);
// Validate parameters
if ($confidenceLevel < 0.5 || $confidenceLevel > 0.99) {

View File

@@ -91,7 +91,7 @@ final readonly class MLDashboardController
)]
public function getDashboardData(HttpRequest $request): JsonResult
{
$timeWindowHours = (int) ($request->queryParameters['timeWindow'] ?? 24);
$timeWindowHours = $request->query->getInt('timeWindow', 24);
$timeWindow = Duration::fromHours($timeWindowHours);
// Get all models
@@ -280,7 +280,7 @@ final readonly class MLDashboardController
)]
public function getAlerts(HttpRequest $request): JsonResult
{
$severityFilter = $request->queryParameters['severity'] ?? null;
$severityFilter = $request->query->get('severity');
$allModels = $this->getAllModels();
$timeWindow = Duration::fromHours(1);

View File

@@ -74,7 +74,7 @@ final readonly class MLModelsController
)]
public function listModels(HttpRequest $request): JsonResult
{
$typeFilter = $request->queryParameters['type'] ?? null;
$typeFilter = $request->query->get('type');
// Get all model names
$modelNames = $this->registry->getAllModelNames();
@@ -161,7 +161,7 @@ final readonly class MLModelsController
)]
public function getModel(string $modelName, HttpRequest $request): JsonResult
{
$versionString = $request->queryParameters['version'] ?? null;
$versionString = $request->query->get('version');
try {
if ($versionString !== null) {
@@ -253,8 +253,8 @@ final readonly class MLModelsController
)]
public function getMetrics(string $modelName, HttpRequest $request): JsonResult
{
$versionString = $request->queryParameters['version'] ?? null;
$timeWindowHours = (int) ($request->queryParameters['timeWindow'] ?? 1);
$versionString = $request->query->get('version');
$timeWindowHours = $request->query->getInt('timeWindow', 1);
try {
if ($versionString !== null) {
@@ -439,7 +439,7 @@ final readonly class MLModelsController
)]
public function unregisterModel(string $modelName, HttpRequest $request): JsonResult
{
$versionString = $request->queryParameters['version'] ?? null;
$versionString = $request->query->get('version');
if ($versionString === null) {
return new JsonResult([

View File

@@ -37,9 +37,9 @@ interface LiveComponentState extends SerializableState
* Create State VO from array data (from client or storage)
*
* @param array $data Raw state data
* @return static Hydrated state object
* @return self Hydrated state object
*/
public static function fromArray(array $data): static;
public static function fromArray(array $data): self;
/**
* Convert State VO to array for serialization

View File

@@ -12,6 +12,7 @@ enum HashAlgorithm: string
case SHA512 = 'sha512';
case SHA3_256 = 'sha3-256';
case SHA3_512 = 'sha3-512';
case XXHASH3 = 'xxh3';
case XXHASH64 = 'xxh64';
public function isSecure(): bool
@@ -29,6 +30,7 @@ enum HashAlgorithm: string
self::SHA1 => 40,
self::SHA256, self::SHA3_256 => 64,
self::SHA512, self::SHA3_512 => 128,
self::XXHASH3 => 16,
self::XXHASH64 => 16,
};
}
@@ -45,6 +47,17 @@ enum HashAlgorithm: string
public static function fast(): self
{
return extension_loaded('xxhash') ? self::XXHASH64 : self::SHA256;
// Prefer xxh3 if available (faster than xxh64)
if (in_array('xxh3', hash_algos(), true)) {
return self::XXHASH3;
}
// Fallback to xxh64 if available
if (in_array('xxh64', hash_algos(), true)) {
return self::XXHASH64;
}
// Default to SHA256 if no xxhash algorithms available
return self::SHA256;
}
}

View File

@@ -136,7 +136,8 @@ PHP;
$bindings[] = " '{$class}' => \$this->{$methodName}()";
}
return implode(",\n", $bindings);
// Add trailing comma if bindings exist (for match expression syntax)
return empty($bindings) ? '' : implode(",\n", $bindings) . ',';
}
/**

View File

@@ -31,6 +31,7 @@ final readonly class ConnectionInitializer
// Create a simple database manager for connection only with minimal dependencies
$databaseManager = new DatabaseManager(
config: $databaseConfig,
platform: $databaseConfig->driverConfig->platform,
timer: $timer,
migrationsPath: 'database/migrations'
);

View File

@@ -8,6 +8,7 @@ use App\Framework\Core\Events\EventDispatcher;
use App\Framework\Database\Cache\EntityCacheManager;
use App\Framework\Database\Config\DatabaseConfig;
use App\Framework\Database\Platform\MySQLPlatform;
use App\Framework\Database\Platform\PostgreSQLPlatform;
use App\Framework\DateTime\Clock;
use App\Framework\DateTime\Timer;
use App\Framework\DI\Container;
@@ -31,7 +32,7 @@ final readonly class EntityManagerInitializer
}
// Create platform for the database (defaulting to MySQL)
$platform = new MySQLPlatform();
$platform = new PostgreSQLPlatform();
$db = new DatabaseManager(
$databaseConfig,

View File

@@ -15,8 +15,8 @@ use App\Framework\ErrorAggregation\Alerting\EmailAlertChannel;
use App\Framework\ErrorAggregation\Storage\DatabaseErrorStorage;
use App\Framework\ErrorAggregation\Storage\ErrorStorageInterface;
use App\Framework\Logging\Logger;
use App\Framework\Mail\Transport\TransportInterface;
use App\Framework\Queue\Queue;
use App\Framework\Mail\TransportInterface;
/**
* Initializer for Error Aggregation services

View File

@@ -9,6 +9,7 @@ use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\DateTime\Clock;
use App\Framework\ErrorAggregation\Storage\ErrorStorageInterface;
use App\Framework\Exception\Core\ErrorSeverity;
use App\Framework\Exception\ErrorHandlerContext;
use App\Framework\Logging\Logger;
use App\Framework\Queue\Queue;

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Framework\ErrorAggregation;
use App\Framework\Exception\Core\ErrorSeverity;
use App\Framework\Exception\ErrorHandlerContext;
/**

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Framework\ErrorAggregation;
use App\Framework\Exception\ErrorHandlerContext;
use App\Framework\Exception\Core\ErrorSeverity;
/**
* Null Object implementation for ErrorAggregator

View File

@@ -0,0 +1,373 @@
# Native PHP 8.5 URL System
**Zero-dependency URL parsing and manipulation** using native PHP 8.5 `Uri\Rfc3986\Uri` and `Uri\WhatWg\Url`.
## Features
**Native PHP 8.5 API** - No external dependencies
**Dual Spec Support** - RFC 3986 and WHATWG URL Standard
**Smart Factory** - Automatic spec selection based on use case
**Immutable Withers** - Framework-compliant readonly pattern
**Type Safe** - Full type safety with enums and value objects
**IDNA/Punycode** - Native international domain name support
## Installation
**Requirement**: PHP 8.5+
No composer packages needed - uses native PHP URL API!
## Quick Start
### Basic Usage
```php
use App\Framework\Http\Url\UrlFactory;
// Automatic spec selection
$url = UrlFactory::parse('https://example.com/path');
// API Client (RFC 3986)
$apiUrl = UrlFactory::forApiClient('https://api.example.com/users');
// Browser Redirect (WHATWG)
$redirect = UrlFactory::forBrowserRedirect('https://app.example.com/dashboard');
```
### URL Manipulation (Immutable)
```php
$url = UrlFactory::forApiClient('https://api.example.com/users');
// Immutable withers return new instances
$withAuth = $url->withUserInfo('api_key', 'secret');
$withQuery = $url->withQuery('filter=active&sort=name');
$withPath = $url->withPath('/v2/users');
// Component access
echo $url->getScheme(); // 'https'
echo $url->getHost(); // 'api.example.com'
echo $url->getPath(); // '/users'
```
### Use Case Factory Methods
```php
// API & Server-Side (RFC 3986)
UrlFactory::forApiClient($url); // REST, GraphQL, SOAP
UrlFactory::forCurlRequest($url); // cURL operations
UrlFactory::forSignature($url); // OAuth, AWS signing
UrlFactory::forCanonical($url); // SEO, duplicate detection
// Browser & Client-Side (WHATWG)
UrlFactory::forBrowserRedirect($url); // HTTP redirects
UrlFactory::forDeepLink($url); // Universal links
UrlFactory::forFormAction($url); // HTML form actions
UrlFactory::forClientSide($url); // JavaScript fetch()
```
## Architecture
### URL Specifications
**RFC 3986** - Server-side URL handling:
- Strict parsing rules
- No automatic encoding
- Preserves exact structure
- Best for: API clients, signatures, cURL
**WHATWG** - Browser-compatible URL handling:
- Living standard (matches browsers)
- Automatic percent-encoding
- URL normalization
- Best for: Redirects, deep links, forms
### Components
```
src/Framework/Http/Url/
├── Url.php # Unified URL interface
├── UrlSpec.php # RFC3986 vs WHATWG enum
├── UrlUseCase.php # Use case categories
├── Rfc3986Url.php # RFC 3986 implementation
├── WhatwgUrl.php # WHATWG implementation
└── UrlFactory.php # Smart factory
```
## Examples
### API Client with Authentication
```php
$apiUrl = UrlFactory::forApiClient('https://api.example.com/resource');
$withAuth = $apiUrl->withUserInfo('api_key', 'secret_token');
echo $withAuth->toString();
// https://api_key:secret_token@api.example.com/resource
```
### URL Signature Generation
```php
$url = UrlFactory::forSignature('https://api.example.com/resource');
$withParams = $url->withQuery('timestamp=1234567890&user_id=42');
// Generate signature from canonical URL string
$canonical = $withParams->toString();
$signature = hash_hmac('sha256', $canonical, $secretKey);
$signed = $withParams->withQuery(
$withParams->getQuery() . "&signature={$signature}"
);
```
### Browser Redirect with Parameters
```php
$redirect = UrlFactory::forBrowserRedirect('https://app.example.com/login');
$withReturn = $redirect->withQuery('return_url=/dashboard&status=success');
// Browser-compatible encoding
header('Location: ' . $withReturn->toString());
```
### Deep Link with Fallback
```php
$deepLink = UrlFactory::forDeepLink('myapp://open/article/123');
$fallback = UrlFactory::forBrowserRedirect('https://example.com/article/123');
// Try deep link first, fallback to web
$targetUrl = $isNativeApp ? $deepLink : $fallback;
```
### IDNA/Punycode Support
```php
$unicode = UrlFactory::parse('https://例え.jp/path');
$ascii = $unicode->toAsciiString();
echo $ascii; // https://xn--r8jz45g.jp/path
```
### URL Comparison
```php
$url1 = UrlFactory::parse('https://example.com/path#frag1');
$url2 = UrlFactory::parse('https://example.com/path#frag2');
// Ignore fragment by default
$url1->equals($url2); // true
// Include fragment in comparison
$url1->equals($url2, includeFragment: true); // false
```
### Relative URL Resolution
```php
$base = UrlFactory::parse('https://example.com/base/path');
$resolved = $base->resolve('../other/resource');
echo $resolved->toString();
// https://example.com/other/resource
```
### Spec Conversion
```php
$rfc = UrlFactory::forApiClient('https://example.com/path');
$whatwg = UrlFactory::convert($rfc, UrlSpec::WHATWG);
// Now browser-compatible with normalization
```
## Use Case Guide
### When to use RFC 3986
✅ REST API requests
✅ URL signature generation (OAuth, AWS)
✅ cURL operations
✅ Canonical URL generation (SEO)
✅ Webhook URLs
✅ FTP/SFTP URLs
### When to use WHATWG
✅ HTTP redirects
✅ Deep links / universal links
✅ HTML form actions
✅ JavaScript fetch() compatibility
✅ Browser-side URL generation
✅ Mobile app URLs
## Testing
Comprehensive Pest tests included:
```bash
./vendor/bin/pest tests/Unit/Framework/Http/Url/
```
Test coverage:
- RFC 3986 parsing and manipulation
- WHATWG parsing and normalization
- Factory method selection
- URL comparison and resolution
- IDNA/Punycode handling
- Immutability guarantees
- Edge cases and error handling
## Framework Integration
### Readonly Pattern
All URL classes are `final readonly` with immutable withers:
```php
final readonly class Rfc3986Url implements Url
{
private function __construct(
private NativeRfc3986Uri $uri
) {}
// Withers return new instances
public function withPath(string $path): self
{
return new self($this->uri->withPath($path));
}
}
```
### Value Object Pattern
URLs are value objects with value semantics:
```php
$url1 = UrlFactory::parse('https://example.com/path');
$url2 = UrlFactory::parse('https://example.com/path');
$url1->equals($url2); // true
```
### DI Container Integration
Register in container initializer:
```php
final readonly class UrlServiceInitializer implements Initializer
{
#[Initializer]
public function __invoke(): UrlService
{
return new UrlService(UrlFactory::class);
}
}
```
## Performance
Native PHP 8.5 implementation = **C-level performance**:
- ✅ Zero external dependencies
- ✅ No reflection overhead
- ✅ Optimized memory usage
- ✅ Fast parsing and manipulation
- ✅ Native IDNA conversion
## Migration from Legacy Code
### Before (primitive strings)
```php
function generateApiUrl(string $baseUrl, array $params): string
{
$query = http_build_query($params);
return $baseUrl . '?' . $query;
}
```
### After (type-safe URLs)
```php
function generateApiUrl(Url $baseUrl, array $params): Url
{
$query = http_build_query($params);
return $baseUrl->withQuery($query);
}
// Usage
$apiUrl = UrlFactory::forApiClient('https://api.example.com/resource');
$withParams = generateApiUrl($apiUrl, ['filter' => 'active']);
```
## Best Practices
1. **Use factory methods** for automatic spec selection
2. **Prefer specific use case methods** over generic parse()
3. **Type hint with Url** for flexibility
4. **Use equals() for comparison** instead of string comparison
5. **Leverage immutability** - withers are safe for concurrent use
6. **Choose correct spec** - RFC 3986 for server, WHATWG for browser
## Advanced Features
### Native URL Access
Access underlying native PHP URL objects when needed:
```php
$url = UrlFactory::forApiClient('https://example.com');
$nativeUri = $url->getNativeUrl(); // \Uri\Rfc3986\Uri
```
### Custom URL Schemes
Both specs support custom schemes:
```php
$custom = UrlFactory::parse('myscheme://resource/path');
echo $custom->getScheme(); // 'myscheme'
```
## Troubleshooting
### Invalid URL Errors
```php
try {
$url = UrlFactory::parse('invalid://url');
} catch (\InvalidArgumentException $e) {
// Handle parse error
}
```
### Spec Mismatch
Convert between specs when needed:
```php
$rfc = UrlFactory::forApiClient('https://example.com');
$whatwg = UrlFactory::convert($rfc, UrlSpec::WHATWG);
```
### IDNA Issues
Native PHP 8.5 handles IDNA automatically:
```php
$url = UrlFactory::parse('https://例え.jp');
$ascii = $url->toAsciiString(); // Automatic Punycode
```
## Resources
- [RFC 3986 Specification](https://www.rfc-editor.org/rfc/rfc3986)
- [WHATWG URL Standard](https://url.spec.whatwg.org/)
- [PHP 8.5 URL API Documentation](https://www.php.net/manual/en/book.uri.php)
- [IDNA/Punycode Reference](https://www.rfc-editor.org/rfc/rfc5891)
## License
Part of Custom PHP Framework - Internal Use

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Url;
use InvalidArgumentException;
use Throwable;
use Uri\Rfc3986\Uri as NativeRfc3986Uri;
/**
* RFC 3986 compliant URL implementation
*
* Wraps native PHP 8.5+ Uri\Rfc3986\Uri for server-side URL handling.
*
* Use Cases:
* - API clients (REST, GraphQL, SOAP)
* - URL signatures (OAuth, AWS, etc.)
* - cURL requests
* - Server-side canonicalization
*
* Characteristics:
* - Strict RFC 3986 compliance
* - No automatic encoding
* - Preserves exact URL structure
* - Deterministic formatting for signatures
*/
final readonly class Rfc3986Url implements Url
{
/**
* @param NativeRfc3986Uri $uri Native PHP RFC 3986 URI instance
*/
private function __construct(
private NativeRfc3986Uri $uri
) {
}
/**
* Parse RFC 3986 URI from string
*
* @param string $input URI string to parse
* @param Url|null $base Optional base URI for relative resolution
* @return self New RFC 3986 URL instance
* @throws InvalidArgumentException If URI is invalid
*/
public static function parse(string $input, ?Url $base = null): self
{
try {
if ($base instanceof self) {
// RFC 3986 reference resolution with base URI
$uri = $base->uri->resolve($input);
} else {
$uri = NativeRfc3986Uri::parse($input);
}
return new self($uri);
} catch (Throwable $e) {
throw new InvalidArgumentException(
"Failed to parse RFC 3986 URI: {$input}",
previous: $e
);
}
}
public function getSpec(): UrlSpec
{
return UrlSpec::RFC3986;
}
// Component Getters
public function getScheme(): string
{
return $this->uri->getScheme() ?? '';
}
public function getHost(): string
{
return $this->uri->getHost() ?? '';
}
public function getPort(): ?int
{
return $this->uri->getPort();
}
public function getPath(): string
{
return $this->uri->getPath() ?? '';
}
public function getQuery(): string
{
return $this->uri->getQuery() ?? '';
}
public function getFragment(): string
{
return $this->uri->getFragment() ?? '';
}
public function getUserInfo(): string
{
return $this->uri->getUserInfo() ?? '';
}
// Immutable Withers (delegate to native withers)
public function withScheme(string $scheme): self
{
return new self($this->uri->withScheme($scheme));
}
public function withHost(string $host): self
{
return new self($this->uri->withHost($host));
}
public function withPort(?int $port): self
{
return new self($this->uri->withPort($port));
}
public function withPath(string $path): self
{
return new self($this->uri->withPath($path));
}
public function withQuery(string $query): self
{
return new self($this->uri->withQuery($query));
}
public function withFragment(string $fragment): self
{
return new self($this->uri->withFragment($fragment));
}
public function withUserInfo(string $user, ?string $password = null): self
{
$userInfo = $password !== null ? "{$user}:{$password}" : $user;
return new self($this->uri->withUserInfo($userInfo));
}
// Serialization
public function toString(): string
{
return $this->uri->toString();
}
public function toAsciiString(): string
{
// Native PHP 8.5 handles IDNA/Punycode conversion
return $this->uri->toRawString();
}
// Utilities
public function resolve(string $relative): self
{
$resolved = $this->uri->resolve($relative);
return new self($resolved);
}
public function equals(Url $other, bool $includeFragment = false): bool
{
if (! $other instanceof self) {
return false;
}
if ($includeFragment) {
return $this->uri->equals($other->uri);
}
// Compare without fragments
$thisWithoutFragment = $this->uri->withFragment(null);
$otherWithoutFragment = $other->uri->withFragment(null);
return $thisWithoutFragment->equals($otherWithoutFragment);
}
public function getNativeUrl(): NativeRfc3986Uri
{
return $this->uri;
}
/**
* String representation (allows string casting)
*/
public function __toString(): string
{
return $this->uri->toString();
}
}

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Url;
/**
* Unified URL interface for native PHP 8.5 URL API
*
* Abstracts RFC 3986 and WHATWG URL implementations
* providing a unified API surface for URL manipulation.
*
* Replaces old Uri class with full-featured URL handling.
*
* All implementations must be immutable - modification methods
* return new instances (wither pattern).
*/
interface Url
{
/**
* Parse URL from string with optional base URL
*
* @param string $input URL string to parse
* @param self|null $base Optional base URL for relative resolution
* @return self New URL instance
* @throws \InvalidArgumentException If URL is invalid
*/
public static function parse(string $input, ?self $base = null): self;
/**
* Get URL specification this instance conforms to
*
* @return UrlSpec Either RFC3986 or WHATWG
*/
public function getSpec(): UrlSpec;
// Component Getters
/**
* Get URL scheme (e.g., 'https', 'ftp')
*
* @return string Scheme without trailing colon, empty string if absent
*/
public function getScheme(): string;
/**
* Get host component (domain or IP address)
*
* @return string Host, empty string if absent
*/
public function getHost(): string;
/**
* Get port number
*
* @return int|null Port number, null if default or absent
*/
public function getPort(): ?int;
/**
* Get path component
*
* @return string Path, empty string if absent
*/
public function getPath(): string;
/**
* Get query string
*
* @return string Query without leading '?', empty string if absent
*/
public function getQuery(): string;
/**
* Get fragment identifier
*
* @return string Fragment without leading '#', empty string if absent
*/
public function getFragment(): string;
/**
* Get user info (username:password)
*
* @return string User info, empty string if absent
*/
public function getUserInfo(): string;
// Immutable Withers (Framework Pattern)
/**
* Return instance with specified scheme
*
* @param string $scheme New scheme
* @return self New instance with updated scheme
*/
public function withScheme(string $scheme): self;
/**
* Return instance with specified host
*
* @param string $host New host
* @return self New instance with updated host
*/
public function withHost(string $host): self;
/**
* Return instance with specified port
*
* @param int|null $port New port, null for default
* @return self New instance with updated port
*/
public function withPort(?int $port): self;
/**
* Return instance with specified path
*
* @param string $path New path
* @return self New instance with updated path
*/
public function withPath(string $path): self;
/**
* Return instance with specified query
*
* @param string $query New query string (without leading '?')
* @return self New instance with updated query
*/
public function withQuery(string $query): self;
/**
* Return instance with specified fragment
*
* @param string $fragment New fragment (without leading '#')
* @return self New instance with updated fragment
*/
public function withFragment(string $fragment): self;
/**
* Return instance with specified user info
*
* @param string $user Username
* @param string|null $password Optional password
* @return self New instance with updated user info
*/
public function withUserInfo(string $user, ?string $password = null): self;
// Serialization
/**
* Convert URL to string representation
*
* @return string Complete URL string
*/
public function toString(): string;
/**
* Convert URL to ASCII-compatible string (Punycode/IDNA)
*
* @return string ASCII-encoded URL for international domain names
*/
public function toAsciiString(): string;
// Utilities
/**
* Resolve relative URL against this URL as base
*
* @param string $relative Relative URL to resolve
* @return self New URL instance with resolved URL
*/
public function resolve(string $relative): self;
/**
* Check equality with another URL
*
* @param self $other URL to compare
* @param bool $includeFragment Whether to include fragment in comparison
* @return bool True if URLs are equal
*/
public function equals(self $other, bool $includeFragment = false): bool;
/**
* Get underlying native PHP URL object
*
* Provides access to native \Uri\Rfc3986\Uri or \Uri\WhatWg\Url
* for advanced use cases requiring direct native API access.
*
* @return object Native PHP URL object
*/
public function getNativeUrl(): object;
}

View File

@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Url;
/**
* Smart URL factory with automatic spec selection
*
* Provides convenient factory methods that automatically select
* the appropriate URL specification (RFC 3986 vs WHATWG) based
* on the intended use case.
*
* Usage:
* ```php
* // API Client (RFC 3986)
* $apiUrl = UrlFactory::forApiClient('https://api.example.com/users');
*
* // Browser Redirect (WHATWG)
* $redirect = UrlFactory::forBrowserRedirect('https://example.com/dashboard');
*
* // Automatic selection
* $url = UrlFactory::forUseCase(UrlUseCase::DEEP_LINK, 'myapp://open');
* ```
*/
final readonly class UrlFactory
{
/**
* Parse URL with automatic spec detection
*
* Attempts to select appropriate spec based on scheme and structure.
* Defaults to RFC 3986 for ambiguous cases.
*
* @param string $input URL string to parse
* @param Url|null $base Optional base URL
* @return Url Parsed URL with selected spec
*/
public static function parse(string $input, ?Url $base = null): Url
{
// Heuristic: Use WHATWG for http/https URLs, RFC 3986 for others
$scheme = parse_url($input, PHP_URL_SCHEME);
if (in_array($scheme, ['http', 'https', 'file', 'ws', 'wss'], true)) {
return WhatwgUrl::parse($input, $base);
}
return Rfc3986Url::parse($input, $base);
}
/**
* Parse URL for specific use case with automatic spec selection
*
* @param UrlUseCase $useCase Intended use case
* @param string $input URL string to parse
* @param Url|null $base Optional base URL
* @return Url Parsed URL with spec matching use case
*/
public static function forUseCase(UrlUseCase $useCase, string $input, ?Url $base = null): Url
{
$spec = UrlSpec::forUseCase($useCase);
return match ($spec) {
UrlSpec::RFC3986 => Rfc3986Url::parse($input, $base),
UrlSpec::WHATWG => WhatwgUrl::parse($input, $base),
};
}
/**
* Create URL for API client use (RFC 3986)
*
* Best for:
* - REST API calls
* - GraphQL endpoints
* - SOAP services
* - Webhook URLs
*
* @param string $input API endpoint URL
* @param Url|null $base Optional base URL
* @return Rfc3986Url RFC 3986 compliant URL
*/
public static function forApiClient(string $input, ?Url $base = null): Rfc3986Url
{
return Rfc3986Url::parse($input, $base);
}
/**
* Create URL for cURL request (RFC 3986)
*
* Best for:
* - cURL operations
* - HTTP client requests
* - File transfers
*
* @param string $input Request URL
* @param Url|null $base Optional base URL
* @return Rfc3986Url RFC 3986 compliant URL
*/
public static function forCurlRequest(string $input, ?Url $base = null): Rfc3986Url
{
return Rfc3986Url::parse($input, $base);
}
/**
* Create URL for signature generation (RFC 3986)
*
* Best for:
* - OAuth signatures
* - AWS request signing
* - HMAC-based authentication
* - Webhook signature verification
*
* @param string $input URL to sign
* @param Url|null $base Optional base URL
* @return Rfc3986Url RFC 3986 compliant URL
*/
public static function forSignature(string $input, ?Url $base = null): Rfc3986Url
{
return Rfc3986Url::parse($input, $base);
}
/**
* Create canonical URL (RFC 3986)
*
* Best for:
* - SEO canonical URLs
* - Duplicate content detection
* - URL normalization
* - Sitemap generation
*
* @param string $input URL to canonicalize
* @param Url|null $base Optional base URL
* @return Rfc3986Url RFC 3986 compliant URL
*/
public static function forCanonical(string $input, ?Url $base = null): Rfc3986Url
{
return Rfc3986Url::parse($input, $base);
}
/**
* Create URL for browser redirect (WHATWG)
*
* Best for:
* - HTTP redirects (302, 301, etc.)
* - Location headers
* - User-facing redirects
*
* @param string $input Redirect target URL
* @param Url|null $base Optional base URL
* @return WhatwgUrl WHATWG compliant URL
*/
public static function forBrowserRedirect(string $input, ?Url $base = null): WhatwgUrl
{
return WhatwgUrl::parse($input, $base);
}
/**
* Create URL for deep link (WHATWG)
*
* Best for:
* - Universal links
* - App deep links
* - Mobile-to-web links
* - Cross-platform navigation
*
* @param string $input Deep link URL
* @param Url|null $base Optional base URL
* @return WhatwgUrl WHATWG compliant URL
*/
public static function forDeepLink(string $input, ?Url $base = null): WhatwgUrl
{
return WhatwgUrl::parse($input, $base);
}
/**
* Create URL for HTML form action (WHATWG)
*
* Best for:
* - Form submission targets
* - HTML5 form actions
* - Browser form handling
*
* @param string $input Form action URL
* @param Url|null $base Optional base URL
* @return WhatwgUrl WHATWG compliant URL
*/
public static function forFormAction(string $input, ?Url $base = null): WhatwgUrl
{
return WhatwgUrl::parse($input, $base);
}
/**
* Create URL for client-side JavaScript (WHATWG)
*
* Best for:
* - JavaScript fetch() API
* - XMLHttpRequest URLs
* - Browser URL API compatibility
* - Client-side routing
*
* @param string $input JavaScript URL
* @param Url|null $base Optional base URL
* @return WhatwgUrl WHATWG compliant URL
*/
public static function forClientSide(string $input, ?Url $base = null): WhatwgUrl
{
return WhatwgUrl::parse($input, $base);
}
/**
* Convert between URL specs
*
* @param Url $url URL to convert
* @param UrlSpec $targetSpec Target specification
* @return Url Converted URL
*/
public static function convert(Url $url, UrlSpec $targetSpec): Url
{
if ($url->getSpec() === $targetSpec) {
return $url;
}
$urlString = $url->toString();
return match ($targetSpec) {
UrlSpec::RFC3986 => Rfc3986Url::parse($urlString),
UrlSpec::WHATWG => WhatwgUrl::parse($urlString),
};
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Url;
/**
* URL Specification identifier
*
* Distinguishes between RFC 3986 and WHATWG URL Standard
* for different parsing and handling semantics.
*/
enum UrlSpec: string
{
/**
* RFC 3986 - Uniform Resource Identifier (URI): Generic Syntax
*
* Use for:
* - Server-side URL canonicalization
* - API clients (REST, GraphQL, SOAP)
* - URL signatures and validation
* - cURL compatibility
* - File system paths
*
* Characteristics:
* - Strict parsing rules
* - No automatic encoding
* - No URL normalization
* - Preserves original structure
*
* Example:
* ```php
* $uri = Rfc3986Url::parse('https://api.example.com/users?id=123');
* ```
*/
case RFC3986 = 'rfc3986';
/**
* WHATWG URL Standard (Living Standard)
*
* Use for:
* - Browser-like URL handling
* - Deep links and redirects
* - Client-side generated URLs
* - HTML form actions
* - JavaScript fetch() API compatibility
*
* Characteristics:
* - Living standard (matches modern browsers)
* - Automatic percent-encoding
* - URL normalization
* - Special scheme handling (http, https, file, etc.)
*
* Example:
* ```php
* $url = WhatwgUrl::parse('https://example.com/redirect');
* ```
*/
case WHATWG = 'whatwg';
/**
* Get recommended spec for specific use case
*
* Automatically selects the appropriate URL specification
* based on the intended usage pattern.
*/
public static function forUseCase(UrlUseCase $useCase): self
{
return match ($useCase) {
UrlUseCase::API_CLIENT,
UrlUseCase::CURL_REQUEST,
UrlUseCase::SIGNATURE_GENERATION,
UrlUseCase::CANONICAL_URL => self::RFC3986,
UrlUseCase::BROWSER_REDIRECT,
UrlUseCase::DEEP_LINK,
UrlUseCase::HTML_FORM_ACTION,
UrlUseCase::CLIENT_SIDE_URL => self::WHATWG,
};
}
/**
* Check if this spec is RFC 3986
*/
public function isRfc3986(): bool
{
return $this === self::RFC3986;
}
/**
* Check if this spec is WHATWG
*/
public function isWhatwg(): bool
{
return $this === self::WHATWG;
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Url;
/**
* URL Use Case categories for automatic spec selection
*
* Helps determine whether RFC 3986 or WHATWG URL Standard
* should be used based on the intended usage pattern.
*/
enum UrlUseCase
{
/**
* API Client requests (REST, GraphQL, SOAP)
*
* Recommended Spec: RFC 3986
* - Strict parsing for API endpoints
* - URL signature compatibility
* - Predictable canonicalization
*/
case API_CLIENT;
/**
* cURL requests and HTTP client operations
*
* Recommended Spec: RFC 3986
* - Compatible with cURL expectations
* - No automatic normalization
* - Preserves exact URL structure
*/
case CURL_REQUEST;
/**
* URL signature generation (OAuth, AWS, etc.)
*
* Recommended Spec: RFC 3986
* - Deterministic URL formatting
* - No automatic encoding changes
* - Critical for signature validation
*/
case SIGNATURE_GENERATION;
/**
* Canonical URL generation (SEO, duplicate detection)
*
* Recommended Spec: RFC 3986
* - Consistent URL representation
* - Reliable comparison
* - SEO-friendly formatting
*/
case CANONICAL_URL;
/**
* Browser redirect URLs
*
* Recommended Spec: WHATWG
* - Browser-compatible behavior
* - Automatic encoding
* - Matches browser expectations
*/
case BROWSER_REDIRECT;
/**
* Deep links (app-to-web, universal links)
*
* Recommended Spec: WHATWG
* - Mobile browser compatibility
* - Modern URL handling
* - Cross-platform consistency
*/
case DEEP_LINK;
/**
* HTML form action URLs
*
* Recommended Spec: WHATWG
* - HTML5 specification compliance
* - Browser form submission compatibility
* - Automatic encoding of form data
*/
case HTML_FORM_ACTION;
/**
* Client-side generated URLs (JavaScript compatibility)
*
* Recommended Spec: WHATWG
* - Matches JavaScript URL API
* - Compatible with fetch()
* - Consistent with browser behavior
*/
case CLIENT_SIDE_URL;
/**
* Get human-readable description of this use case
*/
public function description(): string
{
return match ($this) {
self::API_CLIENT => 'API client requests (REST, GraphQL, SOAP)',
self::CURL_REQUEST => 'cURL requests and HTTP client operations',
self::SIGNATURE_GENERATION => 'URL signature generation (OAuth, AWS)',
self::CANONICAL_URL => 'Canonical URL generation (SEO)',
self::BROWSER_REDIRECT => 'Browser redirect URLs',
self::DEEP_LINK => 'Deep links and universal links',
self::HTML_FORM_ACTION => 'HTML form action URLs',
self::CLIENT_SIDE_URL => 'Client-side JavaScript URLs',
};
}
/**
* Get recommended URL spec for this use case
*/
public function recommendedSpec(): UrlSpec
{
return UrlSpec::forUseCase($this);
}
}

View File

@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace App\Framework\Http\Url;
use InvalidArgumentException;
use Uri\WhatWg\Url as NativeWhatwgUrl;
/**
* WHATWG URL Standard implementation
*
* Wraps native PHP 8.5+ Uri\WhatWg\Url for browser-compatible URL handling.
*
* Use Cases:
* - Browser redirects
* - Deep links and universal links
* - HTML form actions
* - Client-side JavaScript compatibility
*
* Characteristics:
* - Living Standard (matches modern browsers)
* - Automatic percent-encoding
* - URL normalization
* - Special scheme handling (http, https, file, etc.)
*/
final readonly class WhatwgUrl implements Url
{
/**
* @param NativeWhatwgUrl $url Native PHP WHATWG URL instance
*/
private function __construct(
private NativeWhatwgUrl $url
) {
}
/**
* Parse WHATWG URL from string
*
* @param string $input URL string to parse
* @param Url|null $base Optional base URL for relative resolution
* @return self New WHATWG URL instance
* @throws InvalidArgumentException If URL is invalid
*/
public static function parse(string $input, ?Url $base = null): self
{
try {
if ($base instanceof self) {
// WHATWG URL resolution with base
$url = $base->url->resolve($input);
} else {
$url = new NativeWhatwgUrl($input);
}
return new self($url);
} catch (\Throwable $e) {
throw new InvalidArgumentException(
"Failed to parse WHATWG URL: {$input}",
previous: $e
);
}
}
public function getSpec(): UrlSpec
{
return UrlSpec::WHATWG;
}
// Component Getters (WHATWG methods)
public function getScheme(): string
{
return $this->url->getScheme() ?? '';
}
public function getHost(): string
{
// Prefer Unicode host for display
return $this->url->getUnicodeHost() ?? '';
}
public function getPort(): ?int
{
return $this->url->getPort();
}
public function getPath(): string
{
return $this->url->getPath() ?? '';
}
public function getQuery(): string
{
return $this->url->getQuery() ?? '';
}
public function getFragment(): string
{
return $this->url->getFragment() ?? '';
}
public function getUserInfo(): string
{
$user = $this->url->getUsername() ?? '';
$pass = $this->url->getPassword() ?? '';
if ($user === '') {
return '';
}
return $pass !== '' ? "{$user}:{$pass}" : $user;
}
// Immutable Withers (delegate to native withers)
public function withScheme(string $scheme): self
{
return new self($this->url->withScheme($scheme));
}
public function withHost(string $host): self
{
return new self($this->url->withHost($host));
}
public function withPort(?int $port): self
{
return new self($this->url->withPort($port));
}
public function withPath(string $path): self
{
return new self($this->url->withPath($path));
}
public function withQuery(string $query): self
{
return new self($this->url->withQuery($query !== '' ? $query : null));
}
public function withFragment(string $fragment): self
{
return new self($this->url->withFragment($fragment !== '' ? $fragment : null));
}
public function withUserInfo(string $user, ?string $password = null): self
{
$withUser = $this->url->withUsername($user);
return new self($withUser->withPassword($password ?? ''));
}
// Serialization
public function toString(): string
{
return $this->url->toUnicodeString();
}
public function toAsciiString(): string
{
// WHATWG URLs with Punycode encoding
return $this->url->toAsciiString();
}
// Utilities
public function resolve(string $relative): self
{
$resolved = $this->url->resolve($relative);
return new self($resolved);
}
public function equals(Url $other, bool $includeFragment = false): bool
{
if (! $other instanceof self) {
return false;
}
if ($includeFragment) {
return $this->url->equals($other->url);
}
// Compare without fragments
$thisWithoutFragment = $this->url->withFragment(null);
$otherWithoutFragment = $other->url->withFragment(null);
return $thisWithoutFragment->equals($otherWithoutFragment);
}
public function getNativeUrl(): NativeWhatwgUrl
{
return $this->url;
}
/**
* String representation (allows string casting)
*/
public function __toString(): string
{
return $this->url->toUnicodeString();
}
}

View File

@@ -149,7 +149,7 @@ final readonly class CachePerformanceStorage implements PerformanceStorage
unset($predictionKeys[$i]);
continue;
}
// Convert timestamp back to DateTimeImmutable
if (is_int($prediction['timestamp'])) {
$prediction['timestamp'] = (new \DateTimeImmutable())->setTimestamp($prediction['timestamp']);
@@ -174,6 +174,104 @@ final readonly class CachePerformanceStorage implements PerformanceStorage
return $deletedCount;
}
public function getRecentPredictions(
string $modelName,
Version $version,
int $limit
): array {
$indexKey = $this->getPredictionsIndexKey($modelName, $version);
$result = $this->cache->get($indexKey);
$predictionKeys = $result->value ?? [];
if (empty($predictionKeys)) {
return [];
}
$predictions = [];
// Get predictions in reverse order (most recent first)
foreach (array_reverse($predictionKeys) as $keyString) {
if (count($predictions) >= $limit) {
break;
}
$predictionKey = CacheKey::fromString($keyString);
$result = $this->cache->get($predictionKey);
$prediction = $result->value;
if ($prediction === null) {
continue;
}
// Convert timestamp back to DateTimeImmutable
if (is_int($prediction['timestamp'])) {
$prediction['timestamp'] = (new \DateTimeImmutable())->setTimestamp($prediction['timestamp']);
}
$predictions[] = $prediction;
}
return $predictions;
}
public function calculateAccuracy(
string $modelName,
Version $version,
int $limit
): float {
$predictions = $this->getRecentPredictions($modelName, $version, $limit);
if (empty($predictions)) {
return 0.0;
}
$correctCount = 0;
$totalCount = 0;
foreach ($predictions as $prediction) {
// Only count predictions that have actual labels for accuracy calculation
if (!isset($prediction['actual_label'])) {
continue;
}
$totalCount++;
if (isset($prediction['predicted_label'])
&& $prediction['predicted_label'] === $prediction['actual_label']) {
$correctCount++;
}
}
if ($totalCount === 0) {
return 0.0;
}
return $correctCount / $totalCount;
}
public function getConfidenceBaseline(
string $modelName,
Version $version
): ?array {
$baselineKey = CacheKey::fromString(
self::CACHE_PREFIX . ":{$modelName}:{$version->toString()}:baseline"
);
$result = $this->cache->get($baselineKey);
$baseline = $result->value;
if ($baseline === null) {
return null;
}
return [
'avg_confidence' => $baseline['avg_confidence'],
'std_dev_confidence' => $baseline['std_dev_confidence'],
'stored_at' => $baseline['stored_at'],
];
}
/**
* Add prediction key to index
*/

View File

@@ -97,6 +97,86 @@ final class InMemoryPerformanceStorage implements PerformanceStorage
return $initialCount - count($this->predictions);
}
/**
* Get recent predictions with limit
*/
public function getRecentPredictions(
string $modelName,
Version $version,
int $limit
): array {
// Filter by model and version
$filtered = array_filter(
$this->predictions,
fn($record) =>
$record['model_name'] === $modelName
&& $record['version'] === $version->toString()
);
// Sort by timestamp descending (most recent first)
usort($filtered, fn($a, $b) => $b['timestamp'] <=> $a['timestamp']);
// Limit results
return array_values(array_slice($filtered, 0, $limit));
}
/**
* Calculate accuracy from recent predictions
*/
public function calculateAccuracy(
string $modelName,
Version $version,
int $limit
): float {
$predictions = $this->getRecentPredictions($modelName, $version, $limit);
if (empty($predictions)) {
return 0.0;
}
$correctCount = 0;
$totalCount = 0;
foreach ($predictions as $prediction) {
// Only count predictions that have actual labels
if (!isset($prediction['actual_label'])) {
continue;
}
$totalCount++;
if (isset($prediction['predicted_label'])
&& $prediction['predicted_label'] === $prediction['actual_label']) {
$correctCount++;
}
}
if ($totalCount === 0) {
return 0.0;
}
return $correctCount / $totalCount;
}
/**
* Get confidence baseline as array
*/
public function getConfidenceBaseline(
string $modelName,
Version $version
): ?array {
$key = $this->getBaselineKey($modelName, $version);
if (!isset($this->confidenceBaselines[$key])) {
return null;
}
return [
'avg_confidence' => $this->confidenceBaselines[$key]['avg'],
'std_dev_confidence' => $this->confidenceBaselines[$key]['stdDev'],
];
}
/**
* Get baseline key for confidence storage
*/

View File

@@ -0,0 +1,455 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mcp\Tools;
use App\Framework\HttpClient\HttpClient;
use App\Framework\HttpClient\HttpMethod;
use App\Framework\Mcp\McpTool;
/**
* Gitea Repository Management MCP Tools
*
* Provides AI-accessible Gitea API operations for repository management,
* SSH key setup, and deployment automation.
*/
final readonly class GiteaTools
{
public function __construct(
private HttpClient $httpClient,
private string $giteaUrl,
private string $giteaUsername,
private string $giteaPassword
) {
}
#[McpTool(
name: 'gitea_create_repository',
description: 'Create a new repository in Gitea'
)]
public function createRepository(
string $name,
string $description = '',
bool $private = true,
bool $autoInit = false,
string $defaultBranch = 'main'
): array {
$url = "{$this->giteaUrl}/api/v1/user/repos";
$data = [
'name' => $name,
'description' => $description,
'private' => $private,
'auto_init' => $autoInit,
'default_branch' => $defaultBranch,
];
$result = $this->makeRequest(HttpMethod::POST, $url, $data);
if ($result['success']) {
return [
'success' => true,
'repository' => [
'name' => $result['response']['name'] ?? $name,
'full_name' => $result['response']['full_name'] ?? "{$this->giteaUsername}/$name",
'clone_url' => $result['response']['clone_url'] ?? null,
'ssh_url' => $result['response']['ssh_url'] ?? null,
'html_url' => $result['response']['html_url'] ?? null,
'private' => $result['response']['private'] ?? $private,
'id' => $result['response']['id'] ?? null,
],
];
}
return $result;
}
#[McpTool(
name: 'gitea_list_repositories',
description: 'List all repositories for the authenticated user'
)]
public function listRepositories(): array
{
$url = "{$this->giteaUrl}/api/v1/user/repos";
$result = $this->makeRequest(HttpMethod::GET, $url);
if ($result['success']) {
$repos = array_map(function ($repo) {
return [
'name' => $repo['name'] ?? 'unknown',
'full_name' => $repo['full_name'] ?? 'unknown',
'description' => $repo['description'] ?? '',
'private' => $repo['private'] ?? false,
'clone_url' => $repo['clone_url'] ?? null,
'ssh_url' => $repo['ssh_url'] ?? null,
'html_url' => $repo['html_url'] ?? null,
];
}, $result['response'] ?? []);
return [
'success' => true,
'repositories' => $repos,
'count' => count($repos),
];
}
return $result;
}
#[McpTool(
name: 'gitea_get_repository',
description: 'Get details of a specific repository'
)]
public function getRepository(string $owner, string $repo): array
{
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo";
$result = $this->makeRequest(HttpMethod::GET, $url);
if ($result['success']) {
$repo = $result['response'];
return [
'success' => true,
'repository' => [
'name' => $repo['name'] ?? 'unknown',
'full_name' => $repo['full_name'] ?? 'unknown',
'description' => $repo['description'] ?? '',
'private' => $repo['private'] ?? false,
'clone_url' => $repo['clone_url'] ?? null,
'ssh_url' => $repo['ssh_url'] ?? null,
'html_url' => $repo['html_url'] ?? null,
'default_branch' => $repo['default_branch'] ?? 'main',
'created_at' => $repo['created_at'] ?? null,
'updated_at' => $repo['updated_at'] ?? null,
],
];
}
return $result;
}
#[McpTool(
name: 'gitea_delete_repository',
description: 'Delete a repository'
)]
public function deleteRepository(string $owner, string $repo): array
{
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo";
return $this->makeRequest(HttpMethod::DELETE, $url);
}
#[McpTool(
name: 'gitea_add_deploy_key',
description: 'Add an SSH deploy key to a repository'
)]
public function addDeployKey(
string $owner,
string $repo,
string $title,
string $key,
bool $readOnly = true
): array {
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys";
$data = [
'title' => $title,
'key' => $key,
'read_only' => $readOnly,
];
$result = $this->makeRequest(HttpMethod::POST, $url, $data);
if ($result['success']) {
return [
'success' => true,
'deploy_key' => [
'id' => $result['response']['id'] ?? null,
'title' => $result['response']['title'] ?? $title,
'key' => $result['response']['key'] ?? $key,
'read_only' => $result['response']['read_only'] ?? $readOnly,
'created_at' => $result['response']['created_at'] ?? null,
],
];
}
return $result;
}
#[McpTool(
name: 'gitea_list_deploy_keys',
description: 'List all deploy keys for a repository'
)]
public function listDeployKeys(string $owner, string $repo): array
{
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys";
$result = $this->makeRequest(HttpMethod::GET, $url);
if ($result['success']) {
$keys = array_map(function ($key) {
return [
'id' => $key['id'] ?? null,
'title' => $key['title'] ?? 'unknown',
'key' => $key['key'] ?? '',
'read_only' => $key['read_only'] ?? true,
'created_at' => $key['created_at'] ?? null,
];
}, $result['response'] ?? []);
return [
'success' => true,
'deploy_keys' => $keys,
'count' => count($keys),
];
}
return $result;
}
#[McpTool(
name: 'gitea_delete_deploy_key',
description: 'Delete a deploy key from a repository'
)]
public function deleteDeployKey(string $owner, string $repo, int $keyId): array
{
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys/$keyId";
return $this->makeRequest(HttpMethod::DELETE, $url);
}
#[McpTool(
name: 'gitea_add_user_ssh_key',
description: 'Add an SSH key to the authenticated user'
)]
public function addUserSshKey(string $title, string $key, bool $readOnly = false): array
{
$url = "{$this->giteaUrl}/api/v1/user/keys";
$data = [
'title' => $title,
'key' => $key,
'read_only' => $readOnly,
];
$result = $this->makeRequest(HttpMethod::POST, $url, $data);
if ($result['success']) {
return [
'success' => true,
'ssh_key' => [
'id' => $result['response']['id'] ?? null,
'title' => $result['response']['title'] ?? $title,
'key' => $result['response']['key'] ?? $key,
'read_only' => $result['response']['read_only'] ?? $readOnly,
'created_at' => $result['response']['created_at'] ?? null,
],
];
}
return $result;
}
#[McpTool(
name: 'gitea_list_user_ssh_keys',
description: 'List all SSH keys for the authenticated user'
)]
public function listUserSshKeys(): array
{
$url = "{$this->giteaUrl}/api/v1/user/keys";
$result = $this->makeRequest(HttpMethod::GET, $url);
if ($result['success']) {
$keys = array_map(function ($key) {
return [
'id' => $key['id'] ?? null,
'title' => $key['title'] ?? 'unknown',
'key' => $key['key'] ?? '',
'fingerprint' => $key['fingerprint'] ?? '',
'read_only' => $key['read_only'] ?? false,
'created_at' => $key['created_at'] ?? null,
];
}, $result['response'] ?? []);
return [
'success' => true,
'ssh_keys' => $keys,
'count' => count($keys),
];
}
return $result;
}
#[McpTool(
name: 'gitea_delete_user_ssh_key',
description: 'Delete an SSH key from the authenticated user'
)]
public function deleteUserSshKey(int $keyId): array
{
$url = "{$this->giteaUrl}/api/v1/user/keys/$keyId";
return $this->makeRequest(HttpMethod::DELETE, $url);
}
#[McpTool(
name: 'gitea_add_remote',
description: 'Add Gitea repository as git remote'
)]
public function addRemote(
string $remoteName,
string $owner,
string $repo,
bool $useSsh = true
): array {
// Get repository info first
$repoInfo = $this->getRepository($owner, $repo);
if (! $repoInfo['success']) {
return $repoInfo;
}
$url = $useSsh
? $repoInfo['repository']['ssh_url']
: $repoInfo['repository']['clone_url'];
if (! $url) {
return [
'success' => false,
'error' => 'Repository URL not found',
];
}
// Add remote via git command
$output = [];
$exitCode = 0;
$command = sprintf(
'git remote add %s %s 2>&1',
escapeshellarg($remoteName),
escapeshellarg($url)
);
exec($command, $output, $exitCode);
if ($exitCode !== 0) {
// Check if remote already exists
if (str_contains(implode("\n", $output), 'already exists')) {
return [
'success' => false,
'error' => 'Remote already exists',
'suggestion' => "Use 'git remote set-url $remoteName $url' to update",
];
}
return [
'success' => false,
'error' => 'Failed to add remote',
'output' => implode("\n", $output),
'exit_code' => $exitCode,
];
}
return [
'success' => true,
'remote_name' => $remoteName,
'url' => $url,
'use_ssh' => $useSsh,
];
}
#[McpTool(
name: 'gitea_webhook_create',
description: 'Create a webhook for a repository'
)]
public function createWebhook(
string $owner,
string $repo,
string $url,
string $contentType = 'json',
array $events = ['push'],
bool $active = true,
?string $secret = null
): array {
$hookUrl = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/hooks";
$data = [
'type' => 'gitea',
'config' => [
'url' => $url,
'content_type' => $contentType,
'secret' => $secret ?? '',
],
'events' => $events,
'active' => $active,
];
$result = $this->makeRequest(HttpMethod::POST, $hookUrl, $data);
if ($result['success']) {
return [
'success' => true,
'webhook' => [
'id' => $result['response']['id'] ?? null,
'url' => $result['response']['config']['url'] ?? $url,
'events' => $result['response']['events'] ?? $events,
'active' => $result['response']['active'] ?? $active,
'created_at' => $result['response']['created_at'] ?? null,
],
];
}
return $result;
}
// ==================== Private Helper Methods ====================
private function makeRequest(HttpMethod $method, string $url, ?array $data = null): array
{
try {
$options = [
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'Authorization' => 'Basic ' . base64_encode("{$this->giteaUsername}:{$this->giteaPassword}"),
],
'verify_ssl' => false, // For self-signed certificates
];
if ($data !== null) {
$options['json'] = $data;
}
$response = $this->httpClient->request($method, $url, $options);
$statusCode = $response->getStatusCode();
$body = $response->getBody();
// Decode JSON response
$decoded = json_decode($body, true);
if ($statusCode >= 200 && $statusCode < 300) {
return [
'success' => true,
'response' => $decoded,
'http_code' => $statusCode,
];
}
return [
'success' => false,
'error' => $decoded['message'] ?? 'HTTP error ' . $statusCode,
'response' => $decoded,
'http_code' => $statusCode,
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => 'Request failed: ' . $e->getMessage(),
'exception' => get_class($e),
];
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Framework\Mcp\Tools;
use App\Framework\Config\Environment;
use App\Framework\DI\Initializer;
use App\Framework\HttpClient\HttpClient;
/**
* Initializer for Gitea MCP Tools
*/
final readonly class GiteaToolsInitializer
{
public function __construct(
private HttpClient $httpClient,
private Environment $environment
) {
}
#[Initializer]
public function __invoke(): GiteaTools
{
// Get Gitea configuration from environment
$giteaUrl = $this->environment->get('GITEA_URL', 'https://localhost:9443');
$giteaUsername = $this->environment->get('GITEA_USERNAME', 'michael');
$giteaPassword = $this->environment->get('GITEA_PASSWORD', 'GiteaAdmin2024');
return new GiteaTools(
$this->httpClient,
$giteaUrl,
$giteaUsername,
$giteaPassword
);
}
}

View File

@@ -6,7 +6,8 @@ namespace App\Framework\Notification\Storage;
use App\Framework\Core\ValueObjects\Timestamp;
use App\Framework\Database\ConnectionInterface;
use App\Framework\Database\SqlQuery;
use App\Framework\Database\ValueObjects\SqlQuery;
use App\Framework\DI\Attributes\DefaultImplementation;
use App\Framework\Notification\Notification;
use App\Framework\Notification\ValueObjects\NotificationChannel;
use App\Framework\Notification\ValueObjects\NotificationId;
@@ -17,17 +18,16 @@ use App\Framework\Notification\ValueObjects\NotificationType;
/**
* Database implementation of NotificationRepository
*/
#[DefaultImplementation]
final readonly class DatabaseNotificationRepository implements NotificationRepository
{
public function __construct(
private ConnectionInterface $connection
) {
}
) {}
public function save(Notification $notification): void
{
$query = new SqlQuery(
sql: <<<'SQL'
$query = SqlQuery::create(<<<'SQL'
INSERT INTO notifications (
id, recipient_id, type, title, body, data,
channels, priority, status, created_at, sent_at,
@@ -38,7 +38,7 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
sent_at = EXCLUDED.sent_at,
read_at = EXCLUDED.read_at
SQL,
params: [
[
$notification->id->toString(),
$notification->recipientId,
$notification->type->toString(),
@@ -61,9 +61,9 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
public function findById(NotificationId $id): ?Notification
{
$query = new SqlQuery(
sql: 'SELECT * FROM notifications WHERE id = ?',
params: [$id->toString()]
$query = SqlQuery::create(
'SELECT * FROM notifications WHERE id = ?',
[$id->toString()]
);
$row = $this->connection->queryOne($query);
@@ -73,14 +73,14 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
public function findByUser(string $userId, int $limit = 20, int $offset = 0): array
{
$query = new SqlQuery(
sql: <<<'SQL'
$query = SqlQuery::create(
<<<'SQL'
SELECT * FROM notifications
WHERE recipient_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
SQL,
params: [$userId, $limit, $offset]
[$userId, $limit, $offset]
);
$rows = $this->connection->query($query)->fetchAll();
@@ -90,15 +90,15 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
public function findUnreadByUser(string $userId, int $limit = 20): array
{
$query = new SqlQuery(
sql: <<<'SQL'
$query = SqlQuery::create(
<<<'SQL'
SELECT * FROM notifications
WHERE recipient_id = ?
AND status != ?
ORDER BY created_at DESC
LIMIT ?
SQL,
params: [$userId, NotificationStatus::READ->value, $limit]
[$userId, NotificationStatus::READ->value, $limit]
);
$rows = $this->connection->query($query)->fetchAll();
@@ -108,13 +108,13 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
public function countUnreadByUser(string $userId): int
{
$query = new SqlQuery(
sql: <<<'SQL'
$query = SqlQuery::create(
<<<'SQL'
SELECT COUNT(*) as count FROM notifications
WHERE recipient_id = ?
AND status != ?
SQL,
params: [$userId, NotificationStatus::READ->value]
[$userId, NotificationStatus::READ->value]
);
return (int) $this->connection->queryScalar($query);
@@ -122,15 +122,15 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
public function markAsRead(NotificationId $id): bool
{
$query = new SqlQuery(
sql: <<<'SQL'
$query = SqlQuery::create(
<<<'SQL'
UPDATE notifications
SET status = ?, read_at = ?
WHERE id = ?
SQL,
params: [
[
NotificationStatus::READ->value,
(new Timestamp())->format('Y-m-d H:i:s'),
Timestamp::now()->format('Y-m-d H:i:s'),
$id->toString(),
]
);
@@ -140,16 +140,16 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
public function markAllAsReadForUser(string $userId): int
{
$query = new SqlQuery(
sql: <<<'SQL'
$query = SqlQuery::create(
<<<'SQL'
UPDATE notifications
SET status = ?, read_at = ?
WHERE recipient_id = ?
AND status != ?
SQL,
params: [
[
NotificationStatus::READ->value,
(new Timestamp())->format('Y-m-d H:i:s'),
Timestamp::now()->format('Y-m-d H:i:s'),
$userId,
NotificationStatus::READ->value,
]
@@ -160,9 +160,9 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
public function delete(NotificationId $id): bool
{
$query = new SqlQuery(
sql: 'DELETE FROM notifications WHERE id = ?',
params: [$id->toString()]
$query = SqlQuery::create(
'DELETE FROM notifications WHERE id = ?',
[$id->toString()]
);
return $this->connection->execute($query) > 0;
@@ -172,13 +172,13 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
{
$cutoffDate = (new Timestamp())->modify("-{$daysOld} days");
$query = new SqlQuery(
sql: <<<'SQL'
$query = SqlQuery::create(
<<<'SQL'
DELETE FROM notifications
WHERE status = ?
AND created_at < ?
SQL,
params: [
[
$status->value,
$cutoffDate->format('Y-m-d H:i:s'),
]
@@ -195,19 +195,19 @@ final readonly class DatabaseNotificationRepository implements NotificationRepos
);
return new Notification(
id: NotificationId::fromString($row['id']),
id : NotificationId::fromString($row['id']),
recipientId: $row['recipient_id'],
type: NotificationType::fromString($row['type']),
title: $row['title'],
body: $row['body'],
data: json_decode($row['data'], true) ?? [],
channels: $channels,
priority: NotificationPriority::from($row['priority']),
status: NotificationStatus::from($row['status']),
createdAt: Timestamp::fromString($row['created_at']),
sentAt: $row['sent_at'] ? Timestamp::fromString($row['sent_at']) : null,
readAt: $row['read_at'] ? Timestamp::fromString($row['read_at']) : null,
actionUrl: $row['action_url'],
type : NotificationType::fromString($row['type']),
title : $row['title'],
body : $row['body'],
createdAt : Timestamp::fromTimestamp((int) strtotime($row['created_at'])),
data : json_decode($row['data'], true) ?? [],
channels : $channels,
priority : NotificationPriority::from($row['priority']),
status : NotificationStatus::from($row['status']),
sentAt : $row['sent_at'] ? Timestamp::fromTimestamp((int) strtotime($row['sent_at'])) : null,
readAt : $row['read_at'] ? Timestamp::fromTimestamp((int) strtotime($row['read_at'])) : null,
actionUrl : $row['action_url'],
actionLabel: $row['action_label']
);
}

View File

@@ -45,10 +45,10 @@ final readonly class TemplateRenderer
// Create base notification
$notification = Notification::create(
recipientId: $recipientId,
type: $type,
title: $title,
body: $body,
$recipientId,
$type,
$title,
$body,
...$channels
)->withPriority($template->defaultPriority);

View File

@@ -7,7 +7,7 @@ namespace App\Framework\Notification\ValueObjects;
/**
* Type/Category of notification for user preferences and filtering
*/
final readonly class NotificationType
final readonly class NotificationType implements NotificationTypeInterface
{
private function __construct(
private string $value
@@ -57,4 +57,14 @@ final readonly class NotificationType
{
return $this->value === $other->value;
}
public function getDisplayName(): string
{
return $this->value;
}
public function isCritical(): bool
{
return false;
}
}

View File

@@ -16,17 +16,22 @@ use Throwable;
*/
final readonly class ExponentialBackoffStrategy implements RetryStrategy
{
private Duration $initialDelay;
private Duration $maxDelay;
public function __construct(
private int $maxAttempts = 3,
private Duration $initialDelay = new Duration(100), // 100ms
?Duration $initialDelay = null,
private float $multiplier = 2.0,
private Duration $maxDelay = new Duration(10000), // 10s
?Duration $maxDelay = null,
private bool $useJitter = true,
private array $retryableExceptions = [
\RuntimeException::class,
\Exception::class,
]
) {
$this->initialDelay = $initialDelay ?? Duration::fromMilliseconds(100);
$this->maxDelay = $maxDelay ?? Duration::fromSeconds(10);
}
public function shouldRetry(int $currentAttempt, Throwable $exception): bool

View File

@@ -35,6 +35,8 @@ enum AdminRoutes: string implements RouteNameInterface
case SYSTEM_PHPINFO = 'admin.system.phpinfo';
case SYSTEM_ENVIRONMENT = 'admin.system.environment';
case ML_DASHBOARD = 'admin.ml.dashboard';
public function getCategory(): RouteCategory
{
return RouteCategory::ADMIN;

View File

@@ -17,5 +17,5 @@ interface SerializableState
/**
* Create state from array (deserialization)
*/
public static function fromArray(array $data): static;
public static function fromArray(array $data): self;
}

View File

@@ -0,0 +1,330 @@
<?php
declare(strict_types=1);
namespace App\Framework\Template\Expression;
/**
* Evaluates template expressions with variable resolution and comparison operators
*
* Supports:
* - Simple variables: isLoggedIn, $count
* - Comparisons: $count > 0, $status === 'active'
* - Negations: !$is_empty, !isAdmin
* - Array access: $user['role'], $items[0]
* - Object properties: $user->isAdmin, $date->year
* - Logical operators: $count > 0 && $enabled, $a || $b
*/
final readonly class ExpressionEvaluator
{
/**
* Evaluates a conditional expression and returns boolean result
*/
public function evaluateCondition(string $expression, array $context): bool
{
$value = $this->evaluate($expression, $context);
return $this->isTruthy($value);
}
/**
* Evaluates an expression and returns the resolved value
*/
public function evaluate(string $expression, array $context): mixed
{
$expression = trim($expression);
// Handle logical OR (lowest precedence)
if (str_contains($expression, '||')) {
$parts = $this->splitByOperator($expression, '||');
foreach ($parts as $part) {
if ($this->evaluateCondition($part, $context)) {
return true;
}
}
return false;
}
// Handle logical AND
if (str_contains($expression, '&&')) {
$parts = $this->splitByOperator($expression, '&&');
foreach ($parts as $part) {
if (!$this->evaluateCondition($part, $context)) {
return false;
}
}
return true;
}
// Handle negation
if (str_starts_with($expression, '!')) {
$innerExpression = trim(substr($expression, 1));
return !$this->evaluateCondition($innerExpression, $context);
}
// Handle comparison operators
if (preg_match('/(.+?)\s*(===|!==|==|!=|<=|>=|<|>)\s*(.+)/', $expression, $matches)) {
$left = $this->resolveValue(trim($matches[1]), $context);
$operator = $matches[2];
$right = $this->resolveValue(trim($matches[3]), $context);
return $this->compareValues($left, $operator, $right);
}
// Simple value resolution
return $this->resolveValue($expression, $context);
}
/**
* Resolves a value from expression (variable, literal, array access, etc.)
*/
private function resolveValue(string $expression, array $context): mixed
{
$expression = trim($expression);
// String literals with quotes
if (preg_match('/^["\'](.*)["\']*$/', $expression, $matches)) {
return $matches[1];
}
// Numbers
if (is_numeric($expression)) {
return str_contains($expression, '.') ? (float) $expression : (int) $expression;
}
// Boolean literals
if ($expression === 'true') return true;
if ($expression === 'false') return false;
if ($expression === 'null') return null;
// Array access: $var['key'] or $var["key"] or $var[0]
if (preg_match('/^\$([a-zA-Z_][a-zA-Z0-9_]*)\[(["\']?)([^"\'\]]+)\2\]$/', $expression, $matches)) {
$varName = $matches[1];
$key = $matches[3];
if (!array_key_exists($varName, $context)) {
return null;
}
$value = $context[$varName];
// Numeric key
if (is_numeric($key)) {
$key = (int) $key;
}
if (is_array($value) && array_key_exists($key, $value)) {
return $value[$key];
}
if (is_object($value) && property_exists($value, $key)) {
return $value->$key;
}
return null;
}
// Object property access: $var->property
if (preg_match('/^\$([a-zA-Z_][a-zA-Z0-9_]*)->([a-zA-Z_][a-zA-Z0-9_]*)$/', $expression, $matches)) {
$varName = $matches[1];
$property = $matches[2];
if (!array_key_exists($varName, $context)) {
return null;
}
$value = $context[$varName];
if (is_object($value) && property_exists($value, $property)) {
return $value->$property;
}
if (is_array($value) && array_key_exists($property, $value)) {
return $value[$property];
}
return null;
}
// Simple variable with $: $varName
if (preg_match('/^\$([a-zA-Z_][a-zA-Z0-9_]*)$/', $expression, $matches)) {
$varName = $matches[1];
return array_key_exists($varName, $context) ? $context[$varName] : null;
}
// Dot notation support (backward compatibility): user.isAdmin, items.length
if (str_contains($expression, '.')) {
// Handle .length for arrays
if (str_ends_with($expression, '.length')) {
$basePath = substr($expression, 0, -7);
$value = $this->resolveDotNotation($context, $basePath);
if (is_array($value)) {
return count($value);
}
if (is_object($value) && method_exists($value, 'count')) {
return $value->count();
}
if (is_countable($value)) {
return count($value);
}
return 0;
}
// Handle method calls: collection.isEmpty()
if (str_contains($expression, '()')) {
$methodPos = strpos($expression, '()');
$basePath = substr($expression, 0, $methodPos);
$methodName = substr($basePath, strrpos($basePath, '.') + 1);
$objectPath = substr($basePath, 0, strrpos($basePath, '.'));
$object = $this->resolveDotNotation($context, $objectPath);
if (is_object($object) && method_exists($object, $methodName)) {
return $object->$methodName();
}
return null;
}
// Standard dot notation
return $this->resolveDotNotation($context, $expression);
}
// Simple variable without $: varName (for backward compatibility)
if (preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $expression)) {
return array_key_exists($expression, $context) ? $context[$expression] : null;
}
return null;
}
/**
* Resolves nested property paths using dot notation (e.g., "user.name", "items.0")
*/
private function resolveDotNotation(array $data, string $path): mixed
{
$keys = explode('.', $path);
$value = $data;
foreach ($keys as $key) {
if (is_array($value) && array_key_exists($key, $value)) {
$value = $value[$key];
} elseif (is_object($value) && isset($value->$key)) {
$value = $value->$key;
} else {
return null;
}
}
return $value;
}
/**
* Splits expression by operator, respecting quotes and parentheses
*/
private function splitByOperator(string $expression, string $operator): array
{
$parts = [];
$current = '';
$depth = 0;
$inQuotes = false;
$quoteChar = null;
$len = strlen($expression);
$opLen = strlen($operator);
for ($i = 0; $i < $len; $i++) {
$char = $expression[$i];
// Handle quotes
if (!$inQuotes && ($char === '"' || $char === "'")) {
$inQuotes = true;
$quoteChar = $char;
$current .= $char;
continue;
}
if ($inQuotes && $char === $quoteChar && ($i === 0 || $expression[$i-1] !== '\\')) {
$inQuotes = false;
$quoteChar = null;
$current .= $char;
continue;
}
// Handle parentheses depth
if (!$inQuotes) {
if ($char === '(') {
$depth++;
} elseif ($char === ')') {
$depth--;
}
}
// Check for operator at depth 0 and not in quotes
if (!$inQuotes && $depth === 0 && substr($expression, $i, $opLen) === $operator) {
$parts[] = trim($current);
$current = '';
$i += $opLen - 1; // Skip operator
continue;
}
$current .= $char;
}
if ($current !== '') {
$parts[] = trim($current);
}
return $parts;
}
/**
* Compares two values using an operator
*/
private function compareValues(mixed $left, string $operator, mixed $right): bool
{
return match ($operator) {
'===' => $left === $right,
'!==' => $left !== $right,
'==' => $left == $right,
'!=' => $left != $right,
'<' => $left < $right,
'>' => $left > $right,
'<=' => $left <= $right,
'>=' => $left >= $right,
default => false,
};
}
/**
* Determines if a value is truthy
*/
private function isTruthy(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_null($value)) {
return false;
}
if (is_string($value)) {
return trim($value) !== '';
}
if (is_numeric($value)) {
return $value != 0;
}
if (is_array($value)) {
return count($value) > 0;
}
if (is_object($value)) {
return true;
}
return (bool) $value;
}
}

View File

@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
namespace App\Framework\Template\Expression;
use App\Framework\View\RawHtml;
/**
* PlaceholderProcessor - Processes template placeholders using ExpressionEvaluator
*
* Replaces placeholders like {{ $var }}, {{ $var['key'] }}, {{ $obj->prop }} with actual values
* Uses ExpressionEvaluator for consistent expression evaluation across the framework
*
* Supports:
* - Simple variables: {{ $name }}, {{ name }}
* - Array access: {{ $user['email'] }}, {{ $items[0] }}
* - Object properties: {{ $user->name }}, {{ $date->format }}
* - Dot notation: {{ user.name }}, {{ items.0 }}
* - Expressions: {{ $count > 0 }}, {{ $user->isAdmin }}
*
* Framework Pattern: readonly class, composition with ExpressionEvaluator
*/
final readonly class PlaceholderProcessor
{
private ExpressionEvaluator $evaluator;
public function __construct()
{
$this->evaluator = new ExpressionEvaluator();
}
/**
* Replace all placeholders in HTML content
*
* @param string $html HTML content with placeholders
* @param array $context Variable context
* @return string HTML with replaced placeholders
*/
public function process(string $html, array $context): string
{
// Pattern matches {{ expression }} with optional whitespace
$pattern = '/{{\\s*(.+?)\\s*}}/';
return preg_replace_callback(
$pattern,
function ($matches) use ($context) {
$expression = $matches[1];
// Evaluate expression using ExpressionEvaluator
$value = $this->evaluator->evaluate($expression, $context);
// Format value for HTML output
return $this->formatValue($value);
},
$html
);
}
/**
* Replace placeholders for a specific loop variable
*
* Useful in foreach loops where we want to replace only the loop variable placeholders
* and leave other placeholders for later processing
*
* @param string $html HTML content
* @param string $varName Loop variable name (without $)
* @param mixed $item Loop item value
* @return string HTML with loop variable placeholders replaced
*/
public function processLoopVariable(string $html, string $varName, mixed $item): string
{
// Pattern 1: Array access {{ $varName['property'] }} or {{ varName['property'] }} ($ optional)
// We need to handle both escaped (&quot;, &#039;) and unescaped quotes
$arrayPatternDouble = '/{{\\s*\\$?' . preg_quote($varName, '/') . '\\[(?:&quot;|")([^"&]+?)(?:&quot;|")\\]\\s*}}/';
$arrayPatternSingle = '/{{\\s*\\$?' . preg_quote($varName, '/') . '\\[(?:&#039;|\')([^\'&]+?)(?:&#039;|\')\\]\\s*}}/';
// Pattern 2: Object property {{ $varName->property }} or {{ varName->property }} ($ optional)
$objectPattern = '/{{\\s*\\$?' . preg_quote($varName, '/') . '->([\\w]+)\\s*}}/';
// Pattern 3: Dot notation {{ varName.property }} or {{ $varName.property }} ($ already optional)
$dotPattern = '/{{\\s*\\$?' . preg_quote($varName, '/') . '\\.([\\w]+)\\s*}}/';
// Pattern 4: Simple variable {{ $varName }} or {{ varName }} ($ optional)
$simplePattern = '/{{\\s*\\$?' . preg_quote($varName, '/') . '\\s*}}/';
// Replace in order: array access (double quotes), array access (single quotes), object property, dot notation, simple variable
$html = preg_replace_callback(
$arrayPatternDouble,
function($matches) use ($item) {
return $this->formatValue($this->getProperty($item, $matches[1]));
},
$html
);
$html = preg_replace_callback(
$arrayPatternSingle,
function($matches) use ($item) {
return $this->formatValue($this->getProperty($item, $matches[1]));
},
$html
);
$html = preg_replace_callback(
$objectPattern,
function($matches) use ($item) {
return $this->formatValue($this->getProperty($item, $matches[1]));
},
$html
);
$html = preg_replace_callback(
$dotPattern,
function($matches) use ($item) {
return $this->formatValue($this->getProperty($item, $matches[1]));
},
$html
);
$html = preg_replace_callback(
$simplePattern,
function($matches) use ($item) {
return $this->formatValue($item);
},
$html
);
return $html;
}
/**
* Get property value from item (array or object)
*/
private function getProperty(mixed $item, string $property): mixed
{
if (is_array($item) && array_key_exists($property, $item)) {
return $item[$property];
}
if (is_object($item) && isset($item->$property)) {
return $item->$property;
}
return null;
}
/**
* Format value for HTML output
*/
private function formatValue(mixed $value): string
{
if ($value === null) {
return '';
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if ($value instanceof RawHtml) {
return $value->content;
}
if (is_array($value) || is_object($value)) {
// Don't render complex types, return empty string
return '';
}
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
}

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Framework\UserAgent;
use App\Framework\Core\ValueObjects\Version;
use App\Framework\UserAgent\Enums\BrowserType;
use App\Framework\UserAgent\Enums\EngineType;
use App\Framework\UserAgent\Enums\PlatformType;
use App\Framework\UserAgent\ValueObjects\DeviceCategory;
/**
* Value Object representing a parsed User-Agent with rich metadata
@@ -17,11 +19,11 @@ final readonly class ParsedUserAgent
public function __construct(
public string $raw,
public BrowserType $browser,
public string $browserVersion,
public Version $browserVersion,
public PlatformType $platform,
public string $platformVersion,
public Version $platformVersion,
public EngineType $engine,
public string $engineVersion,
public Version $engineVersion,
public bool $isMobile,
public bool $isBot,
public bool $isModern
@@ -41,11 +43,7 @@ final readonly class ParsedUserAgent
*/
public function getBrowserName(): string
{
if ($this->browserVersion === 'Unknown') {
return $this->browser->getDisplayName();
}
return $this->browser->getDisplayName() . ' ' . $this->browserVersion;
return $this->browser->getDisplayName() . ' ' . $this->browserVersion->toString();
}
/**
@@ -53,11 +51,7 @@ final readonly class ParsedUserAgent
*/
public function getPlatformName(): string
{
if ($this->platformVersion === 'Unknown') {
return $this->platform->getDisplayName();
}
return $this->platform->getDisplayName() . ' ' . $this->platformVersion;
return $this->platform->getDisplayName() . ' ' . $this->platformVersion->toString();
}
/**
@@ -65,11 +59,7 @@ final readonly class ParsedUserAgent
*/
public function getEngineName(): string
{
if ($this->engineVersion === 'Unknown') {
return $this->engine->getDisplayName();
}
return $this->engine->getDisplayName() . ' ' . $this->engineVersion;
return $this->engine->getDisplayName() . ' ' . $this->engineVersion->toString();
}
/**
@@ -104,16 +94,18 @@ final readonly class ParsedUserAgent
return match ($feature) {
// Image formats
'webp' => $this->browser->getEngine() === EngineType::BLINK ||
($this->browser === BrowserType::FIREFOX && version_compare($this->browserVersion, '65.0', '>=')),
($this->browser === BrowserType::FIREFOX &&
$this->browserVersion->isNewerThan(Version::fromString('65.0')) ||
$this->browserVersion->equals(Version::fromString('65.0'))),
'avif' => $this->browser->getEngine() === EngineType::BLINK &&
version_compare($this->browserVersion, '85.0', '>='),
($this->browserVersion->isNewerThan(Version::fromString('85.0')) ||
$this->browserVersion->equals(Version::fromString('85.0'))),
// JavaScript features
'es6', 'css-custom-properties', 'css-flexbox', 'css-grid', 'webrtc', 'websockets' => $this->isModern,
'es2017' => $this->isModern && version_compare($this->browserVersion, $this->getEs2017MinVersion(), '>='),
'es2020' => $this->isModern && version_compare($this->browserVersion, $this->getEs2020MinVersion(), '>='),
'es2017' => $this->isModern && $this->supportsEs2017(),
'es2020' => $this->isModern && $this->supportsEs2020(),
// CSS features
// Web APIs
'service-worker' => $this->isModern && $this->platform !== PlatformType::IOS,
'web-push' => $this->isModern && $this->browser !== BrowserType::SAFARI,
@@ -122,54 +114,80 @@ final readonly class ParsedUserAgent
};
}
/**
* Check if browser supports ES2017
*/
private function supportsEs2017(): bool
{
$minVersion = $this->getEs2017MinVersion();
return $this->browserVersion->isNewerThan($minVersion) ||
$this->browserVersion->equals($minVersion);
}
/**
* Check if browser supports ES2020
*/
private function supportsEs2020(): bool
{
$minVersion = $this->getEs2020MinVersion();
return $this->browserVersion->isNewerThan($minVersion) ||
$this->browserVersion->equals($minVersion);
}
/**
* Get minimum browser version for ES2017 support
*/
private function getEs2017MinVersion(): string
private function getEs2017MinVersion(): Version
{
return match ($this->browser) {
BrowserType::CHROME => '58.0',
BrowserType::FIREFOX => '52.0',
BrowserType::SAFARI => '10.1',
BrowserType::EDGE => '79.0',
BrowserType::OPERA => '45.0',
default => '999.0'
$versionString = match ($this->browser) {
BrowserType::CHROME => '58.0.0',
BrowserType::FIREFOX => '52.0.0',
BrowserType::SAFARI => '10.1.0',
BrowserType::EDGE => '79.0.0',
BrowserType::OPERA => '45.0.0',
default => '999.0.0'
};
return Version::fromString($versionString);
}
/**
* Get minimum browser version for ES2020 support
*/
private function getEs2020MinVersion(): string
private function getEs2020MinVersion(): Version
{
return match ($this->browser) {
BrowserType::CHROME => '80.0',
BrowserType::FIREFOX => '72.0',
BrowserType::SAFARI => '13.1',
BrowserType::EDGE => '80.0',
BrowserType::OPERA => '67.0',
default => '999.0'
$versionString = match ($this->browser) {
BrowserType::CHROME => '80.0.0',
BrowserType::FIREFOX => '72.0.0',
BrowserType::SAFARI => '13.1.0',
BrowserType::EDGE => '80.0.0',
BrowserType::OPERA => '67.0.0',
default => '999.0.0'
};
return Version::fromString($versionString);
}
/**
* Get device category
*/
public function getDeviceCategory(): string
public function getDeviceCategory(): DeviceCategory
{
if ($this->isBot) {
return 'bot';
return DeviceCategory::BOT;
}
if ($this->platform->isMobile()) {
return 'mobile';
return DeviceCategory::MOBILE;
}
if ($this->platform->isDesktop()) {
return 'desktop';
return DeviceCategory::DESKTOP;
}
return 'unknown';
return DeviceCategory::UNKNOWN;
}
/**
@@ -183,20 +201,20 @@ final readonly class ParsedUserAgent
'browser' => [
'type' => $this->browser->value,
'name' => $this->browser->getDisplayName(),
'version' => $this->browserVersion,
'version' => $this->browserVersion->toString(),
'fullName' => $this->getBrowserName(),
],
'platform' => [
'type' => $this->platform->value,
'name' => $this->platform->getDisplayName(),
'version' => $this->platformVersion,
'version' => $this->platformVersion->toString(),
'fullName' => $this->getPlatformName(),
'family' => $this->platform->getFamily(),
],
'engine' => [
'type' => $this->engine->value,
'name' => $this->engine->getDisplayName(),
'version' => $this->engineVersion,
'version' => $this->engineVersion->toString(),
'fullName' => $this->getEngineName(),
'developer' => $this->engine->getDeveloper(),
],
@@ -205,7 +223,7 @@ final readonly class ParsedUserAgent
'isBot' => $this->isBot,
'isModern' => $this->isModern,
],
'deviceCategory' => $this->getDeviceCategory(),
'deviceCategory' => $this->getDeviceCategory()->value,
'summary' => $this->getSummary(),
];
}

View File

@@ -5,6 +5,11 @@ declare(strict_types=1);
namespace App\Framework\UserAgent;
use App\Framework\Cache\Cache;
use App\Framework\Cache\CacheKey;
use App\Framework\Core\ValueObjects\Duration;
use App\Framework\Core\ValueObjects\Hash;
use App\Framework\Core\ValueObjects\HashAlgorithm;
use App\Framework\Core\ValueObjects\Version;
use App\Framework\UserAgent\Enums\BrowserType;
use App\Framework\UserAgent\Enums\EngineType;
use App\Framework\UserAgent\Enums\PlatformType;
@@ -20,7 +25,8 @@ final readonly class UserAgentParser
{
public function __construct(
private ?Cache $cache = null
) {}
) {
}
/**
* Parse User-Agent string into structured ParsedUserAgent object
@@ -34,8 +40,9 @@ final readonly class UserAgentParser
return $this->createUnknownUserAgent('');
}
// Check cache first
$cacheKey = 'useragent:' . md5($normalized);
// Check cache first (using framework's Hash VO with fast algorithm)
$hash = Hash::create($normalized, HashAlgorithm::fast());
$cacheKey = CacheKey::fromString('useragent:' . $hash->toString());
if ($this->cache) {
$cached = $this->cache->get($cacheKey);
if ($cached instanceof ParsedUserAgent) {
@@ -67,9 +74,9 @@ final readonly class UserAgentParser
isModern: $isModern
);
// Cache result
// Cache result for 1 hour
if ($this->cache) {
$this->cache->set($cacheKey, $parsedUserAgent, 3600); // Cache for 1 hour
$this->cache->set($cacheKey, $parsedUserAgent, Duration::fromHours(1));
}
return $parsedUserAgent;
@@ -99,16 +106,18 @@ final readonly class UserAgentParser
/**
* Parse browser version
*/
private function parseBrowserVersion(string $userAgent, BrowserType $browser): string
private function parseBrowserVersion(string $userAgent, BrowserType $browser): Version
{
// Find matching pattern for this browser
foreach (BrowserPatterns::getPatterns() as $pattern) {
if ($pattern['browser'] === $browser && preg_match($pattern['versionPattern'], $userAgent, $matches)) {
return $matches[1] ?? 'Unknown';
$versionString = $matches[1] ?? '0.0.0';
return $this->parseVersion($versionString);
}
}
return 'Unknown';
return Version::fromString('0.0.0');
}
/**
@@ -128,25 +137,27 @@ final readonly class UserAgentParser
/**
* Parse platform version
*/
private function parsePlatformVersion(string $userAgent, PlatformType $platform): string
private function parsePlatformVersion(string $userAgent, PlatformType $platform): Version
{
foreach (PlatformPatterns::getPatterns() as $pattern) {
if ($pattern['platform'] === $platform &&
! empty($pattern['versionPattern']) &&
preg_match($pattern['versionPattern'], $userAgent, $matches)) {
$version = $matches[1] ?? 'Unknown';
$version = $matches[1] ?? '0.0.0';
// Format version based on platform
return match ($platform) {
$formattedVersion = match ($platform) {
PlatformType::WINDOWS => PlatformPatterns::formatWindowsVersion($version),
PlatformType::MACOS, PlatformType::IOS => PlatformPatterns::formatAppleVersion($version),
default => $version
};
return $this->parseVersion($formattedVersion);
}
}
return 'Unknown';
return Version::fromString('0.0.0');
}
/**
@@ -170,30 +181,32 @@ final readonly class UserAgentParser
/**
* Parse engine version
*/
private function parseEngineVersion(string $userAgent, EngineType $engine): string
private function parseEngineVersion(string $userAgent, EngineType $engine): Version
{
foreach (EnginePatterns::getPatterns() as $pattern) {
if ($pattern['engine'] === $engine && preg_match($pattern['versionPattern'], $userAgent, $matches)) {
$version = $matches[1] ?? 'Unknown';
$version = $matches[1] ?? '0.0.0';
// Special formatting for Gecko
if ($engine === EngineType::GECKO) {
return EnginePatterns::formatGeckoVersion($version);
$formattedVersion = EnginePatterns::formatGeckoVersion($version);
return $this->parseVersion($formattedVersion);
}
return $version;
return $this->parseVersion($version);
}
}
return 'Unknown';
return Version::fromString('0.0.0');
}
/**
* Determine if browser is considered modern
*/
private function determineModernBrowser(BrowserType $browser, string $version, bool $isBot): bool
private function determineModernBrowser(BrowserType $browser, Version $version, bool $isBot): bool
{
if ($isBot || $version === 'Unknown') {
if ($isBot) {
return false;
}
@@ -201,9 +214,9 @@ final readonly class UserAgentParser
return false;
}
$threshold = $browser->getModernVersionThreshold();
$threshold = Version::fromString($browser->getModernVersionThreshold());
return version_compare($version, $threshold, '>=');
return $version->isNewerThan($threshold) || $version->equals($threshold);
}
/**
@@ -214,17 +227,48 @@ final readonly class UserAgentParser
return new ParsedUserAgent(
raw: $raw,
browser: BrowserType::UNKNOWN,
browserVersion: 'Unknown',
browserVersion: Version::fromString('0.0.0'),
platform: PlatformType::UNKNOWN,
platformVersion: 'Unknown',
platformVersion: Version::fromString('0.0.0'),
engine: EngineType::UNKNOWN,
engineVersion: 'Unknown',
engineVersion: Version::fromString('0.0.0'),
isMobile: false,
isBot: false,
isModern: false
);
}
/**
* Parse version string into Version Value Object
* Handles various version formats from User-Agent strings
*/
private function parseVersion(string $versionString): Version
{
// Normalize version string
$normalized = trim($versionString);
if ($normalized === '' || $normalized === 'Unknown') {
return Version::fromString('0.0.0');
}
// Try to parse as semver
try {
return Version::fromString($normalized);
} catch (\InvalidArgumentException $e) {
// If parsing fails, try to extract major.minor.patch from string
if (preg_match('/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/', $normalized, $matches)) {
$major = (int) $matches[1];
$minor = isset($matches[2]) ? (int) $matches[2] : 0;
$patch = isset($matches[3]) ? (int) $matches[3] : 0;
return Version::fromComponents($major, $minor, $patch);
}
// Fallback to 0.0.0 if we can't parse
return Version::fromString('0.0.0');
}
}
/**
* Clear parser cache
*/

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Framework\UserAgent\ValueObjects;
/**
* Value Object representing device category
*/
enum DeviceCategory: string
{
case BOT = 'bot';
case MOBILE = 'mobile';
case DESKTOP = 'desktop';
case TABLET = 'tablet';
case UNKNOWN = 'unknown';
/**
* Get human-readable display name
*/
public function getDisplayName(): string
{
return match ($this) {
self::BOT => 'Bot',
self::MOBILE => 'Mobile Device',
self::DESKTOP => 'Desktop Computer',
self::TABLET => 'Tablet',
self::UNKNOWN => 'Unknown Device',
};
}
/**
* Check if device is mobile (includes tablets)
*/
public function isMobile(): bool
{
return match ($this) {
self::MOBILE, self::TABLET => true,
default => false,
};
}
/**
* Check if device is desktop
*/
public function isDesktop(): bool
{
return $this === self::DESKTOP;
}
/**
* Check if device is a bot
*/
public function isBot(): bool
{
return $this === self::BOT;
}
}

View File

@@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace App\Framework\View\Dom\Transformer;
use App\Framework\DI\Container;
use App\Framework\Meta\MetaData;
use App\Framework\Template\Expression\PlaceholderProcessor;
use App\Framework\Template\Processing\AstTransformer;
use App\Framework\View\Dom\DocumentNode;
use App\Framework\View\Dom\ElementNode;
use App\Framework\View\Dom\Node;
use App\Framework\View\Dom\TextNode;
use App\Framework\View\Processors\PlaceholderReplacer;
use App\Framework\View\RenderContext;
/**
* ForTransformer - AST-based foreach loop processor using PlaceholderProcessor
*
* Processes:
* - foreach attributes: <div foreach="$items as $item">
* - <for> elements: <for items="items" as="item">
*
* Uses PlaceholderProcessor for consistent placeholder replacement with ExpressionEvaluator
*/
final readonly class ForTransformer implements AstTransformer
{
private PlaceholderProcessor $placeholderProcessor;
public function __construct(
private Container $container
) {
$this->placeholderProcessor = new PlaceholderProcessor();
}
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
{
$this->processForLoops($document, $context);
return $document;
}
private function processForLoops(Node $node, RenderContext $context): void
{
if (!$node instanceof ElementNode && !$node instanceof DocumentNode) {
return;
}
// Process children first (depth-first for nested loops)
$children = $node->getChildren();
foreach ($children as $child) {
$this->processForLoops($child, $context);
}
// Process foreach attribute on this element
if ($node instanceof ElementNode && $node->hasAttribute('foreach')) {
$this->processForeachAttribute($node, $context);
}
// Process <for> elements
if ($node instanceof ElementNode && $node->getTagName() === 'for') {
$this->processForElement($node, $context);
}
}
/**
* Process foreach attribute: <div foreach="$items as $item">
*/
private function processForeachAttribute(ElementNode $node, RenderContext $context): void
{
$foreachExpr = $node->getAttribute('foreach');
// Parse "array as var" syntax (with or without $ prefix)
if (!preg_match('/^\$?(\w+)\s+as\s+\$?(\w+)$/', $foreachExpr, $matches)) {
return; // Invalid syntax
}
$dataKey = $matches[1];
$varName = $matches[2];
// Remove foreach attribute
$node->removeAttribute('foreach');
// Resolve items from context
$items = $this->resolveValue($context->data, $dataKey);
if (!is_iterable($items)) {
// Remove element if not iterable
$parent = $node->getParent();
if ($parent instanceof ElementNode || $parent instanceof DocumentNode) {
$parent->removeChild($node);
}
return;
}
// Get parent and position
$parent = $node->getParent();
if (!($parent instanceof ElementNode || $parent instanceof DocumentNode)) {
return;
}
// Clone and process for each item
$fragments = [];
foreach ($items as $item) {
$clone = $node->clone();
// Process placeholders in cloned element
$this->replacePlaceholdersInNode($clone, $varName, $item);
$fragments[] = $clone;
}
// Replace original node with all fragments
$parent->removeChild($node);
foreach ($fragments as $fragment) {
$parent->appendChild($fragment);
}
}
/**
* Process <for> element: <for items="items" as="item">
*/
private function processForElement(ElementNode $node, RenderContext $context): void
{
// Support both syntaxes
$dataKey = $node->getAttribute('items') ?? $node->getAttribute('in');
$varName = $node->getAttribute('as') ?? $node->getAttribute('var');
if (!$dataKey || !$varName) {
return; // Invalid syntax
}
// Resolve items from context
$items = $this->resolveValue($context->data, $dataKey);
if (!is_iterable($items)) {
// Remove element if not iterable
$parent = $node->getParent();
if ($parent instanceof ElementNode || $parent instanceof DocumentNode) {
$parent->removeChild($node);
}
return;
}
// Get parent and position
$parent = $node->getParent();
if (!($parent instanceof ElementNode || $parent instanceof DocumentNode)) {
return;
}
// Process children for each item
$fragments = [];
foreach ($items as $item) {
foreach ($node->getChildren() as $child) {
$clone = $child->clone();
// Process placeholders
$this->replacePlaceholdersInNode($clone, $varName, $item);
$fragments[] = $clone;
}
}
// Replace <for> element with processed fragments
$parent->removeChild($node);
foreach ($fragments as $fragment) {
$parent->appendChild($fragment);
}
}
/**
* Replace placeholders in a node and its children using PlaceholderProcessor
*/
private function replacePlaceholdersInNode(Node $node, string $varName, mixed $item): void
{
if ($node instanceof TextNode) {
// Process text content with PlaceholderProcessor
$node->setText(
$this->placeholderProcessor->processLoopVariable($node->getTextContent(), $varName, $item)
);
return;
}
if ($node instanceof ElementNode) {
// Process attributes - HTML decode first to handle entity-encoded quotes
foreach (array_keys($node->getAttributes()) as $attrName) {
$attrValue = $node->getAttribute($attrName);
if ($attrValue !== null) {
// Decode HTML entities (&#039; -> ', &quot; -> ")
$decodedValue = html_entity_decode($attrValue, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Process placeholders with decoded value
$processedValue = $this->placeholderProcessor->processLoopVariable($decodedValue, $varName, $item);
// Set the processed value (will be re-encoded if needed during rendering)
$node->setAttribute($attrName, $processedValue);
}
}
// Process children recursively
foreach ($node->getChildren() as $child) {
$this->replacePlaceholdersInNode($child, $varName, $item);
}
}
}
/**
* Resolve nested property paths like "redis.key_sample"
*/
private function resolveValue(array $data, string $expr): mixed
{
$keys = explode('.', $expr);
$value = $data;
foreach ($keys as $key) {
if (is_array($value) && array_key_exists($key, $value)) {
$value = $value[$key];
} elseif (is_object($value)) {
if (isset($value->$key)) {
$value = $value->$key;
} elseif (method_exists($value, $key)) {
$value = $value->$key();
} elseif (method_exists($value, 'get' . ucfirst($key))) {
$getterMethod = 'get' . ucfirst($key);
$value = $value->$getterMethod();
} else {
return null;
}
} else {
return null;
}
}
return $value;
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Framework\View\Dom\Transformer;
use App\Framework\Template\Expression\ExpressionEvaluator;
use App\Framework\Template\Processing\AstTransformer;
use App\Framework\View\Dom\DocumentNode;
use App\Framework\View\Dom\ElementNode;
@@ -19,17 +20,24 @@ use App\Framework\View\RenderContext;
* - Removes attribute if condition is truthy
*
* Supports:
* - Simple properties: if="user.isAdmin"
* - Dollar syntax: if="$count > 0", if="$user->isAdmin"
* - Dot notation (legacy): if="user.isAdmin", if="items.length > 0"
* - Comparisons: if="count > 5", if="status == 'active'"
* - Logical operators: if="user.isAdmin && user.isVerified"
* - Negation: if="!user.isBanned"
* - Array properties: if="items.length > 0"
* - Negation: if="!$user->isBanned", if="!user.isAdmin"
* - Array access: if="$user['role'] === 'admin'"
* - Method calls: if="collection.isEmpty()"
*
* Framework Pattern: readonly class, AST-based transformation
* Framework Pattern: readonly class, AST-based transformation, composition with ExpressionEvaluator
*/
final readonly class IfTransformer implements AstTransformer
{
private ExpressionEvaluator $evaluator;
public function __construct()
{
$this->evaluator = new ExpressionEvaluator();
}
public function transform(DocumentNode $document, RenderContext $context): DocumentNode
{
// Process both 'if' and 'condition' attributes
@@ -81,180 +89,10 @@ final readonly class IfTransformer implements AstTransformer
}
/**
* Evaluates condition expression with support for operators
* Evaluates condition expression using ExpressionEvaluator
*/
private function evaluateCondition(array $data, string $condition): bool
{
$condition = trim($condition);
// Handle logical operators (&&, ||)
if (str_contains($condition, '&&')) {
$parts = array_map('trim', explode('&&', $condition));
foreach ($parts as $part) {
if (! $this->evaluateCondition($data, $part)) {
return false;
}
}
return true;
}
if (str_contains($condition, '||')) {
$parts = array_map('trim', explode('||', $condition));
foreach ($parts as $part) {
if ($this->evaluateCondition($data, $part)) {
return true;
}
}
return false;
}
// Handle negation (!)
if (str_starts_with($condition, '!')) {
$negatedCondition = trim(substr($condition, 1));
return ! $this->evaluateCondition($data, $negatedCondition);
}
// Handle comparison operators
foreach (['!=', '==', '>=', '<=', '>', '<'] as $operator) {
if (str_contains($condition, $operator)) {
[$left, $right] = array_map('trim', explode($operator, $condition, 2));
$leftValue = $this->parseValue($data, $left);
$rightValue = $this->parseValue($data, $right);
return match ($operator) {
'!=' => $leftValue != $rightValue,
'==' => $leftValue == $rightValue,
'>=' => $leftValue >= $rightValue,
'<=' => $leftValue <= $rightValue,
'>' => $leftValue > $rightValue,
'<' => $leftValue < $rightValue,
};
}
}
// Simple property evaluation
$value = $this->resolveValue($data, $condition);
return $this->isTruthy($value);
}
/**
* Parse value from expression (property path, string literal, or number)
*/
private function parseValue(array $data, string $expr): mixed
{
$expr = trim($expr);
// String literal (quoted)
if ((str_starts_with($expr, '"') && str_ends_with($expr, '"')) ||
(str_starts_with($expr, "'") && str_ends_with($expr, "'"))) {
return substr($expr, 1, -1);
}
// Number literal
if (is_numeric($expr)) {
return str_contains($expr, '.') ? (float) $expr : (int) $expr;
}
// Boolean literals
if ($expr === 'true') {
return true;
}
if ($expr === 'false') {
return false;
}
if ($expr === 'null') {
return null;
}
// Property path
return $this->resolveComplexValue($data, $expr);
}
/**
* Resolves complex expressions including method calls and array properties
*/
private function resolveComplexValue(array $data, string $expr): mixed
{
// Handle method calls like isEmpty()
if (str_contains($expr, '()')) {
$methodPos = strpos($expr, '()');
$basePath = substr($expr, 0, $methodPos);
$methodName = substr($basePath, strrpos($basePath, '.') + 1);
$objectPath = substr($basePath, 0, strrpos($basePath, '.'));
$object = $this->resolveValue($data, $objectPath);
if (is_object($object) && method_exists($object, $methodName)) {
return $object->$methodName();
}
return null;
}
// Handle .length property for arrays
if (str_ends_with($expr, '.length')) {
$basePath = substr($expr, 0, -7);
$value = $this->resolveValue($data, $basePath);
if (is_array($value)) {
return count($value);
}
if (is_object($value) && method_exists($value, 'count')) {
return $value->count();
}
if (is_countable($value)) {
return count($value);
}
return 0;
}
// Standard property path resolution
return $this->resolveValue($data, $expr);
}
/**
* Resolves nested property paths like "performance.opcacheMemoryUsage"
*/
private function resolveValue(array $data, string $expr): mixed
{
$keys = explode('.', $expr);
$value = $data;
foreach ($keys as $key) {
if (is_array($value) && array_key_exists($key, $value)) {
$value = $value[$key];
} elseif (is_object($value) && isset($value->$key)) {
$value = $value->$key;
} else {
return null;
}
}
return $value;
}
/**
* Check if value is truthy
*/
private function isTruthy(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_null($value)) {
return false;
}
if (is_string($value)) {
return trim($value) !== '';
}
if (is_numeric($value)) {
return $value != 0;
}
if (is_array($value)) {
return count($value) > 0;
}
return true;
return $this->evaluator->evaluateCondition($condition, $data);
}
}

View File

@@ -6,16 +6,19 @@ namespace App\Framework\View\Processors;
use App\Framework\DI\Container;
use App\Framework\Meta\MetaData;
use App\Framework\Template\Expression\PlaceholderProcessor;
use App\Framework\Template\Processing\DomProcessor;
use App\Framework\View\DomWrapper;
use App\Framework\View\RawHtml;
use App\Framework\View\RenderContext;
final class ForProcessor implements DomProcessor
{
private PlaceholderProcessor $placeholderProcessor;
public function __construct(
private Container $container,
) {
$this->placeholderProcessor = new PlaceholderProcessor();
}
public function process(DomWrapper $dom, RenderContext $context): DomWrapper
@@ -40,8 +43,11 @@ final class ForProcessor implements DomProcessor
$forNodesOld = $dom->document->querySelectorAll('for[var][in]');
$forNodesNew = $dom->document->querySelectorAll('for[items][as]');
// Support foreach attribute on any element: <tr foreach="$models as $model">
$foreachNodes = $dom->document->querySelectorAll('[foreach]');
// Merge both nodesets
// Merge all nodesets
$forNodes = [];
foreach ($forNodesOld as $node) {
$forNodes[] = $node;
@@ -49,11 +55,29 @@ final class ForProcessor implements DomProcessor
foreach ($forNodesNew as $node) {
$forNodes[] = $node;
}
foreach ($foreachNodes as $node) {
$forNodes[] = $node;
}
foreach ($forNodes as $node) {
// Detect which syntax is being used
if ($node->hasAttribute('items') && $node->hasAttribute('as')) {
if ($node->hasAttribute('foreach')) {
// foreach attribute syntax: <tr foreach="$models as $model">
$foreachExpr = $node->getAttribute('foreach');
// Parse "array as var" syntax (with or without $ prefix)
if (preg_match('/^\$?(\w+)\s+as\s+\$?(\w+)$/', $foreachExpr, $matches)) {
$in = $matches[1];
$var = $matches[2];
} else {
// Invalid foreach syntax, skip this node
continue;
}
// Remove foreach attribute from element
$node->removeAttribute('foreach');
} elseif ($node->hasAttribute('items') && $node->hasAttribute('as')) {
// New syntax: <for items="arrayName" as="itemVar">
$in = $node->getAttribute('items');
$var = $node->getAttribute('as');
@@ -64,6 +88,10 @@ final class ForProcessor implements DomProcessor
}
$output = '';
// Check if this was a foreach attribute (already removed)
// We detect this by checking if node is NOT a <for> element
$isForeachAttribute = !in_array(strtolower($node->tagName), ['for']);
// Resolve items from context data or model
$items = $this->resolveValue($context->data, $in);
@@ -88,16 +116,23 @@ final class ForProcessor implements DomProcessor
controllerClass: $context->controllerClass
);
// Get innerHTML from cloned node
$innerHTML = $clone->innerHTML;
// For foreach attribute: process the entire element
// For <for> element: process only innerHTML
if ($isForeachAttribute) {
// Process entire element (e.g., <tr>)
$innerHTML = $clone->outerHTML;
} else {
// Get innerHTML from cloned node
$innerHTML = $clone->innerHTML;
// Handle case where DOM parser treats <for> as self-closing
if (trim($innerHTML) === '') {
$innerHTML = $this->collectSiblingContent($node, $dom);
// Handle case where DOM parser treats <for> as self-closing
if (trim($innerHTML) === '') {
$innerHTML = $this->collectSiblingContent($node, $dom);
}
}
// Replace loop variable placeholders
$innerHTML = $this->replaceLoopVariables($innerHTML, $var, $item);
// Replace loop variable placeholders using PlaceholderProcessor
$innerHTML = $this->placeholderProcessor->processLoopVariable($innerHTML, $var, $item);
// Process placeholders in loop content
$placeholderReplacer = $this->container->get(PlaceholderReplacer::class);
@@ -184,51 +219,6 @@ final class ForProcessor implements DomProcessor
return $value;
}
/**
* Replaces loop variable placeholders in the HTML content
*/
private function replaceLoopVariables(string $html, string $varName, mixed $item): string
{
$pattern = '/{{\\s*' . preg_quote($varName, '/') . '\\.([\\w]+)\\s*}}/';
return preg_replace_callback(
$pattern,
function ($matches) use ($item) {
$property = $matches[1];
if (is_array($item) && array_key_exists($property, $item)) {
$value = $item[$property];
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if ($value instanceof RawHtml) {
return $value->content;
}
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
} elseif (is_object($item) && isset($item->$property)) {
$value = $item->$property;
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if ($value instanceof RawHtml) {
return $value->content;
}
return htmlspecialchars((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
// Return placeholder unchanged if property not found
return $matches[0];
},
$html
);
}
/**
* Collects content from sibling nodes when <for> is treated as self-closing
*/

View File

@@ -18,12 +18,15 @@ final readonly class ForStringProcessor implements StringProcessor
public function process(string $content, RenderContext $context): string
{
error_log("🔧🔧🔧 ForStringProcessor::process() CALLED - Template: " . $context->template);
error_log("🔧 ForStringProcessor: Processing content, looking for <for> tags");
error_log("🔧 ForStringProcessor: Processing content, looking for <for> tags and foreach attributes");
error_log("🔧 ForStringProcessor: Content contains '<for': " . (strpos($content, '<for') !== false ? 'YES' : 'NO'));
error_log("🔧 ForStringProcessor: Content contains 'foreach=': " . (strpos($content, 'foreach=') !== false ? 'YES' : 'NO'));
error_log("🔧 ForStringProcessor: Available data keys: " . implode(', ', array_keys($context->data)));
// Process nested <for> loops iteratively from innermost to outermost
$result = $content;
// FIRST: Process foreach attributes (must be done before <for> tags to handle nested cases)
$result = $this->processForeachAttributes($content, $context);
// THEN: Process nested <for> loops iteratively from innermost to outermost
$maxIterations = 10; // Prevent infinite loops
$iteration = 0;
@@ -209,4 +212,146 @@ final readonly class ForStringProcessor implements StringProcessor
return $result;
}
/**
* Process foreach attributes on elements: <tr foreach="$models as $model">
*/
private function processForeachAttributes(string $content, RenderContext $context): string
{
// Pattern to match elements with foreach attribute
// Matches: <tagname foreach="$array as $var" ... > ... </tagname>
// OR: <tagname foreach="array as var" ... > ... </tagname> (without $ prefix)
$pattern = '/<([a-zA-Z][a-zA-Z0-9]*)\s+([^>]*?)foreach\s*=\s*["\']?\$?([a-zA-Z_][a-zA-Z0-9_]*)\s+as\s+\$?([a-zA-Z_][a-zA-Z0-9_]*)["\']?([^>]*?)>(.*?)<\/\1>/s';
$result = preg_replace_callback(
$pattern,
function ($matches) use ($context) {
$tagName = $matches[1]; // e.g., "tr"
$beforeAttrs = $matches[2]; // attributes before foreach
$dataKey = $matches[3]; // e.g., "models"
$varName = $matches[4]; // e.g., "model"
$afterAttrs = $matches[5]; // attributes after foreach
$innerHTML = $matches[6]; // content inside the element
error_log("🔧 ForStringProcessor: Processing foreach attribute on <$tagName>");
error_log("🔧 ForStringProcessor: dataKey='$dataKey', varName='$varName'");
// Resolve the data array/collection
$data = $this->resolveValue($context->data, $dataKey);
if (! is_array($data) && ! is_iterable($data)) {
error_log("🔧 ForStringProcessor: Data for '$dataKey' is not iterable: " . gettype($data));
return ''; // Remove the element if data is not iterable
}
// Combine attributes (remove foreach attribute)
$allAttrs = trim($beforeAttrs . ' ' . $afterAttrs);
$output = '';
foreach ($data as $item) {
// Replace loop variables in innerHTML
$processedInnerHTML = $this->replaceForeachVariables($innerHTML, $varName, $item);
// Reconstruct the element
$output .= "<{$tagName}" . ($allAttrs ? " {$allAttrs}" : '') . ">{$processedInnerHTML}</{$tagName}>";
}
error_log("🔧 ForStringProcessor: foreach processing complete, generated " . count($data) . " elements");
return $output;
},
$content
);
return $result;
}
/**
* Replace foreach loop variables, supporting both {{ $var.property }} and {{ $var['property'] }} syntax
*/
private function replaceForeachVariables(string $template, string $varName, mixed $item): string
{
error_log("🔧 ForStringProcessor: replaceForeachVariables called for varName='$varName'");
// Pattern 1: {{ $var.property }} or {{ var.property }} (dot notation)
$patternDot = '/\{\{\s*\$?' . preg_quote($varName, '/') . '\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/';
// Pattern 2: {{ $var['property'] }} or {{ var['property'] }} (bracket notation with single quotes)
$patternBracketSingle = '/\{\{\s*\$?' . preg_quote($varName, '/') . '\s*\[\s*\'([^\']+)\'\s*\]\s*\}\}/';
// Pattern 3: {{ $var["property"] }} or {{ var["property"] }} (bracket notation with double quotes)
$patternBracketDouble = '/\{\{\s*\$?' . preg_quote($varName, '/') . '\s*\[\s*"([^"]+)"\s*\]\s*\}\}/';
// Replace all patterns
$result = preg_replace_callback(
$patternDot,
function ($matches) use ($item, $varName) {
return $this->resolveItemProperty($item, $matches[1], $varName, $matches[0]);
},
$template
);
$result = preg_replace_callback(
$patternBracketSingle,
function ($matches) use ($item, $varName) {
return $this->resolveItemProperty($item, $matches[1], $varName, $matches[0]);
},
$result
);
$result = preg_replace_callback(
$patternBracketDouble,
function ($matches) use ($item, $varName) {
return $this->resolveItemProperty($item, $matches[1], $varName, $matches[0]);
},
$result
);
return $result;
}
/**
* Resolve a property from an item (array or object)
*/
private function resolveItemProperty(mixed $item, string $property, string $varName, string $originalPlaceholder): string
{
error_log("🔧 ForStringProcessor: Resolving property '$property' from item");
if (is_array($item) && array_key_exists($property, $item)) {
$value = $item[$property];
error_log("🔧 ForStringProcessor: Found property '$property' in array with value: " . var_export($value, true));
// Handle boolean values properly
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
// Handle null
if ($value === null) {
return '';
}
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
} elseif (is_object($item) && isset($item->$property)) {
$value = $item->$property;
error_log("🔧 ForStringProcessor: Found property '$property' in object with value: " . var_export($value, true));
// Handle boolean values properly
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
// Handle null
if ($value === null) {
return '';
}
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
error_log("🔧 ForStringProcessor: Property '$property' not found, returning unchanged placeholder");
// Return placeholder unchanged if property not found
return $originalPlaceholder;
}
}

View File

@@ -55,8 +55,9 @@ final class PlaceholderReplacer implements StringProcessor
// Standard Variablen und Methoden: {{ $item.getRelativeFile() }} or {{ item.getRelativeFile() }}
// Supports both old and new syntax for backwards compatibility
// Also supports array bracket syntax: {{ $model['key'] }} or {{ $model["key"] }}
return preg_replace_callback(
'/{{\\s*\\$?([\\w.]+)(?:\\(\\s*([^)]*)\\s*\\))?\\s*}}/',
'/{{\\s*\\$?([\\w.\\[\\]\'\"]+)(?:\\(\\s*([^)]*)\\s*\\))?\\s*}}/',
function ($matches) use ($context) {
$expression = $matches[1];
$params = isset($matches[2]) ? trim($matches[2]) : null;
@@ -276,16 +277,34 @@ final class PlaceholderReplacer implements StringProcessor
private function resolveValue(array $data, string $expr): mixed
{
$keys = explode('.', $expr);
// Handle array bracket syntax: $var['key'] or $var["key"]
// Can be chained: $var['key1']['key2'] or mixed: $var.prop['key']
$originalExpr = $expr;
$value = $data;
foreach ($keys as $key) {
if (is_array($value) && array_key_exists($key, $value)) {
$value = $value[$key];
} elseif (is_object($value) && isset($value->$key)) {
$value = $value->$key;
} else {
return null;
// Split expression into parts, handling both dot notation and bracket notation
$pattern = '/([\\w]+)|\\[([\'"])([^\\2]+?)\\2\\]/';
preg_match_all($pattern, $expr, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
if (!empty($match[1])) {
// Dot notation: variable.property
$key = $match[1];
if (is_array($value) && array_key_exists($key, $value)) {
$value = $value[$key];
} elseif (is_object($value) && isset($value->$key)) {
$value = $value->$key;
} else {
return null;
}
} elseif (!empty($match[3])) {
// Bracket notation: variable['key'] or variable["key"]
$key = $match[3];
if (is_array($value) && array_key_exists($key, $value)) {
$value = $value[$key];
} else {
return null;
}
}
}

View File

@@ -12,6 +12,7 @@ use App\Framework\Discovery\Results\DiscoveryRegistry;
use App\Framework\Performance\PerformanceService;
use App\Framework\View\Dom\Transformer\AssetInjectorTransformer;
use App\Framework\View\Dom\Transformer\CommentStripTransformer;
use App\Framework\View\Dom\Transformer\ForTransformer;
use App\Framework\View\Dom\Transformer\HoneypotTransformer;
use App\Framework\View\Dom\Transformer\IfTransformer;
use App\Framework\View\Dom\Transformer\LayoutTagTransformer;
@@ -19,7 +20,6 @@ use App\Framework\View\Dom\Transformer\MetaManipulatorTransformer;
use App\Framework\View\Dom\Transformer\WhitespaceCleanupTransformer;
use App\Framework\View\Dom\Transformer\XComponentTransformer;
use App\Framework\View\Loading\TemplateLoader;
use App\Framework\View\Processors\ForStringProcessor;
use App\Framework\View\Processors\PlaceholderReplacer;
use App\Framework\View\Processors\VoidElementsSelfClosingProcessor;
@@ -33,11 +33,12 @@ final readonly class TemplateRendererInitializer
#[Initializer]
public function __invoke(): TemplateRenderer
{
// AST Transformers (new approach)
// AST Transformers (new approach) - Modern template processing
$astTransformers = [
// Core transformers (order matters!)
LayoutTagTransformer::class, // Process <layout> tags FIRST (before other processing)
XComponentTransformer::class, // Process <x-*> components (LiveComponents + HtmlComponents)
ForTransformer::class, // Process foreach loops and <for> elements (BEFORE if/placeholders)
IfTransformer::class, // Conditional rendering (if/condition attributes)
MetaManipulatorTransformer::class, // Set meta tags from context
AssetInjectorTransformer::class, // Inject Vite assets (CSS/JS)
@@ -49,11 +50,9 @@ final readonly class TemplateRendererInitializer
// TODO: Migrate remaining DOM processors to AST transformers:
// - ComponentProcessor (for <component> tags) - COMPLEX, keep in DOM for now
// - TableProcessor (for table rendering) - OPTIONAL
// - ForProcessor (DOM-based, we already have ForStringProcessor) - HANDLED
// - FormProcessor (for form handling) - OPTIONAL
$strings = [
ForStringProcessor::class, // ForStringProcessor MUST run first to process <for> loops before DOM parsing
PlaceholderReplacer::class, // PlaceholderReplacer handles simple {{ }} replacements
VoidElementsSelfClosingProcessor::class,
];

View File

@@ -35,19 +35,49 @@ final readonly class TelegramSignatureProvider implements SignatureProvider
return hash_equals($secret, $signature);
}
/**
* Parse signature from header value
*
* For Telegram, the signature is simply the secret token value
*/
public function parseSignature(string $headerValue): \App\Framework\Webhook\ValueObjects\WebhookSignature
{
return new \App\Framework\Webhook\ValueObjects\WebhookSignature(
algorithm: 'token',
signature: $headerValue,
timestamp: null
);
}
/**
* Generate signature (not applicable for Telegram)
*
* Telegram doesn't generate signatures from payload.
* This method exists for SignatureProvider interface compliance.
*/
public function generate(string $payload, string $secret): string
public function generateSignature(string $payload, string $secret): string
{
// For Telegram, we just return the secret token
// It's sent as-is in the X-Telegram-Bot-Api-Secret-Token header
return $secret;
}
/**
* Get the expected header name for Telegram webhooks
*/
public function getSignatureHeader(): string
{
return 'X-Telegram-Bot-Api-Secret-Token';
}
/**
* Get provider name
*/
public function getProviderName(): string
{
return 'telegram';
}
public function getAlgorithm(): string
{
return 'token';